From a8a55f4e6843caf6c3ad40e8f0696c597b708366 Mon Sep 17 00:00:00 2001 From: "github-action[bot]" Date: Fri, 27 Feb 2026 19:57:57 +0100 Subject: [PATCH] Update On Fri Feb 27 19:57:56 CET 2026 --- .github/update.log | 1 + clash-meta/adapter/outbound/mieru.go | 35 +- clash-meta/adapter/outbound/mieru_test.go | 13 +- clash-meta/adapter/outboundgroup/groupbase.go | 2 +- clash-meta/docs/config.yaml | 4 + clash-meta/go.mod | 2 +- clash-meta/go.sum | 4 +- clash-meta/listener/inbound/mieru.go | 22 +- clash-meta/listener/inbound/mieru_test.go | 28 + .../.github/workflows/deps-build-linux.yaml | 6 +- .../.github/workflows/deps-build-macos.yaml | 2 +- .../workflows/deps-build-windows-nsis.yaml | 4 +- .../nyanpasu/src/components/ui/modal.tsx | 8 +- .../nyanpasu/src/components/ui/slider.tsx | 152 + clash-nyanpasu/package.json | 2 +- clash-nyanpasu/pnpm-lock.yaml | 132 +- filebrowser/frontend/src/api/files.ts | 5 +- .../src/components/files/ListingItem.vue | 37 +- .../frontend/src/components/prompts/Copy.vue | 45 +- .../frontend/src/components/prompts/Move.vue | 39 +- .../src/components/prompts/Prompts.vue | 4 +- .../src/components/prompts/ReplaceRename.vue | 57 - .../components/prompts/ResolveConflict.vue | 307 + .../src/components/prompts/Upload.vue | 27 +- filebrowser/frontend/src/i18n/en.json | 18 +- filebrowser/frontend/src/types/file.d.ts | 15 + filebrowser/frontend/src/types/upload.d.ts | 1 + filebrowser/frontend/src/utils/upload.ts | 41 +- .../frontend/src/views/files/FileListing.vue | 108 +- lede/package/libs/libnl-tiny/Makefile | 6 +- mieru/Makefile | 2 +- mieru/README.md | 1 + mieru/README.zh_CN.md | 1 + .../package/mieru/amd64/debian/DEBIAN/control | 2 +- .../build/package/mieru/amd64/rpm/mieru.spec | 2 +- .../package/mieru/arm64/debian/DEBIAN/control | 2 +- .../build/package/mieru/arm64/rpm/mieru.spec | 2 +- .../package/mita/amd64/debian/DEBIAN/control | 2 +- mieru/build/package/mita/amd64/rpm/mita.spec | 2 +- .../package/mita/arm64/debian/DEBIAN/control | 2 +- mieru/build/package/mita/arm64/rpm/mita.spec | 2 +- mieru/docs/server-install.md | 16 +- mieru/docs/server-install.zh_CN.md | 16 +- mieru/docs/traffic-pattern.md | 197 + mieru/docs/traffic-pattern.zh_CN.md | 197 + mieru/pkg/appctl/appctlpb/base.pb.go | 2 +- mieru/pkg/appctl/proto/base.proto | 2 +- mieru/pkg/version/current.go | 2 +- mihomo/adapter/outbound/mieru.go | 35 +- mihomo/adapter/outbound/mieru_test.go | 13 +- mihomo/adapter/outboundgroup/groupbase.go | 2 +- mihomo/docs/config.yaml | 4 + mihomo/go.mod | 2 +- mihomo/go.sum | 4 +- mihomo/listener/inbound/mieru.go | 22 +- mihomo/listener/inbound/mieru_test.go | 28 + .../resources/view/ddns-go/config.js | 4 +- .../luci-app-ddns-go/po/zh_Hans/ddns-go.po | 4 +- .../cbi/passwall/client/node_subscribe.lua | 14 +- .../model/cbi/passwall/client/type/ray.lua | 10 +- .../cbi/passwall/client/type/sing-box.lua | 12 +- .../cbi/passwall/server/type/hysteria2.lua | 4 +- .../cbi/passwall/server/type/sing-box.lua | 4 +- .../cbi/passwall/server/type/ss-rust.lua | 4 +- .../model/cbi/passwall/server/type/ss.lua | 4 +- .../model/cbi/passwall/server/type/ssr.lua | 4 +- .../luci-app-passwall/luasrc/passwall/api.lua | 15 + .../luasrc/passwall/util_sing-box.lua | 21 +- .../passwall/node_config/link_share_man.htm | 30 +- .../root/usr/share/passwall/app.sh | 2 +- .../root/usr/share/passwall/subscribe.lua | 69 +- .../cbi/passwall2/client/node_subscribe.lua | 14 +- .../model/cbi/passwall2/client/type/ray.lua | 10 +- .../cbi/passwall2/client/type/sing-box.lua | 12 +- .../cbi/passwall2/server/type/hysteria2.lua | 4 +- .../cbi/passwall2/server/type/sing-box.lua | 4 +- .../cbi/passwall2/server/type/ss-rust.lua | 4 +- .../model/cbi/passwall2/server/type/ss.lua | 4 +- .../model/cbi/passwall2/server/type/ssr.lua | 4 +- .../luasrc/passwall2/api.lua | 15 + .../luasrc/passwall2/util_sing-box.lua | 21 +- .../passwall2/node_config/link_share_man.htm | 30 +- .../root/usr/share/passwall2/app.sh | 2 +- .../root/usr/share/passwall2/subscribe.lua | 69 +- sing-box/.fpm_pacman | 23 + sing-box/.github/CRONET_GO_VERSION | 2 +- sing-box/.github/workflows/build.yml | 2 +- sing-box/Makefile | 4 +- sing-box/clients/android/.editorconfig | 37 - sing-box/clients/android/.gitignore | 2 +- sing-box/clients/android/app/build.gradle | 202 + sing-box/clients/android/app/build.gradle.kts | 366 - .../1.json | 16 +- .../2.json | 55 - .../app/src/github/AndroidManifest.xml | 17 - .../nekohasekai/sfa/vendor/ApkDownloader.kt | 43 - .../sfa/vendor/GitHubUpdateChecker.kt | 149 - .../sfa/vendor/InstallResultReceiver.kt | 47 - .../nekohasekai/sfa/vendor/RootInstaller.kt | 79 - .../sfa/vendor/SystemPackageInstaller.kt | 45 - .../io/nekohasekai/sfa/vendor/UpdateWorker.kt | 90 - .../android/app/src/main/AndroidManifest.xml | 98 +- .../service/IXposedScopeCallback.aidl | 9 - .../libxposed/service/IXposedService.aidl | 36 - .../io/nekohasekai/sfa/bg/IRootService.aidl | 14 - .../nekohasekai/sfa/bg/IShizukuService.aidl | 12 - .../aidl/io/nekohasekai/sfa/bg/LogEntry.aidl | 3 - .../io/nekohasekai/sfa/bg/PackageEntry.aidl | 3 - .../nekohasekai/sfa/bg/ParceledListSlice.aidl | 3 - .../java/android/content/IIntentReceiver.java | 15 - .../java/android/content/IIntentSender.java | 29 - .../android/content/pm/IPackageInstaller.java | 26 - .../content/pm/IPackageInstallerSession.java | 14 - .../libxposed/service/RemotePreferences.java | 238 - .../libxposed/service/XposedProvider.java | 73 - .../libxposed/service/XposedService.java | 372 - .../service/XposedServiceHelper.java | 72 - .../java/io/nekohasekai/sfa/Application.kt | 46 +- .../sfa/WorkingDirectoryProvider.kt | 174 - .../nekohasekai/sfa/bg/AppChangeReceiver.kt | 55 +- .../io/nekohasekai/sfa/bg/BootReceiver.kt | 2 + .../java/io/nekohasekai/sfa/bg/BoxService.kt | 255 +- .../nekohasekai/sfa/bg/DebugInfoExporter.kt | 312 - .../sfa/bg/DefaultNetworkListener.kt | 193 +- .../sfa/bg/DefaultNetworkMonitor.kt | 27 +- .../io/nekohasekai/sfa/bg/LocalResolver.kt | 129 +- .../java/io/nekohasekai/sfa/bg/LogEntry.java | 67 - .../io/nekohasekai/sfa/bg/PackageEntry.java | 40 - .../nekohasekai/sfa/bg/ParceledListSlice.java | 152 - .../sfa/bg/PlatformInterfaceWrapper.kt | 137 +- .../io/nekohasekai/sfa/bg/ProxyService.kt | 17 +- .../java/io/nekohasekai/sfa/bg/RootClient.kt | 106 - .../java/io/nekohasekai/sfa/bg/RootServer.kt | 37 - .../io/nekohasekai/sfa/bg/ServiceBinder.kt | 6 +- .../nekohasekai/sfa/bg/ServiceConnection.kt | 31 +- .../nekohasekai/sfa/bg/ServiceNotification.kt | 50 +- .../java/io/nekohasekai/sfa/bg/TileService.kt | 19 +- .../nekohasekai/sfa/bg/UpdateProfileWork.kt | 23 +- .../java/io/nekohasekai/sfa/bg/VPNService.kt | 90 +- .../io/nekohasekai/sfa/compose/LineChart.kt | 131 - .../nekohasekai/sfa/compose/MainActivity.kt | 1244 --- .../sfa/compose/base/BaseViewModel.kt | 74 - .../sfa/compose/base/GlobalEventBus.kt | 33 - .../compose/base/SelectableMessageDialog.kt | 56 - .../nekohasekai/sfa/compose/base/UiEvent.kt | 43 - .../nekohasekai/sfa/compose/base/UiState.kt | 11 - .../sfa/compose/component/ServiceStatusBar.kt | 207 - .../sfa/compose/component/UpdateDialog.kt | 86 - .../sfa/compose/component/qr/QRCodeDialog.kt | 65 - .../compose/component/qr/QRSBitmapState.kt | 108 - .../sfa/compose/component/qr/QRSDialog.kt | 295 - .../sfa/compose/component/qr/QRScanSheet.kt | 362 - .../sfa/compose/model/Connection.kt | 114 - .../sfa/compose/model/ConnectionFilters.kt | 15 - .../navigation/NavigationDestinations.kt | 50 - .../sfa/compose/navigation/ProfileRoutes.kt | 11 - .../sfa/compose/navigation/SFANavigation.kt | 297 - .../screen/configuration/NewProfileScreen.kt | 590 -- .../configuration/NewProfileViewModel.kt | 319 - .../configuration/ProfileImportHandler.kt | 386 - .../connections/ConnectionDetailsScreen.kt | 364 - .../screen/connections/ConnectionItem.kt | 221 - .../screen/connections/ConnectionsScreen.kt | 594 -- .../connections/ConnectionsViewModel.kt | 282 - .../compose/screen/dashboard/ClashModeCard.kt | 183 - .../screen/dashboard/ConnectionsCard.kt | 94 - .../screen/dashboard/DashboardCardRenderer.kt | 130 - .../screen/dashboard/DashboardScreen.kt | 314 - .../dashboard/DashboardSettingsBottomSheet.kt | 437 - .../screen/dashboard/DashboardViewModel.kt | 766 -- .../sfa/compose/screen/dashboard/DebugCard.kt | 94 - .../screen/dashboard/DownloadTrafficCard.kt | 79 - .../compose/screen/dashboard/GroupsCard.kt | 660 -- .../screen/dashboard/ProfilePickerSheet.kt | 490 -- .../screen/dashboard/ProfileSelectorButton.kt | 95 - .../compose/screen/dashboard/ProfilesCard.kt | 905 --- .../screen/dashboard/SystemProxyCard.kt | 62 - .../screen/dashboard/UploadTrafficCard.kt | 79 - .../screen/dashboard/groups/GroupsScreen.kt | 503 -- .../dashboard/groups/GroupsViewModel.kt | 320 - .../compose/screen/log/BaseLogViewModel.kt | 153 - .../sfa/compose/screen/log/HookLogScreen.kt | 32 - .../compose/screen/log/HookLogViewModel.kt | 117 - .../sfa/compose/screen/log/LogModels.kt | 36 - .../sfa/compose/screen/log/LogScreen.kt | 949 --- .../sfa/compose/screen/log/LogViewModel.kt | 152 - .../compose/screen/log/LogViewerViewModel.kt | 25 - .../PrivilegeSettingsManageScreen.kt | 847 -- .../profile/EditProfileContentScreen.kt | 863 -- .../profile/EditProfileContentViewModel.kt | 598 -- .../screen/profile/EditProfileRoute.kt | 181 - .../screen/profile/EditProfileScreen.kt | 563 -- .../screen/profile/EditProfileViewModel.kt | 371 - .../screen/profile/IconSelectionDialog.kt | 184 - .../screen/profile/IconSelectionScreen.kt | 622 -- .../profile/ManualScrollTextProcessor.java | 132 - .../profileoverride/PerAppProxyScreen.kt | 1390 ---- .../compose/screen/qrscan/QRCodeSmartCrop.kt | 268 - .../compose/screen/qrscan/QRScanViewModel.kt | 403 - .../screen/qrscan/ZxingQRCodeAnalyzer.kt | 118 - .../screen/settings/AppSettingsScreen.kt | 1194 --- .../screen/settings/CoreSettingsScreen.kt | 349 - .../settings/PrivilegeSettingsScreen.kt | 968 --- .../screen/settings/ProfileOverrideScreen.kt | 687 -- .../screen/settings/ServiceSettingsScreen.kt | 176 - .../compose/screen/settings/SettingsScreen.kt | 366 - .../compose/shared/AppSelectionComponents.kt | 301 - .../io/nekohasekai/sfa/compose/theme/Color.kt | 32 - .../io/nekohasekai/sfa/compose/theme/Shape.kt | 14 - .../io/nekohasekai/sfa/compose/theme/Theme.kt | 69 - .../io/nekohasekai/sfa/compose/theme/Type.kt | 137 - .../sfa/compose/topbar/TopBarController.kt | 37 - .../sfa/compose/util/AnsiColorUtils.kt | 120 - .../sfa/compose/util/MaterialIconsLibrary.kt | 434 - .../sfa/compose/util/ProfileIcons.kt | 30 - .../sfa/compose/util/QRCodeGenerator.kt | 114 - .../sfa/compose/util/RelativeTimeFormatter.kt | 92 - .../sfa/compose/util/SheetNestedScroll.kt | 58 - .../sfa/compose/util/icons/AVIcons.kt | 306 - .../sfa/compose/util/icons/ActionIcons.kt | 983 --- .../sfa/compose/util/icons/AlertIcons.kt | 28 - .../compose/util/icons/CommunicationIcons.kt | 218 - .../sfa/compose/util/icons/ContentIcons.kt | 187 - .../sfa/compose/util/icons/DeviceIcons.kt | 469 -- .../sfa/compose/util/icons/EditorIcons.kt | 272 - .../sfa/compose/util/icons/FileIcons.kt | 112 - .../sfa/compose/util/icons/HardwareIcons.kt | 186 - .../sfa/compose/util/icons/IconCategory.kt | 10 - .../sfa/compose/util/icons/ImageIcons.kt | 509 -- .../sfa/compose/util/icons/MapsIcons.kt | 465 -- .../util/icons/MaterialIconsLibrary.kt | 102 - .../sfa/compose/util/icons/NavigationIcons.kt | 137 - .../compose/util/icons/NotificationIcons.kt | 186 - .../sfa/compose/util/icons/PlacesIcons.kt | 179 - .../sfa/compose/util/icons/SocialIcons.kt | 422 - .../sfa/compose/util/icons/ToggleIcons.kt | 44 - .../io/nekohasekai/sfa/constant/Action.kt | 2 +- .../java/io/nekohasekai/sfa/constant/Alert.kt | 4 +- .../java/io/nekohasekai/sfa/constant/Bugs.kt | 10 +- .../nekohasekai/sfa/constant/EnabledType.kt | 37 +- .../java/io/nekohasekai/sfa/constant/Path.kt | 2 +- .../sfa/constant/PerAppProxyUpdateType.kt | 41 + .../nekohasekai/sfa/constant/ServiceMode.kt | 2 +- .../nekohasekai/sfa/constant/SettingsKey.kt | 29 +- .../io/nekohasekai/sfa/constant/Status.kt | 2 +- .../io/nekohasekai/sfa/database/Profile.kt | 9 +- .../sfa/database/ProfileDatabase.kt | 18 +- .../sfa/database/ProfileManager.kt | 31 +- .../io/nekohasekai/sfa/database/Settings.kt | 76 +- .../nekohasekai/sfa/database/TypedProfile.kt | 35 +- .../database/preference/KeyValueDatabase.kt | 5 +- .../sfa/database/preference/KeyValueEntity.kt | 58 +- .../preference/RoomPreferenceDataStore.kt | 53 +- .../java/io/nekohasekai/sfa/ktx/Browsers.kt | 6 +- .../main/java/io/nekohasekai/sfa/ktx/Clips.kt | 2 +- .../java/io/nekohasekai/sfa/ktx/Colors.kt | 9 +- .../java/io/nekohasekai/sfa/ktx/Context.kt | 4 +- .../io/nekohasekai/sfa/ktx/Continuations.kt | 1 + .../java/io/nekohasekai/sfa/ktx/Dialogs.kt | 42 +- .../java/io/nekohasekai/sfa/ktx/Dimens.kt | 8 +- .../java/io/nekohasekai/sfa/ktx/Inputs.kt | 4 +- .../java/io/nekohasekai/sfa/ktx/Intents.kt | 6 +- .../io/nekohasekai/sfa/ktx/Preferences.kt | 50 +- .../main/java/io/nekohasekai/sfa/ktx/Room.kt | 2 +- .../java/io/nekohasekai/sfa/ktx/Shares.kt | 50 +- .../java/io/nekohasekai/sfa/ktx/Wrappers.kt | 34 +- .../sfa/qrs/ByteArrayExtensions.kt | 20 - .../java/io/nekohasekai/sfa/qrs/LubyCodec.kt | 321 - .../io/nekohasekai/sfa/qrs/QRSConstants.kt | 25 - .../java/io/nekohasekai/sfa/qrs/QRSDecoder.kt | 181 - .../java/io/nekohasekai/sfa/qrs/QRSEncoder.kt | 108 - .../sfa/qrs/SolitonDistribution.kt | 19 - .../io/nekohasekai/sfa/ui/MainActivity.kt | 435 + .../io/nekohasekai/sfa/ui/ShortcutActivity.kt | 94 + .../{compose/model => ui/dashboard}/Groups.kt | 15 +- .../sfa/ui/dashboard/GroupsFragment.kt | 328 + .../sfa/ui/dashboard/OverviewFragment.kt | 379 + .../nekohasekai/sfa/ui/debug/DebugActivity.kt | 19 + .../sfa/ui/debug/VPNScanActivity.kt | 262 + .../sfa/ui/main/ConfigurationFragment.kt | 281 + .../sfa/ui/main/DashboardFragment.kt | 181 + .../io/nekohasekai/sfa/ui/main/LogFragment.kt | 181 + .../sfa/ui/main/SettingsFragment.kt | 152 + .../sfa/ui/profile/EditProfileActivity.kt | 202 + .../ui/profile/EditProfileContentActivity.kt | 139 + .../sfa/ui/profile/NewProfileActivity.kt | 216 + .../sfa/ui/profile/QRScanActivity.kt | 235 + .../sfa/ui/profile/ZxingQRCodeAnalyzer.kt | 49 + .../ui/profileoverride/PerAppProxyActivity.kt | 782 ++ .../ProfileOverrideActivity.kt | 58 + .../sfa/ui/shared/AbstractActivity.kt | 87 + .../nekohasekai/sfa/ui/shared/QRCodeDialog.kt | 26 + .../sfa/update/UpdateCheckException.kt | 5 - .../io/nekohasekai/sfa/update/UpdateInfo.kt | 24 - .../io/nekohasekai/sfa/update/UpdateState.kt | 90 - .../io/nekohasekai/sfa/update/UpdateTrack.kt | 14 - .../sfa/utils/AppLifecycleObserver.kt | 54 - .../io/nekohasekai/sfa/utils/ColorUtils.kt | 22 +- .../io/nekohasekai/sfa/utils/CommandClient.kt | 131 +- .../sfa/utils/ConnectivityBinderUtils.kt | 45 - .../io/nekohasekai/sfa/utils/HTTPClient.kt | 5 +- .../nekohasekai/sfa/utils/HookErrorClient.kt | 48 - .../sfa/utils/HookModuleUpdateNotifier.kt | 77 - .../nekohasekai/sfa/utils/HookStatusClient.kt | 61 - .../io/nekohasekai/sfa/utils/MIUIUtils.kt | 4 +- .../sfa/utils/PrivilegeSettingsClient.kt | 68 - .../nekohasekai/sfa/utils/VpnDetectionTest.kt | 162 - .../sfa/vendor/PackageQueryStrategy.kt | 7 - .../PrivilegedAccessRequiredException.kt | 3 - .../sfa/vendor/PrivilegedServiceUtils.kt | 183 - .../sfa/vendor/SystemServiceHelperCompat.kt | 33 - .../nekohasekai/sfa/vendor/VendorInterface.kt | 59 +- .../nekohasekai/sfa/xposed/HookErrorStore.kt | 91 - .../sfa/xposed/HookModuleVersion.kt | 5 - .../nekohasekai/sfa/xposed/HookStatusKeys.kt | 10 - .../nekohasekai/sfa/xposed/HookStatusStore.kt | 23 - .../sfa/xposed/PrivilegeChecker.kt | 146 - .../sfa/xposed/PrivilegeSettingsStore.kt | 137 - .../io/nekohasekai/sfa/xposed/VpnAppStore.kt | 188 - .../nekohasekai/sfa/xposed/VpnHideContext.kt | 19 - .../io/nekohasekai/sfa/xposed/VpnSanitizer.kt | 153 - .../sfa/xposed/XposedActivation.kt | 36 - .../io/nekohasekai/sfa/xposed/XposedInit.kt | 54 - .../hooks/IConnectivityManager+onTransact.kt | 196 - .../sfa/xposed/hooks/SafeMethodHook.kt | 32 - .../io/nekohasekai/sfa/xposed/hooks/XHook.kt | 5 - ...ConnectivityManager+CONNECTIVITY_ACTION.kt | 36 - ...ConnectivityManager+PROXY_CHANGE_ACTION.kt | 83 - .../ConnectivityManager+getActiveNetwork.kt | 41 - ...onnectivityManager+getActiveNetworkInfo.kt | 51 - .../ConnectivityManager+getAllNetworkInfo.kt | 30 - .../ConnectivityManager+getAllNetworks.kt | 29 - .../ConnectivityManager+getDefaultProxy.kt | 43 - .../ConnectivityManager+getLinkProperties.kt | 94 - ...nectivityManager+getNetworkCapabilities.kt | 161 - .../ConnectivityManager+getNetworkForType.kt | 29 - .../ConnectivityManager+getNetworkInfo.kt | 54 - .../ConnectivityManager+requestNetwork.kt | 574 -- .../hidevpn/ConnectivityServiceHookHelper.kt | 530 -- .../NetworkCapabilities+writeToParcel.kt | 120 - .../hooks/hidevpn/NetworkInterface+getName.kt | 299 - .../PackageManager+getInstalledPackages.kt | 299 - .../main/res/drawable/ic_filter_list_24.xml | 10 - .../app/src/main/res/drawable/ic_pause_24.xml | 10 - .../src/main/res/drawable/ic_search_24.xml | 10 - .../main/res/layout/activity_add_profile.xml | 188 + .../res/layout/activity_config_override.xml | 98 + .../src/main/res/layout/activity_debug.xml | 75 + .../main/res/layout/activity_edit_profile.xml | 160 + .../layout/activity_edit_profile_content.xml | 40 + .../app/src/main/res/layout/activity_main.xml | 73 + .../src/main/res/layout/activity_qr_scan.xml | 26 + .../src/main/res/layout/activity_vpn_scan.xml | 40 + .../src/main/res/layout/dialog_progress.xml | 19 + .../res/layout/fragment_configuration.xml | 43 + .../main/res/layout/fragment_dashboard.xml | 23 + .../res/layout/fragment_dashboard_groups.xml | 23 + .../layout/fragment_dashboard_overview.xml | 527 ++ .../app/src/main/res/layout/fragment_log.xml | 43 + .../res/layout/fragment_qrcode_dialog.xml | 30 + .../src/main/res/layout/fragment_settings.xml | 409 + .../src/main/res/layout/sheet_add_profile.xml | 95 + .../main/res/layout/view_app_list_item0.xml | 64 + .../res/layout/view_clash_mode_button.xml | 20 + .../res/layout/view_configutation_item.xml | 62 + .../main/res/layout/view_dashboard_group.xml | 102 + .../res/layout/view_dashboard_group_item.xml | 81 + .../main/res/layout/view_log_text_item.xml | 19 + .../res/layout/view_prefenence_screen.xml | 10 + .../src/main/res/layout/view_profile_item.xml | 23 + .../src/main/res/layout/view_vpn_app_item.xml | 186 + .../app/src/main/res/menu/app_menu.xml | 2 +- .../app/src/main/res/menu/bottom_nav_menu.xml | 23 + .../main/res/menu/edit_configutation_menu.xml | 20 + .../app/src/main/res/menu/per_app_menu.xml | 2 +- .../app/src/main/res/menu/profile_menu.xml | 26 + .../main/res/navigation/mobile_navigation.xml | 33 + .../app/src/main/res/resources.properties | 1 - .../app/src/main/res/values-fa/strings.xml | 441 - .../src/main/res/values-ru-rRU/strings.xml | 447 -- .../src/main/res/values-zh-rCN/strings.xml | 469 +- .../src/main/res/values-zh-rTW/strings.xml | 440 - .../app/src/main/res/values/arrays.xml | 5 + .../app/src/main/res/values/colors.xml | 1 - .../app/src/main/res/values/strings.xml | 486 +- .../app/src/main/res/xml/shortcuts.xml | 13 + .../resources/META-INF/xposed/java_init.list | 1 - .../resources/META-INF/xposed/module.prop | 3 - .../main/resources/META-INF/xposed/scope.list | 1 - .../sfa/compat/LazyItemModifiers.kt | 9 - .../sfa/compat/OverscrollCompat.kt | 56 - .../sfa/compat/WindowSizeClassCompat.kt | 17 - .../sfa/vendor/PackageQueryManager.kt | 82 - .../sfa/compat/LazyItemModifiers.kt | 8 - .../sfa/compat/OverscrollCompat.kt | 63 - .../sfa/compat/WindowSizeClassCompat.kt | 10 - .../sfa/vendor/PackageQueryManager.kt | 92 - .../sfa/vendor/ShizukuInstaller.kt | 50 - .../sfa/vendor/ShizukuPackageManager.kt | 81 - .../sfa/vendor/ShizukuPrivilegedService.kt | 24 - .../vendor/ShizukuPrivilegedServiceClient.kt | 86 - .../android/app/src/other/AndroidManifest.xml | 30 - .../io/nekohasekai/sfa/vendor/ApkInstaller.kt | 67 - .../java/io/nekohasekai/sfa/vendor/Vendor.kt | 137 +- .../app/src/otherLegacy/AndroidManifest.xml | 12 - .../io/nekohasekai/sfa/vendor/ApkInstaller.kt | 48 - .../java/io/nekohasekai/sfa/vendor/Vendor.kt | 130 - .../android/app/src/play/AndroidManifest.xml | 24 - .../sfa/vendor/MLKitQRCodeAnalyzer.kt | 124 +- .../java/io/nekohasekai/sfa/vendor/Vendor.kt | 23 +- sing-box/clients/android/build.gradle | 15 + sing-box/clients/android/build.gradle.kts | 24 - .../clients/android/config/detekt/detekt.yml | 1066 --- sing-box/clients/android/gradle.properties | 5 +- .../clients/android/gradle/libs.versions.toml | 6 - .../gradle/wrapper/gradle-wrapper.properties | 2 +- .../{settings.gradle.kts => settings.gradle} | 7 +- .../android/third_party/libxposed-api/LICENSE | 201 - .../libxposed-api/build.gradle.kts | 26 - .../src/main/AndroidManifest.xml | 2 - .../github/libxposed/api/XposedInterface.java | 525 -- .../libxposed/api/XposedInterfaceWrapper.java | 171 - .../io/github/libxposed/api/XposedModule.java | 21 - .../libxposed/api/XposedModuleInterface.java | 108 - .../libxposed/api/errors/HookFailedError.java | 20 - .../api/errors/XposedFrameworkError.java | 19 - .../github/libxposed/api/utils/DexParser.java | 376 - sing-box/clients/android/version.properties | 4 +- sing-box/clients/apple/Localizable.xcstrings | 7086 ++++++++--------- .../apple/MacLibrary/MainViewModel.swift | 2 +- .../apple/sing-box.xcodeproj/project.pbxproj | 8 +- sing-box/common/dialer/default.go | 9 +- sing-box/constant/proxy.go | 2 + sing-box/daemon/started_service.go | 2 +- sing-box/docs/changelog.md | 10 +- sing-box/go.mod | 70 +- sing-box/go.sum | 140 +- sing-box/option/naive.go | 17 +- sing-box/option/oom_killer.go | 13 +- sing-box/protocol/tailscale/endpoint.go | 11 +- sing-box/service/oomkiller/config.go | 51 + sing-box/service/oomkiller/service.go | 82 +- sing-box/service/oomkiller/service_stub.go | 54 +- sing-box/service/oomkiller/service_timer.go | 158 + .../cbi/passwall/client/node_subscribe.lua | 14 +- .../model/cbi/passwall/client/type/ray.lua | 10 +- .../cbi/passwall/client/type/sing-box.lua | 12 +- .../cbi/passwall/server/type/hysteria2.lua | 4 +- .../cbi/passwall/server/type/sing-box.lua | 4 +- .../cbi/passwall/server/type/ss-rust.lua | 4 +- .../model/cbi/passwall/server/type/ss.lua | 4 +- .../model/cbi/passwall/server/type/ssr.lua | 4 +- .../luci-app-passwall/luasrc/passwall/api.lua | 15 + .../luasrc/passwall/util_sing-box.lua | 21 +- .../passwall/node_config/link_share_man.htm | 30 +- .../root/usr/share/passwall/app.sh | 2 +- .../root/usr/share/passwall/subscribe.lua | 69 +- .../cbi/passwall2/client/node_subscribe.lua | 14 +- .../model/cbi/passwall2/client/type/ray.lua | 10 +- .../cbi/passwall2/client/type/sing-box.lua | 12 +- .../cbi/passwall2/server/type/hysteria2.lua | 4 +- .../cbi/passwall2/server/type/sing-box.lua | 4 +- .../cbi/passwall2/server/type/ss-rust.lua | 4 +- .../model/cbi/passwall2/server/type/ss.lua | 4 +- .../model/cbi/passwall2/server/type/ssr.lua | 4 +- .../luasrc/passwall2/api.lua | 15 + .../luasrc/passwall2/util_sing-box.lua | 21 +- .../passwall2/node_config/link_share_man.htm | 30 +- .../root/usr/share/passwall2/app.sh | 2 +- .../root/usr/share/passwall2/subscribe.lua | 69 +- small/nikki/files/nikki.init | 9 +- small/nikki/files/scripts/firewall_include.sh | 15 +- small/sing-box/Makefile | 4 +- small/v2ray-geodata/Makefile | 4 +- v2rayn/.github/workflows/build-linux.yml | 6 +- v2rayn/.github/workflows/build-osx.yml | 2 +- .../workflows/build-windows-desktop.yml | 2 +- v2rayn/.github/workflows/build-windows.yml | 2 +- v2rayn/package-debian.sh | 15 +- v2rayn/package-rhel.sh | 370 +- v2rayn/v2rayN/Directory.Packages.props | 4 +- .../ServiceLib/Helper/DownloaderHelper.cs | 12 +- v2rayn/v2rayN/ServiceLib/Resx/ResUI.fr.resx | 22 +- .../CoreConfig/Singbox/SingboxDnsService.cs | 14 +- .../Singbox/SingboxOutboundService.cs | 9 +- .../V2ray/CoreConfigV2rayService.cs | 9 + .../CoreConfig/V2ray/V2rayDnsService.cs | 138 +- .../CoreConfig/V2ray/V2rayRoutingService.cs | 24 + v2rayng/.github/workflows/build.yml | 6 +- .../v2ray/ang/dto/SubscriptionUpdateResult.kt | 24 + .../com/v2ray/ang/handler/AngConfigManager.kt | 49 +- .../java/com/v2ray/ang/ui/MainActivity.kt | 19 +- .../com/v2ray/ang/ui/SubSettingActivity.kt | 18 +- .../com/v2ray/ang/viewmodel/MainViewModel.kt | 7 +- .../app/src/main/res/values-ar/strings.xml | 2 + .../app/src/main/res/values-bn/strings.xml | 2 + .../src/main/res/values-bqi-rIR/strings.xml | 2 + .../app/src/main/res/values-fa/strings.xml | 2 + .../app/src/main/res/values-ru/strings.xml | 2 + .../app/src/main/res/values-vi/strings.xml | 2 + .../src/main/res/values-zh-rCN/strings.xml | 2 + .../src/main/res/values-zh-rTW/strings.xml | 2 + .../app/src/main/res/values/strings.xml | 2 + 503 files changed, 14408 insertions(+), 48692 deletions(-) create mode 100644 clash-nyanpasu/frontend/nyanpasu/src/components/ui/slider.tsx delete mode 100644 filebrowser/frontend/src/components/prompts/ReplaceRename.vue create mode 100644 filebrowser/frontend/src/components/prompts/ResolveConflict.vue create mode 100644 mieru/docs/traffic-pattern.md create mode 100644 mieru/docs/traffic-pattern.zh_CN.md create mode 100644 sing-box/.fpm_pacman delete mode 100644 sing-box/clients/android/.editorconfig create mode 100644 sing-box/clients/android/app/build.gradle delete mode 100644 sing-box/clients/android/app/build.gradle.kts delete mode 100644 sing-box/clients/android/app/schemas/io.nekohasekai.sfa.database.ProfileDatabase/2.json delete mode 100644 sing-box/clients/android/app/src/github/AndroidManifest.xml delete mode 100644 sing-box/clients/android/app/src/github/java/io/nekohasekai/sfa/vendor/ApkDownloader.kt delete mode 100644 sing-box/clients/android/app/src/github/java/io/nekohasekai/sfa/vendor/GitHubUpdateChecker.kt delete mode 100644 sing-box/clients/android/app/src/github/java/io/nekohasekai/sfa/vendor/InstallResultReceiver.kt delete mode 100644 sing-box/clients/android/app/src/github/java/io/nekohasekai/sfa/vendor/RootInstaller.kt delete mode 100644 sing-box/clients/android/app/src/github/java/io/nekohasekai/sfa/vendor/SystemPackageInstaller.kt delete mode 100644 sing-box/clients/android/app/src/github/java/io/nekohasekai/sfa/vendor/UpdateWorker.kt delete mode 100644 sing-box/clients/android/app/src/main/aidl/io/github/libxposed/service/IXposedScopeCallback.aidl delete mode 100644 sing-box/clients/android/app/src/main/aidl/io/github/libxposed/service/IXposedService.aidl delete mode 100644 sing-box/clients/android/app/src/main/aidl/io/nekohasekai/sfa/bg/IRootService.aidl delete mode 100644 sing-box/clients/android/app/src/main/aidl/io/nekohasekai/sfa/bg/IShizukuService.aidl delete mode 100644 sing-box/clients/android/app/src/main/aidl/io/nekohasekai/sfa/bg/LogEntry.aidl delete mode 100644 sing-box/clients/android/app/src/main/aidl/io/nekohasekai/sfa/bg/PackageEntry.aidl delete mode 100644 sing-box/clients/android/app/src/main/aidl/io/nekohasekai/sfa/bg/ParceledListSlice.aidl delete mode 100644 sing-box/clients/android/app/src/main/java/android/content/IIntentReceiver.java delete mode 100644 sing-box/clients/android/app/src/main/java/android/content/IIntentSender.java delete mode 100644 sing-box/clients/android/app/src/main/java/android/content/pm/IPackageInstaller.java delete mode 100644 sing-box/clients/android/app/src/main/java/android/content/pm/IPackageInstallerSession.java delete mode 100644 sing-box/clients/android/app/src/main/java/io/github/libxposed/service/RemotePreferences.java delete mode 100644 sing-box/clients/android/app/src/main/java/io/github/libxposed/service/XposedProvider.java delete mode 100644 sing-box/clients/android/app/src/main/java/io/github/libxposed/service/XposedService.java delete mode 100644 sing-box/clients/android/app/src/main/java/io/github/libxposed/service/XposedServiceHelper.java delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/WorkingDirectoryProvider.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/DebugInfoExporter.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/LogEntry.java delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/PackageEntry.java delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/ParceledListSlice.java delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/RootClient.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/RootServer.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/LineChart.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/MainActivity.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/base/BaseViewModel.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/base/GlobalEventBus.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/base/SelectableMessageDialog.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/base/UiEvent.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/base/UiState.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/component/ServiceStatusBar.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/component/UpdateDialog.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/component/qr/QRCodeDialog.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/component/qr/QRSBitmapState.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/component/qr/QRSDialog.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/component/qr/QRScanSheet.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/model/Connection.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/model/ConnectionFilters.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/navigation/NavigationDestinations.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/navigation/ProfileRoutes.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/navigation/SFANavigation.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/configuration/NewProfileScreen.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/configuration/NewProfileViewModel.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/configuration/ProfileImportHandler.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionDetailsScreen.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionItem.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionsScreen.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionsViewModel.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/ClashModeCard.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/ConnectionsCard.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardCardRenderer.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardScreen.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardSettingsBottomSheet.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardViewModel.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DebugCard.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DownloadTrafficCard.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/GroupsCard.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/ProfilePickerSheet.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/ProfileSelectorButton.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/ProfilesCard.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/SystemProxyCard.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/UploadTrafficCard.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/groups/GroupsScreen.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/groups/GroupsViewModel.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/BaseLogViewModel.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/HookLogScreen.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/HookLogViewModel.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/LogModels.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/LogScreen.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/LogViewModel.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/LogViewerViewModel.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/privilegesettings/PrivilegeSettingsManageScreen.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileContentScreen.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileContentViewModel.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileRoute.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileScreen.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileViewModel.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/IconSelectionDialog.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/IconSelectionScreen.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/ManualScrollTextProcessor.java delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/profileoverride/PerAppProxyScreen.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/qrscan/QRCodeSmartCrop.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/qrscan/QRScanViewModel.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/qrscan/ZxingQRCodeAnalyzer.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/AppSettingsScreen.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/CoreSettingsScreen.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/PrivilegeSettingsScreen.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/ProfileOverrideScreen.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/ServiceSettingsScreen.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/SettingsScreen.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/shared/AppSelectionComponents.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/theme/Color.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/theme/Shape.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/theme/Theme.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/theme/Type.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/topbar/TopBarController.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/AnsiColorUtils.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/MaterialIconsLibrary.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/ProfileIcons.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/QRCodeGenerator.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/RelativeTimeFormatter.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/SheetNestedScroll.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/AVIcons.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/ActionIcons.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/AlertIcons.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/CommunicationIcons.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/ContentIcons.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/DeviceIcons.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/EditorIcons.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/FileIcons.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/HardwareIcons.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/IconCategory.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/ImageIcons.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/MapsIcons.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/MaterialIconsLibrary.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/NavigationIcons.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/NotificationIcons.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/PlacesIcons.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/SocialIcons.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/ToggleIcons.kt create mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/constant/PerAppProxyUpdateType.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/qrs/ByteArrayExtensions.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/qrs/LubyCodec.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/qrs/QRSConstants.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/qrs/QRSDecoder.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/qrs/QRSEncoder.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/qrs/SolitonDistribution.kt create mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/MainActivity.kt create mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/ShortcutActivity.kt rename sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/{compose/model => ui/dashboard}/Groups.kt (78%) create mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/dashboard/GroupsFragment.kt create mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/dashboard/OverviewFragment.kt create mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/debug/DebugActivity.kt create mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/debug/VPNScanActivity.kt create mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/main/ConfigurationFragment.kt create mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/main/DashboardFragment.kt create mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/main/LogFragment.kt create mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/main/SettingsFragment.kt create mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/profile/EditProfileActivity.kt create mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/profile/EditProfileContentActivity.kt create mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/profile/NewProfileActivity.kt create mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/profile/QRScanActivity.kt create mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/profile/ZxingQRCodeAnalyzer.kt create mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/profileoverride/PerAppProxyActivity.kt create mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/profileoverride/ProfileOverrideActivity.kt create mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/shared/AbstractActivity.kt create mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/shared/QRCodeDialog.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/update/UpdateCheckException.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/update/UpdateInfo.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/update/UpdateState.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/update/UpdateTrack.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/utils/AppLifecycleObserver.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/utils/ConnectivityBinderUtils.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/utils/HookErrorClient.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/utils/HookModuleUpdateNotifier.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/utils/HookStatusClient.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/utils/PrivilegeSettingsClient.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/utils/VpnDetectionTest.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/vendor/PackageQueryStrategy.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/vendor/PrivilegedAccessRequiredException.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/vendor/PrivilegedServiceUtils.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/vendor/SystemServiceHelperCompat.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/HookErrorStore.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/HookModuleVersion.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/HookStatusKeys.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/HookStatusStore.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/PrivilegeChecker.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/PrivilegeSettingsStore.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/VpnAppStore.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/VpnHideContext.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/VpnSanitizer.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/XposedActivation.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/XposedInit.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/IConnectivityManager+onTransact.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/SafeMethodHook.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/XHook.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+CONNECTIVITY_ACTION.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+PROXY_CHANGE_ACTION.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getActiveNetwork.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getActiveNetworkInfo.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getAllNetworkInfo.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getAllNetworks.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getDefaultProxy.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getLinkProperties.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getNetworkCapabilities.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getNetworkForType.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getNetworkInfo.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+requestNetwork.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityServiceHookHelper.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/NetworkCapabilities+writeToParcel.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/NetworkInterface+getName.kt delete mode 100644 sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpnapp/PackageManager+getInstalledPackages.kt delete mode 100644 sing-box/clients/android/app/src/main/res/drawable/ic_filter_list_24.xml delete mode 100644 sing-box/clients/android/app/src/main/res/drawable/ic_pause_24.xml delete mode 100644 sing-box/clients/android/app/src/main/res/drawable/ic_search_24.xml create mode 100644 sing-box/clients/android/app/src/main/res/layout/activity_add_profile.xml create mode 100644 sing-box/clients/android/app/src/main/res/layout/activity_config_override.xml create mode 100644 sing-box/clients/android/app/src/main/res/layout/activity_debug.xml create mode 100644 sing-box/clients/android/app/src/main/res/layout/activity_edit_profile.xml create mode 100644 sing-box/clients/android/app/src/main/res/layout/activity_edit_profile_content.xml create mode 100644 sing-box/clients/android/app/src/main/res/layout/activity_main.xml create mode 100644 sing-box/clients/android/app/src/main/res/layout/activity_qr_scan.xml create mode 100644 sing-box/clients/android/app/src/main/res/layout/activity_vpn_scan.xml create mode 100644 sing-box/clients/android/app/src/main/res/layout/dialog_progress.xml create mode 100644 sing-box/clients/android/app/src/main/res/layout/fragment_configuration.xml create mode 100644 sing-box/clients/android/app/src/main/res/layout/fragment_dashboard.xml create mode 100644 sing-box/clients/android/app/src/main/res/layout/fragment_dashboard_groups.xml create mode 100644 sing-box/clients/android/app/src/main/res/layout/fragment_dashboard_overview.xml create mode 100644 sing-box/clients/android/app/src/main/res/layout/fragment_log.xml create mode 100644 sing-box/clients/android/app/src/main/res/layout/fragment_qrcode_dialog.xml create mode 100644 sing-box/clients/android/app/src/main/res/layout/fragment_settings.xml create mode 100644 sing-box/clients/android/app/src/main/res/layout/sheet_add_profile.xml create mode 100644 sing-box/clients/android/app/src/main/res/layout/view_app_list_item0.xml create mode 100644 sing-box/clients/android/app/src/main/res/layout/view_clash_mode_button.xml create mode 100644 sing-box/clients/android/app/src/main/res/layout/view_configutation_item.xml create mode 100644 sing-box/clients/android/app/src/main/res/layout/view_dashboard_group.xml create mode 100644 sing-box/clients/android/app/src/main/res/layout/view_dashboard_group_item.xml create mode 100644 sing-box/clients/android/app/src/main/res/layout/view_log_text_item.xml create mode 100644 sing-box/clients/android/app/src/main/res/layout/view_prefenence_screen.xml create mode 100644 sing-box/clients/android/app/src/main/res/layout/view_profile_item.xml create mode 100644 sing-box/clients/android/app/src/main/res/layout/view_vpn_app_item.xml create mode 100644 sing-box/clients/android/app/src/main/res/menu/bottom_nav_menu.xml create mode 100644 sing-box/clients/android/app/src/main/res/menu/edit_configutation_menu.xml create mode 100644 sing-box/clients/android/app/src/main/res/menu/profile_menu.xml create mode 100644 sing-box/clients/android/app/src/main/res/navigation/mobile_navigation.xml delete mode 100644 sing-box/clients/android/app/src/main/res/resources.properties delete mode 100644 sing-box/clients/android/app/src/main/res/values-fa/strings.xml delete mode 100644 sing-box/clients/android/app/src/main/res/values-ru-rRU/strings.xml delete mode 100644 sing-box/clients/android/app/src/main/res/values-zh-rTW/strings.xml create mode 100644 sing-box/clients/android/app/src/main/res/xml/shortcuts.xml delete mode 100644 sing-box/clients/android/app/src/main/resources/META-INF/xposed/java_init.list delete mode 100644 sing-box/clients/android/app/src/main/resources/META-INF/xposed/module.prop delete mode 100644 sing-box/clients/android/app/src/main/resources/META-INF/xposed/scope.list delete mode 100644 sing-box/clients/android/app/src/minApi21/java/io/nekohasekai/sfa/compat/LazyItemModifiers.kt delete mode 100644 sing-box/clients/android/app/src/minApi21/java/io/nekohasekai/sfa/compat/OverscrollCompat.kt delete mode 100644 sing-box/clients/android/app/src/minApi21/java/io/nekohasekai/sfa/compat/WindowSizeClassCompat.kt delete mode 100644 sing-box/clients/android/app/src/minApi21/java/io/nekohasekai/sfa/vendor/PackageQueryManager.kt delete mode 100644 sing-box/clients/android/app/src/minApi23/java/io/nekohasekai/sfa/compat/LazyItemModifiers.kt delete mode 100644 sing-box/clients/android/app/src/minApi23/java/io/nekohasekai/sfa/compat/OverscrollCompat.kt delete mode 100644 sing-box/clients/android/app/src/minApi23/java/io/nekohasekai/sfa/compat/WindowSizeClassCompat.kt delete mode 100644 sing-box/clients/android/app/src/minApi23/java/io/nekohasekai/sfa/vendor/PackageQueryManager.kt delete mode 100644 sing-box/clients/android/app/src/minApi23/java/io/nekohasekai/sfa/vendor/ShizukuInstaller.kt delete mode 100644 sing-box/clients/android/app/src/minApi23/java/io/nekohasekai/sfa/vendor/ShizukuPackageManager.kt delete mode 100644 sing-box/clients/android/app/src/minApi23/java/io/nekohasekai/sfa/vendor/ShizukuPrivilegedService.kt delete mode 100644 sing-box/clients/android/app/src/minApi23/java/io/nekohasekai/sfa/vendor/ShizukuPrivilegedServiceClient.kt delete mode 100644 sing-box/clients/android/app/src/other/AndroidManifest.xml delete mode 100644 sing-box/clients/android/app/src/other/java/io/nekohasekai/sfa/vendor/ApkInstaller.kt delete mode 100644 sing-box/clients/android/app/src/otherLegacy/AndroidManifest.xml delete mode 100644 sing-box/clients/android/app/src/otherLegacy/java/io/nekohasekai/sfa/vendor/ApkInstaller.kt delete mode 100644 sing-box/clients/android/app/src/otherLegacy/java/io/nekohasekai/sfa/vendor/Vendor.kt delete mode 100644 sing-box/clients/android/app/src/play/AndroidManifest.xml create mode 100644 sing-box/clients/android/build.gradle delete mode 100644 sing-box/clients/android/build.gradle.kts delete mode 100644 sing-box/clients/android/config/detekt/detekt.yml delete mode 100644 sing-box/clients/android/gradle/libs.versions.toml rename sing-box/clients/android/{settings.gradle.kts => settings.gradle} (58%) delete mode 100644 sing-box/clients/android/third_party/libxposed-api/LICENSE delete mode 100644 sing-box/clients/android/third_party/libxposed-api/build.gradle.kts delete mode 100644 sing-box/clients/android/third_party/libxposed-api/src/main/AndroidManifest.xml delete mode 100644 sing-box/clients/android/third_party/libxposed-api/src/main/java/io/github/libxposed/api/XposedInterface.java delete mode 100644 sing-box/clients/android/third_party/libxposed-api/src/main/java/io/github/libxposed/api/XposedInterfaceWrapper.java delete mode 100644 sing-box/clients/android/third_party/libxposed-api/src/main/java/io/github/libxposed/api/XposedModule.java delete mode 100644 sing-box/clients/android/third_party/libxposed-api/src/main/java/io/github/libxposed/api/XposedModuleInterface.java delete mode 100644 sing-box/clients/android/third_party/libxposed-api/src/main/java/io/github/libxposed/api/errors/HookFailedError.java delete mode 100644 sing-box/clients/android/third_party/libxposed-api/src/main/java/io/github/libxposed/api/errors/XposedFrameworkError.java delete mode 100644 sing-box/clients/android/third_party/libxposed-api/src/main/java/io/github/libxposed/api/utils/DexParser.java create mode 100644 sing-box/service/oomkiller/config.go create mode 100644 sing-box/service/oomkiller/service_timer.go create mode 100644 v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/dto/SubscriptionUpdateResult.kt diff --git a/.github/update.log b/.github/update.log index 481681bd47..0a6336e814 100644 --- a/.github/update.log +++ b/.github/update.log @@ -1284,3 +1284,4 @@ Update On Mon Feb 23 20:25:31 CET 2026 Update On Tue Feb 24 20:15:23 CET 2026 Update On Wed Feb 25 20:17:35 CET 2026 Update On Thu Feb 26 20:04:04 CET 2026 +Update On Fri Feb 27 19:57:47 CET 2026 diff --git a/clash-meta/adapter/outbound/mieru.go b/clash-meta/adapter/outbound/mieru.go index af790b6e05..a70a042bf5 100644 --- a/clash-meta/adapter/outbound/mieru.go +++ b/clash-meta/adapter/outbound/mieru.go @@ -15,6 +15,7 @@ import ( mieruclient "github.com/enfein/mieru/v3/apis/client" mierucommon "github.com/enfein/mieru/v3/apis/common" mierumodel "github.com/enfein/mieru/v3/apis/model" + mierutp "github.com/enfein/mieru/v3/apis/trafficpattern" mierupb "github.com/enfein/mieru/v3/pkg/appctl/appctlpb" "google.golang.org/protobuf/proto" ) @@ -28,16 +29,17 @@ type Mieru struct { type MieruOption struct { BasicOption - Name string `proxy:"name"` - Server string `proxy:"server"` - Port int `proxy:"port,omitempty"` - PortRange string `proxy:"port-range,omitempty"` - Transport string `proxy:"transport"` - UDP bool `proxy:"udp,omitempty"` - UserName string `proxy:"username"` - Password string `proxy:"password"` - Multiplexing string `proxy:"multiplexing,omitempty"` - HandshakeMode string `proxy:"handshake-mode,omitempty"` + Name string `proxy:"name"` + Server string `proxy:"server"` + Port int `proxy:"port,omitempty"` + PortRange string `proxy:"port-range,omitempty"` + Transport string `proxy:"transport"` + UDP bool `proxy:"udp,omitempty"` + UserName string `proxy:"username"` + Password string `proxy:"password"` + Multiplexing string `proxy:"multiplexing,omitempty"` + HandshakeMode string `proxy:"handshake-mode,omitempty"` + TrafficPattern string `proxy:"traffic-pattern,omitempty"` } type mieruPacketDialer struct { @@ -291,6 +293,10 @@ func buildMieruClientConfig(option MieruOption) (*mieruclient.ClientConfig, erro if handshakeMode, ok := mierupb.HandshakeMode_value[option.HandshakeMode]; ok { config.Profile.HandshakeMode = (*mierupb.HandshakeMode)(&handshakeMode) } + if option.TrafficPattern != "" { + trafficPattern, _ := mierutp.Decode(option.TrafficPattern) + config.Profile.TrafficPattern = trafficPattern + } return config, nil } @@ -345,6 +351,15 @@ func validateMieruOption(option MieruOption) error { return fmt.Errorf("invalid handshake mode: %s", option.HandshakeMode) } } + if option.TrafficPattern != "" { + trafficPattern, err := mierutp.Decode(option.TrafficPattern) + if err != nil { + return fmt.Errorf("failed to decode traffic pattern %q: %w", option.TrafficPattern, err) + } + if err := mierutp.Validate(trafficPattern); err != nil { + return fmt.Errorf("invalid traffic pattern %q: %w", option.TrafficPattern, err) + } + } return nil } diff --git a/clash-meta/adapter/outbound/mieru_test.go b/clash-meta/adapter/outbound/mieru_test.go index d80b27696c..f47cf998b2 100644 --- a/clash-meta/adapter/outbound/mieru_test.go +++ b/clash-meta/adapter/outbound/mieru_test.go @@ -31,12 +31,13 @@ func TestNewMieru(t *testing.T) { }, { option: MieruOption{ - Name: "test", - Server: "example.com", - Port: 10003, - Transport: "UDP", - UserName: "test", - Password: "test", + Name: "test", + Server: "example.com", + Port: 10003, + Transport: "UDP", + UserName: "test", + Password: "test", + TrafficPattern: "GgQIARAK", }, wantBaseAddr: "example.com:10003", }, diff --git a/clash-meta/adapter/outboundgroup/groupbase.go b/clash-meta/adapter/outboundgroup/groupbase.go index 9e705677e4..1a1b3cfd0b 100644 --- a/clash-meta/adapter/outboundgroup/groupbase.go +++ b/clash-meta/adapter/outboundgroup/groupbase.go @@ -272,7 +272,7 @@ func (gb *GroupBase) onDialFailed(adapterType C.AdapterType, err error, fn func( log.Debugln("ProxyGroup: %s failed count: %d", gb.Name(), gb.failedTimes) if gb.failedTimes >= gb.maxFailedTimes { - log.Warnln("because %s failed multiple times, active health check", gb.Name()) + log.Warnln("because %s failed multiple times, activate health check", gb.Name()) fn() } } diff --git a/clash-meta/docs/config.yaml b/clash-meta/docs/config.yaml index 63e54dfec8..864a9b80e5 100644 --- a/clash-meta/docs/config.yaml +++ b/clash-meta/docs/config.yaml @@ -1077,6 +1077,8 @@ proxies: # socks5 # multiplexing: MULTIPLEXING_LOW # 如果想开启 0-RTT 握手,请设置为 HANDSHAKE_NO_WAIT,否则请设置为 HANDSHAKE_STANDARD。默认值为 HANDSHAKE_STANDARD # handshake-mode: HANDSHAKE_STANDARD + # 一个 base64 字符串用于微调网络行为 + # traffic-pattern: "" # sudoku - name: sudoku @@ -1645,6 +1647,8 @@ listeners: users: username1: password1 username2: password2 + # 一个 base64 字符串用于微调网络行为 + # traffic-pattern: "" - name: sudoku-in-1 type: sudoku diff --git a/clash-meta/go.mod b/clash-meta/go.mod index 8b777d0c63..8fc052bad1 100644 --- a/clash-meta/go.mod +++ b/clash-meta/go.mod @@ -6,7 +6,7 @@ require ( github.com/bahlo/generic-list-go v0.2.0 github.com/coreos/go-iptables v0.8.0 github.com/dlclark/regexp2 v1.11.5 - github.com/enfein/mieru/v3 v3.26.2 + github.com/enfein/mieru/v3 v3.28.0 github.com/gobwas/ws v1.4.0 github.com/gofrs/uuid/v5 v5.4.0 github.com/golang/snappy v1.0.0 diff --git a/clash-meta/go.sum b/clash-meta/go.sum index 724cc909e6..fb65418c46 100644 --- a/clash-meta/go.sum +++ b/clash-meta/go.sum @@ -20,8 +20,8 @@ github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZ github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dunglas/httpsfv v1.0.2 h1:iERDp/YAfnojSDJ7PW3dj1AReJz4MrwbECSSE59JWL0= github.com/dunglas/httpsfv v1.0.2/go.mod h1:zID2mqw9mFsnt7YC3vYQ9/cjq30q41W+1AnDwH8TiMg= -github.com/enfein/mieru/v3 v3.26.2 h1:U/2XJc+3vrJD9r815FoFdwToQFEcqSOzzzWIPPhjfEU= -github.com/enfein/mieru/v3 v3.26.2/go.mod h1:zJBUCsi5rxyvHM8fjFf+GLaEl4OEjjBXr1s5F6Qd3hM= +github.com/enfein/mieru/v3 v3.28.0 h1:4OsFPUIjKfQ6ymfyX1Laqz7h+zB8TxuK1m0isnYJ8ww= +github.com/enfein/mieru/v3 v3.28.0/go.mod h1:zJBUCsi5rxyvHM8fjFf+GLaEl4OEjjBXr1s5F6Qd3hM= github.com/ericlagergren/aegis v0.0.0-20250325060835-cd0defd64358 h1:kXYqH/sL8dS/FdoFjr12ePjnLPorPo2FsnrHNuXSDyo= github.com/ericlagergren/aegis v0.0.0-20250325060835-cd0defd64358/go.mod h1:hkIFzoiIPZYxdFOOLyDho59b7SrDfo+w3h+yWdlg45I= github.com/ericlagergren/polyval v0.0.0-20220411101811-e25bc10ba391 h1:8j2RH289RJplhA6WfdaPqzg1MjH2K8wX5e0uhAxrw2g= diff --git a/clash-meta/listener/inbound/mieru.go b/clash-meta/listener/inbound/mieru.go index 8a5718a636..5f7e4ffee9 100644 --- a/clash-meta/listener/inbound/mieru.go +++ b/clash-meta/listener/inbound/mieru.go @@ -14,6 +14,7 @@ import ( "google.golang.org/protobuf/proto" mieruserver "github.com/enfein/mieru/v3/apis/server" + mierutp "github.com/enfein/mieru/v3/apis/trafficpattern" mierupb "github.com/enfein/mieru/v3/pkg/appctl/appctlpb" ) @@ -26,8 +27,9 @@ type Mieru struct { type MieruOption struct { BaseOption - Transport string `inbound:"transport"` - Users map[string]string `inbound:"users"` + Transport string `inbound:"transport"` + Users map[string]string `inbound:"users"` + TrafficPattern string `inbound:"traffic-pattern"` } type mieruListenerFactory struct{} @@ -154,10 +156,13 @@ func buildMieruServerConfig(option *MieruOption, ports utils.IntRanges[uint16]) Password: proto.String(password), }) } + var trafficPattern *mierupb.TrafficPattern + trafficPattern, _ = mierutp.Decode(option.TrafficPattern) return &mieruserver.ServerConfig{ Config: &mierupb.ServerConfig{ - PortBindings: portBindings, - Users: users, + PortBindings: portBindings, + Users: users, + TrafficPattern: trafficPattern, }, StreamListenerFactory: mieruListenerFactory{}, PacketListenerFactory: mieruListenerFactory{}, @@ -179,5 +184,14 @@ func validateMieruOption(option *MieruOption) error { return fmt.Errorf("password is empty") } } + if option.TrafficPattern != "" { + trafficPattern, err := mierutp.Decode(option.TrafficPattern) + if err != nil { + return fmt.Errorf("failed to decode traffic pattern %q: %w", option.TrafficPattern, err) + } + if err := mierutp.Validate(trafficPattern); err != nil { + return fmt.Errorf("invalid traffic pattern %q: %w", option.TrafficPattern, err) + } + } return nil } diff --git a/clash-meta/listener/inbound/mieru_test.go b/clash-meta/listener/inbound/mieru_test.go index d57f4df5ee..af9e7826e2 100644 --- a/clash-meta/listener/inbound/mieru_test.go +++ b/clash-meta/listener/inbound/mieru_test.go @@ -61,6 +61,20 @@ func TestNewMieru(t *testing.T) { }, wantErr: false, }, + { + name: "valid traffic pattern", + args: args{ + option: &inbound.MieruOption{ + BaseOption: inbound.BaseOption{ + Port: "8080", + }, + Transport: "TCP", + Users: map[string]string{"user": "pass"}, + TrafficPattern: "GgQIARAK", + }, + }, + wantErr: false, + }, { name: "invalid - no port", args: args{ @@ -135,6 +149,20 @@ func TestNewMieru(t *testing.T) { }, wantErr: true, }, + { + name: "invalid traffic pattern", + args: args{ + option: &inbound.MieruOption{ + BaseOption: inbound.BaseOption{ + Port: "8080", + }, + Transport: "TCP", + Users: map[string]string{"user": "pass"}, + TrafficPattern: "1212ababXYYX", + }, + }, + wantErr: true, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/clash-nyanpasu/.github/workflows/deps-build-linux.yaml b/clash-nyanpasu/.github/workflows/deps-build-linux.yaml index e57ddc7bf5..0fc5c76b6a 100644 --- a/clash-nyanpasu/.github/workflows/deps-build-linux.yaml +++ b/clash-nyanpasu/.github/workflows/deps-build-linux.yaml @@ -230,13 +230,13 @@ jobs: - name: Upload AppImage to Github Artifact if: ${{ inputs.arch == 'x86_64' }} - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: Clash.Nyanpasu-linux-${{ inputs.arch }}-appimage path: ./backend/target/**/*.AppImage - name: Upload deb to Github Artifact - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: Clash.Nyanpasu-linux-${{ inputs.arch }}-deb path: | @@ -244,7 +244,7 @@ jobs: ./backend/target/**/*.deb.sha256 - name: Upload rpm to Github Artifact - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: Clash.Nyanpasu-linux-${{ inputs.arch }}-rpm path: | diff --git a/clash-nyanpasu/.github/workflows/deps-build-macos.yaml b/clash-nyanpasu/.github/workflows/deps-build-macos.yaml index 797e46d63b..283d49a1b6 100644 --- a/clash-nyanpasu/.github/workflows/deps-build-macos.yaml +++ b/clash-nyanpasu/.github/workflows/deps-build-macos.yaml @@ -132,7 +132,7 @@ jobs: TARGET_ARCH: ${{ inputs.aarch64 == true && 'aarch64' || 'x86_64' }} - name: Upload to Github Artifact - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: Clash.Nyanpasu-macOS-${{ inputs.aarch64 == true && 'aarch64' || 'amd64' }} path: | diff --git a/clash-nyanpasu/.github/workflows/deps-build-windows-nsis.yaml b/clash-nyanpasu/.github/workflows/deps-build-windows-nsis.yaml index 39e6cdf22e..b133d555d8 100644 --- a/clash-nyanpasu/.github/workflows/deps-build-windows-nsis.yaml +++ b/clash-nyanpasu/.github/workflows/deps-build-windows-nsis.yaml @@ -239,7 +239,7 @@ jobs: VITE_WIN_PORTABLE: 1 - name: Upload NSIS Installer - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: Clash.Nyanpasu-windows-${{ inputs.arch }}${{ inputs.fixed-webview == true && '-fixed-webview' || '' }}-nsis-installer path: | @@ -247,7 +247,7 @@ jobs: - name: Upload portable if: ${{ inputs.portable == true }} - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: Clash.Nyanpasu-windows-${{ inputs.arch }}${{ inputs.fixed-webview == true && '-fixed-webview' || '' }}-portable path: | diff --git a/clash-nyanpasu/frontend/nyanpasu/src/components/ui/modal.tsx b/clash-nyanpasu/frontend/nyanpasu/src/components/ui/modal.tsx index 08c01abb13..04b3489c81 100644 --- a/clash-nyanpasu/frontend/nyanpasu/src/components/ui/modal.tsx +++ b/clash-nyanpasu/frontend/nyanpasu/src/components/ui/modal.tsx @@ -46,14 +46,18 @@ export function ModalTrigger({ data-slot="modal-trigger" data-layout-id={layoutId} > - + {children} diff --git a/clash-nyanpasu/frontend/nyanpasu/src/components/ui/slider.tsx b/clash-nyanpasu/frontend/nyanpasu/src/components/ui/slider.tsx new file mode 100644 index 0000000000..a200f670e5 --- /dev/null +++ b/clash-nyanpasu/frontend/nyanpasu/src/components/ui/slider.tsx @@ -0,0 +1,152 @@ +import { clamp, motion, Transition } from 'framer-motion' +import { ComponentProps } from 'react' +import { cn } from '@nyanpasu/ui' +import { useControllableState } from '@radix-ui/react-use-controllable-state' + +const EDGE_OFFSET_PX = 16 +const PADDING_PX = 8 + +export function Slider({ + className, + defaultValue, + value, + min = 0, + max = 100, + disabled, + step = 1, + onValueChange, + onValueCommit, + onMouseUp, + onTouchEnd, + onKeyUp, + onBlur, + ...props +}: Omit< + ComponentProps<'input'>, + 'type' | 'value' | 'defaultValue' | 'min' | 'max' | 'onChange' +> & { + value?: number[] + defaultValue?: number[] + min?: number + max?: number + onValueChange?: (value: number[]) => void + onValueCommit?: (value: number[]) => void +}) { + const controlledValue = Array.isArray(value) + ? clamp(min, max, value[0] ?? min) + : undefined + + const defaultSliderValue = clamp( + min, + max, + Array.isArray(defaultValue) ? (defaultValue[0] ?? min) : min, + ) + + const [rawValue, setRawValue] = useControllableState({ + prop: controlledValue, + defaultProp: defaultSliderValue, + onChange: (nextValue) => { + onValueChange?.([clamp(min, max, nextValue)]) + }, + }) + + const currentValue = clamp(min, max, rawValue ?? min) + + const percentage = + max === min ? 0 : ((currentValue - min) / (max - min)) * 100 + + const ratio = percentage / 100 + + const thumbOffsetPx = EDGE_OFFSET_PX + PADDING_PX + const thumbLeft = `calc(${thumbOffsetPx}px + (100% - ${thumbOffsetPx * 2}px) * ${ratio})` + const rangeWidth = `calc(${thumbLeft} - ${PADDING_PX}px)` + const trackWidth = `calc(100% - ${thumbLeft} - ${PADDING_PX}px)` + + const motionTransition: Transition = disabled + ? { duration: 0 } + : { type: 'spring' as const, stiffness: 380, damping: 35, mass: 0.2 } + + const handleValueChange: ComponentProps<'input'>['onChange'] = (event) => { + const nextValue = clamp(min, max, Number(event.target.value)) + setRawValue(nextValue) + } + + const commitValue = () => { + onValueCommit?.([currentValue]) + } + + return ( +
+ + + + + + + { + commitValue() + onMouseUp?.(event) + }} + onTouchEnd={(event) => { + commitValue() + onTouchEnd?.(event) + }} + onKeyUp={(event) => { + commitValue() + onKeyUp?.(event) + }} + onBlur={(event) => { + commitValue() + onBlur?.(event) + }} + className="absolute inset-0 h-full w-full cursor-pointer appearance-none bg-transparent opacity-0 disabled:cursor-not-allowed" + {...props} + /> +
+ ) +} diff --git a/clash-nyanpasu/package.json b/clash-nyanpasu/package.json index 16aa4566dd..742d392738 100644 --- a/clash-nyanpasu/package.json +++ b/clash-nyanpasu/package.json @@ -62,7 +62,7 @@ "@tauri-apps/cli": "2.10.0", "@types/fs-extra": "11.0.4", "@types/lodash-es": "4.17.12", - "@types/node": "24.10.14", + "@types/node": "24.10.15", "autoprefixer": "10.4.27", "conventional-changelog-conventionalcommits": "9.1.0", "cross-env": "10.1.0", diff --git a/clash-nyanpasu/pnpm-lock.yaml b/clash-nyanpasu/pnpm-lock.yaml index 17fcdf18fe..e80fcf1376 100644 --- a/clash-nyanpasu/pnpm-lock.yaml +++ b/clash-nyanpasu/pnpm-lock.yaml @@ -24,7 +24,7 @@ importers: devDependencies: '@commitlint/cli': specifier: 20.4.2 - version: 20.4.2(@types/node@24.10.14)(typescript@5.9.3) + version: 20.4.2(@types/node@24.10.15)(typescript@5.9.3) '@commitlint/config-conventional': specifier: 20.4.2 version: 20.4.2 @@ -41,8 +41,8 @@ importers: specifier: 4.17.12 version: 4.17.12 '@types/node': - specifier: 24.10.14 - version: 24.10.14 + specifier: 24.10.15 + version: 24.10.15 autoprefixer: specifier: 10.4.27 version: 10.4.27(postcss@8.5.6) @@ -60,7 +60,7 @@ importers: version: 17.3.0 knip: specifier: 5.85.0 - version: 5.85.0(@types/node@24.10.14)(typescript@5.9.3) + version: 5.85.0(@types/node@24.10.15)(typescript@5.9.3) lint-staged: specifier: 16.2.7 version: 16.2.7 @@ -368,7 +368,7 @@ importers: version: 1.161.4(@tanstack/react-router@1.161.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@tanstack/router-core@1.161.4)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@tanstack/router-plugin': specifier: 1.161.4 - version: 1.161.4(@tanstack/react-router@1.161.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.10.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1)) + version: 1.161.4(@tanstack/react-router@1.161.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.10.15)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1)) '@tauri-apps/plugin-clipboard-manager': specifier: 2.3.2 version: 2.3.2 @@ -404,13 +404,13 @@ importers: version: 13.15.10 '@vitejs/plugin-legacy': specifier: 7.2.1 - version: 7.2.1(terser@5.36.0)(vite@7.3.1(@types/node@24.10.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1)) + version: 7.2.1(terser@5.36.0)(vite@7.3.1(@types/node@24.10.15)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1)) '@vitejs/plugin-react': specifier: 5.1.4 - version: 5.1.4(vite@7.3.1(@types/node@24.10.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1)) + version: 5.1.4(vite@7.3.1(@types/node@24.10.15)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1)) '@vitejs/plugin-react-swc': specifier: 4.2.3 - version: 4.2.3(vite@7.3.1(@types/node@24.10.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1)) + version: 4.2.3(vite@7.3.1(@types/node@24.10.15)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1)) change-case: specifier: 5.4.4 version: 5.4.4 @@ -449,19 +449,19 @@ importers: version: 13.15.26 vite: specifier: 7.3.1 - version: 7.3.1(@types/node@24.10.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1) + version: 7.3.1(@types/node@24.10.15)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1) vite-plugin-html: specifier: 3.2.2 - version: 3.2.2(vite@7.3.1(@types/node@24.10.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1)) + version: 3.2.2(vite@7.3.1(@types/node@24.10.15)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1)) vite-plugin-sass-dts: specifier: 1.3.35 - version: 1.3.35(postcss@8.5.6)(prettier@3.8.1)(sass-embedded@1.97.3)(vite@7.3.1(@types/node@24.10.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1)) + version: 1.3.35(postcss@8.5.6)(prettier@3.8.1)(sass-embedded@1.97.3)(vite@7.3.1(@types/node@24.10.15)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1)) vite-plugin-svgr: specifier: 4.5.0 - version: 4.5.0(rollup@4.46.2)(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1)) + version: 4.5.0(rollup@4.46.2)(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.15)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1)) vite-tsconfig-paths: specifier: 6.1.1 - version: 6.1.1(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1)) + version: 6.1.1(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.15)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1)) zod: specifier: 4.3.6 version: 4.3.6 @@ -497,7 +497,7 @@ importers: version: 19.2.14 '@vitejs/plugin-react': specifier: 5.1.4 - version: 5.1.4(vite@7.3.1(@types/node@24.10.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1)) + version: 5.1.4(vite@7.3.1(@types/node@24.10.15)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1)) ahooks: specifier: 3.9.6 version: 3.9.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -527,10 +527,10 @@ importers: version: 4.2.1 vite: specifier: 7.3.1 - version: 7.3.1(@types/node@24.10.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1) + version: 7.3.1(@types/node@24.10.15)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1) vite-tsconfig-paths: specifier: 6.1.1 - version: 6.1.1(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1)) + version: 6.1.1(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.15)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1)) devDependencies: '@emotion/react': specifier: 11.14.0 @@ -555,7 +555,7 @@ importers: version: 5.2.0(typescript@5.9.3) vite-plugin-dts: specifier: 4.5.4 - version: 4.5.4(@types/node@24.10.14)(rollup@4.46.2)(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1)) + version: 4.5.4(@types/node@24.10.15)(rollup@4.46.2)(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.15)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1)) scripts: dependencies: @@ -3894,8 +3894,8 @@ packages: '@types/ms@0.7.34': resolution: {integrity: sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==} - '@types/node@24.10.14': - resolution: {integrity: sha512-OowOUbD1lBCOFIPOZ8xnMIhgqA4sCutMiYOmPHL1PTLt5+y1XA+g2+yC9OOyz8p+deMZqPZLxfMjYIfrKsPeFg==} + '@types/node@24.10.15': + resolution: {integrity: sha512-BgjLoRuSr0MTI5wA6gMw9Xy0sFudAaUuvrnjgGx9wZ522fYYLA5SYJ+1Y30vTcJEG+DRCyDHx/gzQVfofYzSdg==} '@types/parse-json@4.0.2': resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} @@ -8485,11 +8485,11 @@ snapshots: hashery: 1.3.0 keyv: 5.6.0 - '@commitlint/cli@20.4.2(@types/node@24.10.14)(typescript@5.9.3)': + '@commitlint/cli@20.4.2(@types/node@24.10.15)(typescript@5.9.3)': dependencies: '@commitlint/format': 20.4.0 '@commitlint/lint': 20.4.2 - '@commitlint/load': 20.4.0(@types/node@24.10.14)(typescript@5.9.3) + '@commitlint/load': 20.4.0(@types/node@24.10.15)(typescript@5.9.3) '@commitlint/read': 20.4.0 '@commitlint/types': 20.4.0 tinyexec: 1.0.1 @@ -8536,14 +8536,14 @@ snapshots: '@commitlint/rules': 20.4.2 '@commitlint/types': 20.4.0 - '@commitlint/load@20.4.0(@types/node@24.10.14)(typescript@5.9.3)': + '@commitlint/load@20.4.0(@types/node@24.10.15)(typescript@5.9.3)': dependencies: '@commitlint/config-validator': 20.4.0 '@commitlint/execute-rule': 20.0.0 '@commitlint/resolve-extends': 20.4.0 '@commitlint/types': 20.4.0 cosmiconfig: 9.0.0(typescript@5.9.3) - cosmiconfig-typescript-loader: 6.1.0(@types/node@24.10.14)(cosmiconfig@9.0.0(typescript@5.9.3))(typescript@5.9.3) + cosmiconfig-typescript-loader: 6.1.0(@types/node@24.10.15)(cosmiconfig@9.0.0(typescript@5.9.3))(typescript@5.9.3) is-plain-obj: 4.1.0 lodash.mergewith: 4.6.2 picocolors: 1.1.1 @@ -8989,23 +8989,23 @@ snapshots: '@material/material-color-utilities@0.4.0': {} - '@microsoft/api-extractor-model@7.30.3(@types/node@24.10.14)': + '@microsoft/api-extractor-model@7.30.3(@types/node@24.10.15)': dependencies: '@microsoft/tsdoc': 0.15.1 '@microsoft/tsdoc-config': 0.17.1 - '@rushstack/node-core-library': 5.11.0(@types/node@24.10.14) + '@rushstack/node-core-library': 5.11.0(@types/node@24.10.15) transitivePeerDependencies: - '@types/node' - '@microsoft/api-extractor@7.51.0(@types/node@24.10.14)': + '@microsoft/api-extractor@7.51.0(@types/node@24.10.15)': dependencies: - '@microsoft/api-extractor-model': 7.30.3(@types/node@24.10.14) + '@microsoft/api-extractor-model': 7.30.3(@types/node@24.10.15) '@microsoft/tsdoc': 0.15.1 '@microsoft/tsdoc-config': 0.17.1 - '@rushstack/node-core-library': 5.11.0(@types/node@24.10.14) + '@rushstack/node-core-library': 5.11.0(@types/node@24.10.15) '@rushstack/rig-package': 0.5.3 - '@rushstack/terminal': 0.15.0(@types/node@24.10.14) - '@rushstack/ts-command-line': 4.23.5(@types/node@24.10.14) + '@rushstack/terminal': 0.15.0(@types/node@24.10.15) + '@rushstack/ts-command-line': 4.23.5(@types/node@24.10.15) lodash: 4.17.21 minimatch: 3.0.8 resolve: 1.22.8 @@ -10249,7 +10249,7 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.46.2': optional: true - '@rushstack/node-core-library@5.11.0(@types/node@24.10.14)': + '@rushstack/node-core-library@5.11.0(@types/node@24.10.15)': dependencies: ajv: 8.13.0 ajv-draft-04: 1.0.0(ajv@8.13.0) @@ -10260,23 +10260,23 @@ snapshots: resolve: 1.22.8 semver: 7.5.4 optionalDependencies: - '@types/node': 24.10.14 + '@types/node': 24.10.15 '@rushstack/rig-package@0.5.3': dependencies: resolve: 1.22.8 strip-json-comments: 3.1.1 - '@rushstack/terminal@0.15.0(@types/node@24.10.14)': + '@rushstack/terminal@0.15.0(@types/node@24.10.15)': dependencies: - '@rushstack/node-core-library': 5.11.0(@types/node@24.10.14) + '@rushstack/node-core-library': 5.11.0(@types/node@24.10.15) supports-color: 8.1.1 optionalDependencies: - '@types/node': 24.10.14 + '@types/node': 24.10.15 - '@rushstack/ts-command-line@4.23.5(@types/node@24.10.14)': + '@rushstack/ts-command-line@4.23.5(@types/node@24.10.15)': dependencies: - '@rushstack/terminal': 0.15.0(@types/node@24.10.14) + '@rushstack/terminal': 0.15.0(@types/node@24.10.15) '@types/argparse': 1.0.38 argparse: 1.0.10 string-argv: 0.3.2 @@ -10609,7 +10609,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/router-plugin@1.161.4(@tanstack/react-router@1.161.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.10.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1))': + '@tanstack/router-plugin@1.161.4(@tanstack/react-router@1.161.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.10.15)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.29.0) @@ -10626,7 +10626,7 @@ snapshots: zod: 3.25.76 optionalDependencies: '@tanstack/react-router': 1.161.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - vite: 7.3.1(@types/node@24.10.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1) + vite: 7.3.1(@types/node@24.10.15)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1) transitivePeerDependencies: - supports-color @@ -10766,7 +10766,7 @@ snapshots: '@types/adm-zip@0.5.7': dependencies: - '@types/node': 24.10.14 + '@types/node': 24.10.15 '@types/argparse@1.0.38': {} @@ -10927,7 +10927,7 @@ snapshots: '@types/fs-extra@11.0.4': dependencies: '@types/jsonfile': 6.1.4 - '@types/node': 24.10.14 + '@types/node': 24.10.15 '@types/geojson@7946.0.14': {} @@ -10943,7 +10943,7 @@ snapshots: '@types/jsonfile@6.1.4': dependencies: - '@types/node': 24.10.14 + '@types/node': 24.10.15 '@types/lodash-es@4.17.12': dependencies: @@ -10957,7 +10957,7 @@ snapshots: '@types/ms@0.7.34': {} - '@types/node@24.10.14': + '@types/node@24.10.15': dependencies: undici-types: 7.16.0 @@ -11209,7 +11209,7 @@ snapshots: '@ungap/structured-clone@1.2.0': {} - '@vitejs/plugin-legacy@7.2.1(terser@5.36.0)(vite@7.3.1(@types/node@24.10.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1))': + '@vitejs/plugin-legacy@7.2.1(terser@5.36.0)(vite@7.3.1(@types/node@24.10.15)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1))': dependencies: '@babel/core': 7.28.0 '@babel/plugin-transform-dynamic-import': 7.27.1(@babel/core@7.28.0) @@ -11224,19 +11224,19 @@ snapshots: regenerator-runtime: 0.14.1 systemjs: 6.15.1 terser: 5.36.0 - vite: 7.3.1(@types/node@24.10.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1) + vite: 7.3.1(@types/node@24.10.15)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1) transitivePeerDependencies: - supports-color - '@vitejs/plugin-react-swc@4.2.3(vite@7.3.1(@types/node@24.10.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1))': + '@vitejs/plugin-react-swc@4.2.3(vite@7.3.1(@types/node@24.10.15)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1))': dependencies: '@rolldown/pluginutils': 1.0.0-rc.2 '@swc/core': 1.15.11 - vite: 7.3.1(@types/node@24.10.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1) + vite: 7.3.1(@types/node@24.10.15)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1) transitivePeerDependencies: - '@swc/helpers' - '@vitejs/plugin-react@5.1.4(vite@7.3.1(@types/node@24.10.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1))': + '@vitejs/plugin-react@5.1.4(vite@7.3.1(@types/node@24.10.15)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) @@ -11244,7 +11244,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-rc.3 '@types/babel__core': 7.20.5 react-refresh: 0.18.0 - vite: 7.3.1(@types/node@24.10.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1) + vite: 7.3.1(@types/node@24.10.15)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1) transitivePeerDependencies: - supports-color @@ -11703,9 +11703,9 @@ snapshots: core-util-is@1.0.3: {} - cosmiconfig-typescript-loader@6.1.0(@types/node@24.10.14)(cosmiconfig@9.0.0(typescript@5.9.3))(typescript@5.9.3): + cosmiconfig-typescript-loader@6.1.0(@types/node@24.10.15)(cosmiconfig@9.0.0(typescript@5.9.3))(typescript@5.9.3): dependencies: - '@types/node': 24.10.14 + '@types/node': 24.10.15 cosmiconfig: 9.0.0(typescript@5.9.3) jiti: 2.6.1 typescript: 5.9.3 @@ -12658,10 +12658,10 @@ snapshots: kind-of@6.0.3: {} - knip@5.85.0(@types/node@24.10.14)(typescript@5.9.3): + knip@5.85.0(@types/node@24.10.15)(typescript@5.9.3): dependencies: '@nodelib/fs.walk': 1.2.8 - '@types/node': 24.10.14 + '@types/node': 24.10.15 fast-glob: 3.3.3 formatly: 0.3.0 jiti: 2.6.1 @@ -14653,9 +14653,9 @@ snapshots: - rollup - supports-color - vite-plugin-dts@4.5.4(@types/node@24.10.14)(rollup@4.46.2)(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1)): + vite-plugin-dts@4.5.4(@types/node@24.10.15)(rollup@4.46.2)(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.15)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1)): dependencies: - '@microsoft/api-extractor': 7.51.0(@types/node@24.10.14) + '@microsoft/api-extractor': 7.51.0(@types/node@24.10.15) '@rollup/pluginutils': 5.1.4(rollup@4.46.2) '@volar/typescript': 2.4.11 '@vue/language-core': 2.2.0(typescript@5.9.3) @@ -14666,13 +14666,13 @@ snapshots: magic-string: 0.30.17 typescript: 5.9.3 optionalDependencies: - vite: 7.3.1(@types/node@24.10.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1) + vite: 7.3.1(@types/node@24.10.15)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1) transitivePeerDependencies: - '@types/node' - rollup - supports-color - vite-plugin-html@3.2.2(vite@7.3.1(@types/node@24.10.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1)): + vite-plugin-html@3.2.2(vite@7.3.1(@types/node@24.10.15)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1)): dependencies: '@rollup/pluginutils': 4.2.1 colorette: 2.0.20 @@ -14686,38 +14686,38 @@ snapshots: html-minifier-terser: 6.1.0 node-html-parser: 5.4.2 pathe: 0.2.0 - vite: 7.3.1(@types/node@24.10.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1) + vite: 7.3.1(@types/node@24.10.15)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1) - vite-plugin-sass-dts@1.3.35(postcss@8.5.6)(prettier@3.8.1)(sass-embedded@1.97.3)(vite@7.3.1(@types/node@24.10.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1)): + vite-plugin-sass-dts@1.3.35(postcss@8.5.6)(prettier@3.8.1)(sass-embedded@1.97.3)(vite@7.3.1(@types/node@24.10.15)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1)): dependencies: postcss: 8.5.6 postcss-js: 4.0.1(postcss@8.5.6) prettier: 3.8.1 sass-embedded: 1.97.3 - vite: 7.3.1(@types/node@24.10.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1) + vite: 7.3.1(@types/node@24.10.15)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1) - vite-plugin-svgr@4.5.0(rollup@4.46.2)(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1)): + vite-plugin-svgr@4.5.0(rollup@4.46.2)(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.15)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1)): dependencies: '@rollup/pluginutils': 5.2.0(rollup@4.46.2) '@svgr/core': 8.1.0(typescript@5.9.3) '@svgr/plugin-jsx': 8.1.0(@svgr/core@8.1.0(typescript@5.9.3)) - vite: 7.3.1(@types/node@24.10.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1) + vite: 7.3.1(@types/node@24.10.15)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1) transitivePeerDependencies: - rollup - supports-color - typescript - vite-tsconfig-paths@6.1.1(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1)): + vite-tsconfig-paths@6.1.1(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.15)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1)): dependencies: debug: 4.4.3 globrex: 0.1.2 tsconfck: 3.0.3(typescript@5.9.3) - vite: 7.3.1(@types/node@24.10.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1) + vite: 7.3.1(@types/node@24.10.15)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1) transitivePeerDependencies: - supports-color - typescript - vite@7.3.1(@types/node@24.10.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1): + vite@7.3.1(@types/node@24.10.15)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1): dependencies: esbuild: 0.27.2 fdir: 6.5.0(picomatch@4.0.3) @@ -14726,7 +14726,7 @@ snapshots: rollup: 4.46.2 tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 24.10.14 + '@types/node': 24.10.15 fsevents: 2.3.3 jiti: 2.6.1 less: 4.2.0 diff --git a/filebrowser/frontend/src/api/files.ts b/filebrowser/frontend/src/api/files.ts index e16b75cd80..ed2de8ee9c 100644 --- a/filebrowser/frontend/src/api/files.ts +++ b/filebrowser/frontend/src/api/files.ts @@ -177,9 +177,12 @@ function moveCopy( for (const item of items) { const from = item.from; const to = encodeURIComponent(removePrefix(item.to ?? "")); + const finalOverwrite = + item.overwrite == undefined ? overwrite : item.overwrite; + const finalRename = item.rename == undefined ? rename : item.rename; const url = `${from}?action=${ copy ? "copy" : "rename" - }&destination=${to}&override=${overwrite}&rename=${rename}`; + }&destination=${to}&override=${finalOverwrite}&rename=${finalRename}`; promises.push(resourceAction(url, "PATCH")); } layoutStore.closeHovers(); diff --git a/filebrowser/frontend/src/components/files/ListingItem.vue b/filebrowser/frontend/src/components/files/ListingItem.vue index d75c2f9816..ce23c482ec 100644 --- a/filebrowser/frontend/src/components/files/ListingItem.vue +++ b/filebrowser/frontend/src/components/files/ListingItem.vue @@ -178,6 +178,10 @@ const drop = async (event: Event) => { from: fileStore.req?.items[i].url, to: props.url + encodeURIComponent(fileStore.req?.items[i].name), name: fileStore.req?.items[i].name, + size: fileStore.req?.items[i].size, + modified: fileStore.req?.items[i].modified, + overwrite: false, + rename: false, }); } } @@ -189,7 +193,7 @@ const drop = async (event: Event) => { const path = el.__vue__.url; const baseItems = (await api.fetch(path)).items; - const action = (overwrite: boolean, rename: boolean) => { + const action = (overwrite?: boolean, rename?: boolean) => { api .move(items, overwrite, rename) .then(() => { @@ -200,26 +204,35 @@ const drop = async (event: Event) => { const conflict = upload.checkConflict(items, baseItems); - let overwrite = false; - let rename = false; - - if (conflict) { + if (conflict.length > 0) { layoutStore.showHover({ - prompt: "replace-rename", - confirm: (event: Event, option: any) => { - overwrite = option == "overwrite"; - rename = option == "rename"; - + prompt: "resolve-conflict", + props: { + conflict: conflict, + }, + confirm: (event: Event, result: Array) => { event.preventDefault(); layoutStore.closeHovers(); - action(overwrite, rename); + for (let i = result.length - 1; i >= 0; i--) { + const item = result[i]; + if (item.checked.length == 2) { + items[item.index].rename = true; + } else if (item.checked.length == 1 && item.checked[0] == "origin") { + items[item.index].overwrite = true; + } else { + items.splice(item.index, 1); + } + } + if (items.length > 0) { + action(); + } }, }); return; } - action(overwrite, rename); + action(false, false); }; const itemClick = (event: Event | KeyboardEvent) => { diff --git a/filebrowser/frontend/src/components/prompts/Copy.vue b/filebrowser/frontend/src/components/prompts/Copy.vue index 09040e0a44..85810f30b3 100644 --- a/filebrowser/frontend/src/components/prompts/Copy.vue +++ b/filebrowser/frontend/src/components/prompts/Copy.vue @@ -91,6 +91,10 @@ export default { from: this.req.items[item].url, to: this.dest + encodeURIComponent(this.req.items[item].name), name: this.req.items[item].name, + size: this.req.items[item].size, + modified: this.req.items[item].modified, + overwrite: false, + rename: this.$route.path === this.dest, }); } @@ -118,36 +122,41 @@ export default { }); }; - if (this.$route.path === this.dest) { - this.closeHovers(); - action(false, true); - - return; - } - const dstItems = (await api.fetch(this.dest)).items; const conflict = upload.checkConflict(items, dstItems); - let overwrite = false; - let rename = false; - - if (conflict) { + if (conflict.length > 0) { this.showHover({ - prompt: "replace-rename", - confirm: (event, option) => { - overwrite = option == "overwrite"; - rename = option == "rename"; - + prompt: "resolve-conflict", + props: { + conflict: conflict, + }, + confirm: (event, result) => { event.preventDefault(); this.closeHovers(); - action(overwrite, rename); + for (let i = result.length - 1; i >= 0; i--) { + const item = result[i]; + if (item.checked.length == 2) { + items[item.index].rename = true; + } else if ( + item.checked.length == 1 && + item.checked[0] == "origin" + ) { + items[item.index].overwrite = true; + } else { + items.splice(item.index, 1); + } + } + if (items.length > 0) { + action(); + } }, }); return; } - action(overwrite, rename); + action(false, false); }, }, }; diff --git a/filebrowser/frontend/src/components/prompts/Move.vue b/filebrowser/frontend/src/components/prompts/Move.vue index 0fec867962..36a92469bd 100644 --- a/filebrowser/frontend/src/components/prompts/Move.vue +++ b/filebrowser/frontend/src/components/prompts/Move.vue @@ -97,6 +97,10 @@ export default { from: this.req.items[item].url, to: this.dest + encodeURIComponent(this.req.items[item].name), name: this.req.items[item].name, + size: this.req.items[item].size, + modified: this.req.items[item].modified, + overwrite: false, + rename: false, }); } @@ -121,26 +125,39 @@ export default { const dstItems = (await api.fetch(this.dest)).items; const conflict = upload.checkConflict(items, dstItems); - let overwrite = false; - let rename = false; - - if (conflict) { + if (conflict.length > 0) { this.showHover({ - prompt: "replace-rename", - confirm: (event, option) => { - overwrite = option == "overwrite"; - rename = option == "rename"; - + prompt: "resolve-conflict", + props: { + conflict: conflict, + files: items, + }, + confirm: (event, result) => { event.preventDefault(); this.closeHovers(); - action(overwrite, rename); + for (let i = result.length - 1; i >= 0; i--) { + const item = result[i]; + if (item.checked.length == 2) { + items[item.index].rename = true; + } else if ( + item.checked.length == 1 && + item.checked[0] == "origin" + ) { + items[item.index].overwrite = true; + } else { + items.splice(item.index, 1); + } + } + if (items.length > 0) { + action(); + } }, }); return; } - action(overwrite, rename); + action(false, false); }, }, }; diff --git a/filebrowser/frontend/src/components/prompts/Prompts.vue b/filebrowser/frontend/src/components/prompts/Prompts.vue index 1cfdbfb2c5..791d3ca910 100644 --- a/filebrowser/frontend/src/components/prompts/Prompts.vue +++ b/filebrowser/frontend/src/components/prompts/Prompts.vue @@ -23,11 +23,11 @@ import Copy from "./Copy.vue"; import NewFile from "./NewFile.vue"; import NewDir from "./NewDir.vue"; import Replace from "./Replace.vue"; -import ReplaceRename from "./ReplaceRename.vue"; import Share from "./Share.vue"; import ShareDelete from "./ShareDelete.vue"; import Upload from "./Upload.vue"; import DiscardEditorChanges from "./DiscardEditorChanges.vue"; +import ResolveConflict from "./ResolveConflict.vue"; const layoutStore = useLayoutStore(); @@ -44,12 +44,12 @@ const components = new Map([ ["newDir", NewDir], ["download", Download], ["replace", Replace], - ["replace-rename", ReplaceRename], ["share", Share], ["upload", Upload], ["share-delete", ShareDelete], ["deleteUser", DeleteUser], ["discardEditorChanges", DiscardEditorChanges], + ["resolve-conflict", ResolveConflict], ]); const modal = computed(() => { diff --git a/filebrowser/frontend/src/components/prompts/ReplaceRename.vue b/filebrowser/frontend/src/components/prompts/ReplaceRename.vue deleted file mode 100644 index 1d49d735bd..0000000000 --- a/filebrowser/frontend/src/components/prompts/ReplaceRename.vue +++ /dev/null @@ -1,57 +0,0 @@ - - - diff --git a/filebrowser/frontend/src/components/prompts/ResolveConflict.vue b/filebrowser/frontend/src/components/prompts/ResolveConflict.vue new file mode 100644 index 0000000000..e7bf9c7462 --- /dev/null +++ b/filebrowser/frontend/src/components/prompts/ResolveConflict.vue @@ -0,0 +1,307 @@ + + + + diff --git a/filebrowser/frontend/src/components/prompts/Upload.vue b/filebrowser/frontend/src/components/prompts/Upload.vue index 75f7951f26..19b1fbb175 100644 --- a/filebrowser/frontend/src/components/prompts/Upload.vue +++ b/filebrowser/frontend/src/components/prompts/Upload.vue @@ -69,18 +69,29 @@ const uploadInput = (event: Event) => { const path = route.path.endsWith("/") ? route.path : route.path + "/"; const conflict = upload.checkConflict(uploadFiles, fileStore.req!.items); - if (conflict) { + if (conflict.length > 0) { layoutStore.showHover({ - prompt: "replace", - action: (event: Event) => { - event.preventDefault(); - layoutStore.closeHovers(); - upload.handleFiles(uploadFiles, path, false); + prompt: "resolve-conflict", + props: { + conflict: conflict, + isUploadAction: true, }, - confirm: (event: Event) => { + confirm: (event: Event, result: Array) => { event.preventDefault(); layoutStore.closeHovers(); - upload.handleFiles(uploadFiles, path, true); + for (let i = result.length - 1; i >= 0; i--) { + const item = result[i]; + if (item.checked.length == 2) { + continue; + } else if (item.checked.length == 1 && item.checked[0] == "origin") { + uploadFiles[item.index].overwrite = true; + } else { + uploadFiles.splice(item.index, 1); + } + } + if (uploadFiles.length > 0) { + upload.handleFiles(uploadFiles, path); + } }, }); diff --git a/filebrowser/frontend/src/i18n/en.json b/filebrowser/frontend/src/i18n/en.json index 54b30b22ef..77d74d9f3f 100644 --- a/filebrowser/frontend/src/i18n/en.json +++ b/filebrowser/frontend/src/i18n/en.json @@ -48,7 +48,11 @@ "saveChanges": "Save changes", "editAsText": "Edit as Text", "increaseFontSize": "Increase font size", - "decreaseFontSize": "Decrease font size" + "decreaseFontSize": "Decrease font size", + "overrideAll": "Replace all files in destination folder", + "skipAll": "Skip all conflicting files", + "renameAll": "Rename all files (create a copy)", + "singleDecision": "Decide for each conflicting file" }, "download": { "downloadFile": "Download File", @@ -161,7 +165,17 @@ "uploadMessage": "Select an option to upload.", "optionalPassword": "Optional password", "resolution": "Resolution", - "discardEditorChanges": "Are you sure you wish to discard the changes you've made?" + "discardEditorChanges": "Are you sure you wish to discard the changes you've made?", + "replaceOrSkip": "Replace or skip files", + "resolveConflict": "Which files do you want to keep?", + "singleConflictResolve": "If you select both versions, a number will be added to the name of the copied file.", + "fastConflictResolve": "The destination folder there are {count} files with same name.", + "uploadingFiles": "Uploading files", + "filesInOrigin": "Files in origin", + "filesInDest": "Files in destination", + "override": "Overwrite", + "skip": "Skip", + "forbiddenError": "Forbidden Error" }, "search": { "images": "Images", diff --git a/filebrowser/frontend/src/types/file.d.ts b/filebrowser/frontend/src/types/file.d.ts index 6b9b6372cd..e6511b3919 100644 --- a/filebrowser/frontend/src/types/file.d.ts +++ b/filebrowser/frontend/src/types/file.d.ts @@ -52,6 +52,8 @@ type DownloadFormat = interface ClipItem { from: string; name: string; + size?: number; + modified?: string; } interface BreadCrumb { @@ -59,6 +61,19 @@ interface BreadCrumb { url: string; } +interface ConflictingItem { + lastModified: number | string | undefined; + size: number | undefined; +} + +interface ConflictingResource { + index: number; + name: string; + origin: ConflictingItem; + dest: ConflictingItem; + checked: Array<"origin" | "dest">; +} + interface CsvData { headers: string[]; rows: string[][]; diff --git a/filebrowser/frontend/src/types/upload.d.ts b/filebrowser/frontend/src/types/upload.d.ts index 4bad9e0650..5e5716acb1 100644 --- a/filebrowser/frontend/src/types/upload.d.ts +++ b/filebrowser/frontend/src/types/upload.d.ts @@ -17,6 +17,7 @@ interface UploadEntry { isDir: boolean; fullPath?: string; file?: File; + overwrite?: boolean; } type UploadList = UploadEntry[]; diff --git a/filebrowser/frontend/src/utils/upload.ts b/filebrowser/frontend/src/utils/upload.ts index e951cb43cb..a5a62b1dfc 100644 --- a/filebrowser/frontend/src/utils/upload.ts +++ b/filebrowser/frontend/src/utils/upload.ts @@ -3,16 +3,24 @@ import { useUploadStore } from "@/stores/upload"; import url from "@/utils/url"; export function checkConflict( - files: UploadList, + files: UploadList | Array, dest: ResourceItem[] -): boolean { +): ConflictingResource[] { if (typeof dest === "undefined" || dest === null) { dest = []; } + const conflictingFiles: ConflictingResource[] = []; const folder_upload = files[0].fullPath !== undefined; - const names = new Set(); + function getFile(name: string): ResourceItem | null { + for (const item of dest) { + if (item.name == name) return item; + } + + return null; + } + for (let i = 0; i < files.length; i++) { const file = files[i]; let name = file.name; @@ -24,10 +32,25 @@ export function checkConflict( } } - names.add(name); + const item = getFile(name); + if (item != null) { + conflictingFiles.push({ + index: i, + name: item.path, + origin: { + lastModified: file.modified || file.file?.lastModified, + size: file.size, + }, + dest: { + lastModified: item.modified, + size: item.size, + }, + checked: ["origin"], + }); + } } - return dest.some((d) => names.has(d.name)); + return conflictingFiles; } export function scanFiles(dt: DataTransfer): Promise { @@ -146,6 +169,12 @@ export function handleFiles( const type = file.isDir ? "dir" : detectType((file.file as File).type); - uploadStore.upload(path, file.name, file.file ?? null, overwrite, type); + uploadStore.upload( + path, + file.name, + file.file ?? null, + file.overwrite || overwrite, + type + ); } } diff --git a/filebrowser/frontend/src/views/files/FileListing.vue b/filebrowser/frontend/src/views/files/FileListing.vue index 0a86b359ee..f612ab1d16 100644 --- a/filebrowser/frontend/src/views/files/FileListing.vue +++ b/filebrowser/frontend/src/views/files/FileListing.vue @@ -628,6 +628,8 @@ const copyCut = (event: Event | KeyboardEvent): void => { items.push({ from: fileStore.req.items[i].url, name: fileStore.req.items[i].name, + size: fileStore.req.items[i].size, + modified: fileStore.req.items[i].modified, }); } @@ -651,7 +653,15 @@ const paste = (event: Event) => { for (const item of clipboardStore.items) { const from = item.from.endsWith("/") ? item.from.slice(0, -1) : item.from; const to = route.path + encodeURIComponent(item.name); - items.push({ from, to, name: item.name }); + items.push({ + from, + to, + name: item.name, + size: item.size, + modified: item.modified, + overwrite: false, + rename: clipboardStore.path == route.path, + }); } if (items.length === 0) { @@ -660,7 +670,7 @@ const paste = (event: Event) => { const preselect = removePrefix(route.path) + items[0].name; - let action = (overwrite: boolean, rename: boolean) => { + let action = (overwrite?: boolean, rename?: boolean) => { api .copy(items, overwrite, rename) .then(() => { @@ -683,34 +693,37 @@ const paste = (event: Event) => { }; } - if (clipboardStore.path == route.path) { - action(false, true); - - return; - } - const conflict = upload.checkConflict(items, fileStore.req!.items); - let overwrite = false; - let rename = false; - - if (conflict) { + if (conflict.length > 0) { layoutStore.showHover({ - prompt: "replace-rename", - confirm: (event: Event, option: string) => { - overwrite = option == "overwrite"; - rename = option == "rename"; - + prompt: "resolve-conflict", + props: { + conflict: conflict, + }, + confirm: (event: Event, result: Array) => { event.preventDefault(); layoutStore.closeHovers(); - action(overwrite, rename); + for (let i = result.length - 1; i >= 0; i--) { + const item = result[i]; + if (item.checked.length == 2) { + items[item.index].rename = true; + } else if (item.checked.length == 1 && item.checked[0] == "origin") { + items[item.index].overwrite = true; + } else { + items.splice(item.index, 1); + } + } + if (items.length > 0) { + action(); + } }, }); return; } - action(overwrite, rename); + action(false, false); }; const columnsResize = () => { @@ -806,20 +819,30 @@ const drop = async (event: DragEvent) => { const preselect = removePrefix(path) + (files[0].fullPath || files[0].name); - if (conflict) { + if (conflict.length > 0) { layoutStore.showHover({ - prompt: "replace", - action: (event: Event) => { - event.preventDefault(); - layoutStore.closeHovers(); - upload.handleFiles(files, path, false); - fileStore.preselect = preselect; + prompt: "resolve-conflict", + props: { + conflict: conflict, + isUploadAction: true, }, - confirm: (event: Event) => { + confirm: (event: Event, result: Array) => { event.preventDefault(); layoutStore.closeHovers(); - upload.handleFiles(files, path, true); - fileStore.preselect = preselect; + for (let i = result.length - 1; i >= 0; i--) { + const item = result[i]; + if (item.checked.length == 2) { + continue; + } else if (item.checked.length == 1 && item.checked[0] == "origin") { + files[item.index].overwrite = true; + } else { + files.splice(item.index, 1); + } + } + if (files.length > 0) { + upload.handleFiles(files, path, true); + fileStore.preselect = preselect; + } }, }); @@ -852,18 +875,29 @@ const uploadInput = (event: Event) => { const path = route.path.endsWith("/") ? route.path : route.path + "/"; const conflict = upload.checkConflict(uploadFiles, fileStore.req!.items); - if (conflict) { + if (conflict.length > 0) { layoutStore.showHover({ - prompt: "replace", - action: (event: Event) => { - event.preventDefault(); - layoutStore.closeHovers(); - upload.handleFiles(uploadFiles, path, false); + prompt: "resolve-conflict", + props: { + conflict: conflict, + isUploadAction: true, }, - confirm: (event: Event) => { + confirm: (event: Event, result: Array) => { event.preventDefault(); layoutStore.closeHovers(); - upload.handleFiles(uploadFiles, path, true); + for (let i = result.length - 1; i >= 0; i--) { + const item = result[i]; + if (item.checked.length == 2) { + continue; + } else if (item.checked.length == 1 && item.checked[0] == "origin") { + uploadFiles[item.index].overwrite = true; + } else { + uploadFiles.splice(item.index, 1); + } + } + if (uploadFiles.length > 0) { + upload.handleFiles(uploadFiles, path, true); + } }, }); diff --git a/lede/package/libs/libnl-tiny/Makefile b/lede/package/libs/libnl-tiny/Makefile index ef6acdb3a8..d52d354393 100644 --- a/lede/package/libs/libnl-tiny/Makefile +++ b/lede/package/libs/libnl-tiny/Makefile @@ -12,9 +12,9 @@ PKG_RELEASE:=1 PKG_SOURCE_PROTO:=git PKG_SOURCE_URL=$(PROJECT_GIT)/project/libnl-tiny.git -PKG_SOURCE_DATE:=2025-03-19 -PKG_SOURCE_VERSION:=c0df580adbd4d555ecc1962dbe88e91d75b67a4e -PKG_MIRROR_HASH:=e0a723e791549866e2d7e1f2aec2392343186645a2c7eae97e73e9fa72171d96 +PKG_SOURCE_DATE:=2025-12-02 +PKG_SOURCE_VERSION:=40493a655d8caa2ccf5206dde1e733abe2920432 +PKG_MIRROR_HASH:=bc237573e0d0ddf32af19445df1734774d330610167b041e034db642a6c84ff1 CMAKE_INSTALL:=1 PKG_LICENSE:=LGPL-2.1 diff --git a/mieru/Makefile b/mieru/Makefile index 23e1d9c5d3..561594b7be 100644 --- a/mieru/Makefile +++ b/mieru/Makefile @@ -32,7 +32,7 @@ PROJECT_NAME=$(shell basename "${ROOT}") # - pkg/version/current.go # # Use `tools/bump_version.sh` script to change all those files at one shot. -VERSION="3.27.0" +VERSION="3.28.0" # With .ONESHELL, each recipe is executed in a single shell instance. # This allows `cd` to affect subsequent commands in the same recipe. diff --git a/mieru/README.md b/mieru/README.md index 286dbab555..051028c003 100644 --- a/mieru/README.md +++ b/mieru/README.md @@ -48,6 +48,7 @@ The mieru proxy software suite consists of two parts, a client software called m 1. [Client Installation & Configuration](./docs/client-install.md) 1. [Client Installation & Configuration - OpenWrt](./docs/client-install-openwrt.md) 1. [Use mieru in Clash Verge Rev](./docs/third-party/clash-verge-rev.md) +1. [Traffic Pattern](./docs/traffic-pattern.md) 1. [Maintenance & Troubleshooting](./docs/operation.md) 1. [Security Guide](./docs/security.md) 1. [Compilation](./docs/compile.md) diff --git a/mieru/README.zh_CN.md b/mieru/README.zh_CN.md index 2f2c4ea229..bdf9d80c8f 100644 --- a/mieru/README.zh_CN.md +++ b/mieru/README.zh_CN.md @@ -46,6 +46,7 @@ mieru 代理软件由称为 mieru【見える】的客户端软件和称为 mita 1. [客户端安装与配置](./docs/client-install.zh_CN.md) 1. [客户端安装与配置 - OpenWrt](./docs/client-install-openwrt.zh_CN.md) 1. [在 Clash Verge Rev 中使用 mieru](./docs/third-party/clash-verge-rev.zh_CN.md) +1. [流量模式](./docs/traffic-pattern.zh_CN.md) 1. [运营维护与故障排查](./docs/operation.zh_CN.md) 1. [翻墙安全指南](./docs/security.zh_CN.md) 1. [编译](./docs/compile.zh_CN.md) diff --git a/mieru/build/package/mieru/amd64/debian/DEBIAN/control b/mieru/build/package/mieru/amd64/debian/DEBIAN/control index 6b724194b9..579957516f 100755 --- a/mieru/build/package/mieru/amd64/debian/DEBIAN/control +++ b/mieru/build/package/mieru/amd64/debian/DEBIAN/control @@ -1,5 +1,5 @@ Package: mieru -Version: 3.27.0 +Version: 3.28.0 Section: net Priority: optional Architecture: amd64 diff --git a/mieru/build/package/mieru/amd64/rpm/mieru.spec b/mieru/build/package/mieru/amd64/rpm/mieru.spec index 79e1767a19..5c97db1925 100644 --- a/mieru/build/package/mieru/amd64/rpm/mieru.spec +++ b/mieru/build/package/mieru/amd64/rpm/mieru.spec @@ -1,5 +1,5 @@ Name: mieru -Version: 3.27.0 +Version: 3.28.0 Release: 1%{?dist} Summary: Mieru proxy client License: GPLv3+ diff --git a/mieru/build/package/mieru/arm64/debian/DEBIAN/control b/mieru/build/package/mieru/arm64/debian/DEBIAN/control index 7de75c8d28..b4c1c24678 100755 --- a/mieru/build/package/mieru/arm64/debian/DEBIAN/control +++ b/mieru/build/package/mieru/arm64/debian/DEBIAN/control @@ -1,5 +1,5 @@ Package: mieru -Version: 3.27.0 +Version: 3.28.0 Section: net Priority: optional Architecture: arm64 diff --git a/mieru/build/package/mieru/arm64/rpm/mieru.spec b/mieru/build/package/mieru/arm64/rpm/mieru.spec index 79e1767a19..5c97db1925 100644 --- a/mieru/build/package/mieru/arm64/rpm/mieru.spec +++ b/mieru/build/package/mieru/arm64/rpm/mieru.spec @@ -1,5 +1,5 @@ Name: mieru -Version: 3.27.0 +Version: 3.28.0 Release: 1%{?dist} Summary: Mieru proxy client License: GPLv3+ diff --git a/mieru/build/package/mita/amd64/debian/DEBIAN/control b/mieru/build/package/mita/amd64/debian/DEBIAN/control index 9436e29c12..506e387d21 100755 --- a/mieru/build/package/mita/amd64/debian/DEBIAN/control +++ b/mieru/build/package/mita/amd64/debian/DEBIAN/control @@ -1,5 +1,5 @@ Package: mita -Version: 3.27.0 +Version: 3.28.0 Section: net Priority: optional Architecture: amd64 diff --git a/mieru/build/package/mita/amd64/rpm/mita.spec b/mieru/build/package/mita/amd64/rpm/mita.spec index 2990f7b9f4..6a40bda667 100644 --- a/mieru/build/package/mita/amd64/rpm/mita.spec +++ b/mieru/build/package/mita/amd64/rpm/mita.spec @@ -1,5 +1,5 @@ Name: mita -Version: 3.27.0 +Version: 3.28.0 Release: 1%{?dist} Summary: Mieru proxy server License: GPLv3+ diff --git a/mieru/build/package/mita/arm64/debian/DEBIAN/control b/mieru/build/package/mita/arm64/debian/DEBIAN/control index 1f7853977f..b3baa6beed 100755 --- a/mieru/build/package/mita/arm64/debian/DEBIAN/control +++ b/mieru/build/package/mita/arm64/debian/DEBIAN/control @@ -1,5 +1,5 @@ Package: mita -Version: 3.27.0 +Version: 3.28.0 Section: net Priority: optional Architecture: arm64 diff --git a/mieru/build/package/mita/arm64/rpm/mita.spec b/mieru/build/package/mita/arm64/rpm/mita.spec index cb860e51a2..718d4dec9b 100644 --- a/mieru/build/package/mita/arm64/rpm/mita.spec +++ b/mieru/build/package/mita/arm64/rpm/mita.spec @@ -1,5 +1,5 @@ Name: mita -Version: 3.27.0 +Version: 3.28.0 Release: 1%{?dist} Summary: Mieru proxy server License: GPLv3+ diff --git a/mieru/docs/server-install.md b/mieru/docs/server-install.md index 90b54c3309..556d02b30b 100644 --- a/mieru/docs/server-install.md +++ b/mieru/docs/server-install.md @@ -18,32 +18,32 @@ Or you can manually install and configure proxy server using the steps below. ```sh # Debian / Ubuntu - X86_64 -curl -LSO https://github.com/enfein/mieru/releases/download/v3.27.0/mita_3.27.0_amd64.deb +curl -LSO https://github.com/enfein/mieru/releases/download/v3.28.0/mita_3.28.0_amd64.deb # Debian / Ubuntu - ARM 64 -curl -LSO https://github.com/enfein/mieru/releases/download/v3.27.0/mita_3.27.0_arm64.deb +curl -LSO https://github.com/enfein/mieru/releases/download/v3.28.0/mita_3.28.0_arm64.deb # RedHat / CentOS / Rocky Linux - X86_64 -curl -LSO https://github.com/enfein/mieru/releases/download/v3.27.0/mita-3.27.0-1.x86_64.rpm +curl -LSO https://github.com/enfein/mieru/releases/download/v3.28.0/mita-3.28.0-1.x86_64.rpm # RedHat / CentOS / Rocky Linux - ARM 64 -curl -LSO https://github.com/enfein/mieru/releases/download/v3.27.0/mita-3.27.0-1.aarch64.rpm +curl -LSO https://github.com/enfein/mieru/releases/download/v3.28.0/mita-3.28.0-1.aarch64.rpm ``` ## Install mita package ```sh # Debian / Ubuntu - X86_64 -sudo dpkg -i mita_3.27.0_amd64.deb +sudo dpkg -i mita_3.28.0_amd64.deb # Debian / Ubuntu - ARM 64 -sudo dpkg -i mita_3.27.0_arm64.deb +sudo dpkg -i mita_3.28.0_arm64.deb # RedHat / CentOS / Rocky Linux - X86_64 -sudo rpm -Uvh --force mita-3.27.0-1.x86_64.rpm +sudo rpm -Uvh --force mita-3.28.0-1.x86_64.rpm # RedHat / CentOS / Rocky Linux - ARM 64 -sudo rpm -Uvh --force mita-3.27.0-1.aarch64.rpm +sudo rpm -Uvh --force mita-3.28.0-1.aarch64.rpm ``` Those instructions can also be used to upgrade the version of mita software package. diff --git a/mieru/docs/server-install.zh_CN.md b/mieru/docs/server-install.zh_CN.md index 10338f067d..b24f88741f 100644 --- a/mieru/docs/server-install.zh_CN.md +++ b/mieru/docs/server-install.zh_CN.md @@ -18,32 +18,32 @@ sudo python3 setup.py --lang=zh ```sh # Debian / Ubuntu - X86_64 -curl -LSO https://github.com/enfein/mieru/releases/download/v3.27.0/mita_3.27.0_amd64.deb +curl -LSO https://github.com/enfein/mieru/releases/download/v3.28.0/mita_3.28.0_amd64.deb # Debian / Ubuntu - ARM 64 -curl -LSO https://github.com/enfein/mieru/releases/download/v3.27.0/mita_3.27.0_arm64.deb +curl -LSO https://github.com/enfein/mieru/releases/download/v3.28.0/mita_3.28.0_arm64.deb # RedHat / CentOS / Rocky Linux - X86_64 -curl -LSO https://github.com/enfein/mieru/releases/download/v3.27.0/mita-3.27.0-1.x86_64.rpm +curl -LSO https://github.com/enfein/mieru/releases/download/v3.28.0/mita-3.28.0-1.x86_64.rpm # RedHat / CentOS / Rocky Linux - ARM 64 -curl -LSO https://github.com/enfein/mieru/releases/download/v3.27.0/mita-3.27.0-1.aarch64.rpm +curl -LSO https://github.com/enfein/mieru/releases/download/v3.28.0/mita-3.28.0-1.aarch64.rpm ``` ## 安装 mita 软件包 ```sh # Debian / Ubuntu - X86_64 -sudo dpkg -i mita_3.27.0_amd64.deb +sudo dpkg -i mita_3.28.0_amd64.deb # Debian / Ubuntu - ARM 64 -sudo dpkg -i mita_3.27.0_arm64.deb +sudo dpkg -i mita_3.28.0_arm64.deb # RedHat / CentOS / Rocky Linux - X86_64 -sudo rpm -Uvh --force mita-3.27.0-1.x86_64.rpm +sudo rpm -Uvh --force mita-3.28.0-1.x86_64.rpm # RedHat / CentOS / Rocky Linux - ARM 64 -sudo rpm -Uvh --force mita-3.27.0-1.aarch64.rpm +sudo rpm -Uvh --force mita-3.28.0-1.aarch64.rpm ``` 上述指令也可以用来升级 mita 软件包的版本。 diff --git a/mieru/docs/traffic-pattern.md b/mieru/docs/traffic-pattern.md new file mode 100644 index 0000000000..0e3e4b4f87 --- /dev/null +++ b/mieru/docs/traffic-pattern.md @@ -0,0 +1,197 @@ +# Traffic Pattern + +## Overview + +The traffic pattern feature allows mieru to modify network traffic characteristics to evade deep packet inspection (DPI) and traffic analysis. + +Traffic patterns can be configured independently on the client and server. The client and server do not need to use the same traffic pattern settings. + +## Configuration + +in the client: + +```sh +mieru apply config +``` + +in the server: + +```sh +mita apply config +``` + +Then restart the proxy service to make the changes effective. + +### Client Configuration Example + +```js +{ + "profiles": [ + { + "profileName": "default", + "user": { + "name": "ducaiguozei", + "password": "xijinping" + }, + "servers": [ + { + "ipAddress": "12.34.56.78", + "portBindings": [ + { + "portRange": "2012-2022", + "protocol": "TCP" + } + ] + } + ], + "trafficPattern": { + "unlockAll": false, + "tcpFragment": { + "enable": true, + "maxSleepMs": 10 + }, + "nonce": { + "type": "NONCE_TYPE_PRINTABLE", + "applyToAllUDPPacket": true, + "minLen": 6, + "maxLen": 8 + } + } + } + ], + "activeProfile": "default", + "rpcPort": 8964, + "socks5Port": 1080 +} +``` + +### Server Configuration Example + +```js +{ + "portBindings": [ + { + "portRange": "2012-2022", + "protocol": "TCP" + } + ], + "users": [ + { + "name": "ducaiguozei", + "password": "xijinping" + } + ], + "trafficPattern": { + "unlockAll": true, + "tcpFragment": { + "enable": false, + "maxSleepMs": 0 + }, + "nonce": { + "type": "NONCE_TYPE_FIXED", + "customHexStrings": ["00010203", "04050607"] + } + } +} +``` + +## Configuration Fields + +The `trafficPattern` object supports the following fields: + +1. [Optional] `seed` - An integer used to generate stable implicit traffic patterns for fields that are not explicitly set. With the same `seed` and `unlockAll` values, the generated implicit traffic patterns do not change. +2. [Optional] `unlockAll` - A boolean that controls the value range of implicit traffic pattern generation. When set to `true`, implicit patterns can use all possible options. When set to `false` (default), implicit patterns use only limited, conservative options. +3. [Optional] `tcpFragment` - An object that configures TCP fragmentation. This has no impact to UDP proxy protocol. +4. [Optional] `nonce` - An object that configures nonce prefix manipulation. + +## TCP Fragmentation + +TCP fragmentation splits some TCP packets into smaller fragments, making traffic harder to analyze. The `tcpFragment` object supports the following fields: + +1. [Optional] `enable` - A boolean that enables or disables TCP fragmentation. Default is `false`. +2. [Optional] `maxSleepMs` - An integer specifying the maximum sleep time in milliseconds between sending two fragments. The value must be between 0 and 100. A higher value increases the delay between fragments, which can be more effective at evading analysis but may reduce performance. + +Enabling TCP fragmentation may increase network latency. + +Example: + +```js +"tcpFragment": { + "enable": true, + "maxSleepMs": 10 +} +``` + +## Nonce Pattern + +The nonce pattern feature manipulates the nonce prefix in encrypted packets. The `nonce` object supports the following fields: + +1. [Optional] `type` - The nonce manipulation strategy. Possible values are: + - `NONCE_TYPE_RANDOM` - Do not make changes to the original random nonce. This is the default. + - `NONCE_TYPE_PRINTABLE` - Use printable ASCII characters (0x20 to 0x7E). + - `NONCE_TYPE_PRINTABLE_SUBSET` - Use a pre-defined subset of printable ASCII characters. + - `NONCE_TYPE_FIXED` - Use a customized nonce prefix from `customHexStrings`. If `customHexStrings` is not set, the behavior is the same as `NONCE_TYPE_RANDOM`. +2. [Optional] `applyToAllUDPPacket` - A boolean. If set to `true`, the pattern applies to every UDP packet. If `false` (default), the pattern only applies to the first UDP packet. +3. [Optional] `minLen` - The minimum number of bytes to manipulate. The value must be between 0 and 12. This field is ignored when `type` is `NONCE_TYPE_RANDOM` or `NONCE_TYPE_FIXED`. +4. [Optional] `maxLen` - The maximum number of bytes to manipulate. The value must be between 0 and 12. This field is ignored when `type` is `NONCE_TYPE_RANDOM` or `NONCE_TYPE_FIXED`. +5. [Optional] `customHexStrings` - A list of hex strings (without the `0x` prefix) that represent customized nonce prefixes. For example, the string `"00010203"` represents a 4-byte nonce prefix `[0, 1, 2, 3]`. Each nonce prefix cannot exceed 12 bytes. When multiple strings are provided, a random one is used each time. This field only has effect when `type` is `NONCE_TYPE_FIXED`. + +Example with printable nonce: + +```js +"nonce": { + "type": "NONCE_TYPE_PRINTABLE", + "applyToAllUDPPacket": true, + "minLen": 6, + "maxLen": 8 +} +``` + +Example with fixed nonce prefix: + +```js +"nonce": { + "type": "NONCE_TYPE_FIXED", + "customHexStrings": ["00010203", "04050607"] +} +``` + +## Implicit Pattern Generation + +When a traffic pattern is configured, mieru automatically generates values for fields that are not explicitly set. This is called implicit pattern generation. + +The `seed` field controls the generation. If `seed` is provided, the generated patterns are stable. If `seed` is not provided, the generated traffic pattern can be different in each machine and in each mieru version. + +The `unlockAll` field controls the range of generated values. + +Explicitly set fields are never affected by implicit generation. For example, if you set `tcpFragment.enable` to `true`, it will remain `true` regardless of the `seed` and `unlockAll` setting. + +## Viewing and Exporting + +To view the effective traffic pattern (including both explicit and implicitly values), run + +in the client: + +```sh +mieru describe effective-traffic-pattern +``` + +in the server: + +```sh +mita describe effective-traffic-pattern +``` + +You can export the traffic pattern as an encoded base64 string, which can be used by third party applications. + +in the client: + +```sh +mieru export traffic-pattern +``` + +in the server: + +```sh +mita export traffic-pattern +``` diff --git a/mieru/docs/traffic-pattern.zh_CN.md b/mieru/docs/traffic-pattern.zh_CN.md new file mode 100644 index 0000000000..251d5dd76c --- /dev/null +++ b/mieru/docs/traffic-pattern.zh_CN.md @@ -0,0 +1,197 @@ +# 流量模式 + +## 概述 + +流量模式功能允许 mieru 修改网络流量特征,以规避深度包检测(DPI)和流量分析。 + +流量模式可以在客户端和服务器上独立配置。客户端和服务器不需要使用相同的流量模式设置。 + +## 配置方法 + +客户端: + +```sh +mieru apply config +``` + +服务器: + +```sh +mita apply config +``` + +然后重启代理服务使更改生效。 + +### 客户端配置示例 + +```js +{ + "profiles": [ + { + "profileName": "default", + "user": { + "name": "ducaiguozei", + "password": "xijinping" + }, + "servers": [ + { + "ipAddress": "12.34.56.78", + "portBindings": [ + { + "portRange": "2012-2022", + "protocol": "TCP" + } + ] + } + ], + "trafficPattern": { + "unlockAll": false, + "tcpFragment": { + "enable": true, + "maxSleepMs": 10 + }, + "nonce": { + "type": "NONCE_TYPE_PRINTABLE", + "applyToAllUDPPacket": true, + "minLen": 6, + "maxLen": 8 + } + } + } + ], + "activeProfile": "default", + "rpcPort": 8964, + "socks5Port": 1080 +} +``` + +### 服务器配置示例 + +```js +{ + "portBindings": [ + { + "portRange": "2012-2022", + "protocol": "TCP" + } + ], + "users": [ + { + "name": "ducaiguozei", + "password": "xijinping" + } + ], + "trafficPattern": { + "unlockAll": true, + "tcpFragment": { + "enable": false, + "maxSleepMs": 0 + }, + "nonce": { + "type": "NONCE_TYPE_FIXED", + "customHexStrings": ["00010203", "04050607"] + } + } +} +``` + +## 配置字段说明 + +`trafficPattern` 对象支持以下字段: + +1. 【可选】`seed` - 一个整数,用于为未显式设置的字段生成稳定的隐式流量模式。如果 `seed` 和 `unlockAll` 的值不变,生成的隐式流量模式保持不变。 +2. 【可选】`unlockAll` - 一个布尔值,控制隐式流量模式的取值范围。设为 `true` 时,隐式模式可以使用所有可能的选项。设为 `false`(默认)时,隐式模式仅使用有限的保守选项。 +3. 【可选】`tcpFragment` - 配置 TCP 分片的对象。它不影响 UDP 代理协议。 +4. 【可选】`nonce` - 配置 Nonce 前缀操纵的对象。 + +## TCP 分片 + +TCP 分片功能将某些 TCP 数据包拆分为更小的分片,使流量更难被分析。`tcpFragment` 对象支持以下字段: + +1. 【可选】`enable` - 布尔值,启用或禁用 TCP 分片。默认为 `false`。 +2. 【可选】`maxSleepMs` - 整数,指定发送两个分片之间的最大休眠时间(毫秒)。值必须在 0 到 100 之间。较高的值会增加分片之间的延迟,可能更有效地规避分析,但会降低性能。 + +启用 TCP 分片可能会增加网络延迟。 + +示例: + +```js +"tcpFragment": { + "enable": true, + "maxSleepMs": 10 +} +``` + +## Nonce 模式 + +Nonce 模式功能操纵加密数据包中的 Nonce 前缀。`nonce` 对象支持以下字段: + +1. 【可选】`type` - Nonce 操纵策略。可选值为: + - `NONCE_TYPE_RANDOM` - 不修改原始随机 Nonce。这是默认值。 + - `NONCE_TYPE_PRINTABLE` - 使用可打印的 ASCII 字符(0x20 到 0x7E)。 + - `NONCE_TYPE_PRINTABLE_SUBSET` - 使用预定义的可打印 ASCII 字符子集。 + - `NONCE_TYPE_FIXED` - 使用 `customHexStrings` 中的自定义 Nonce 前缀。如果未设置 `customHexStrings`,行为与 `NONCE_TYPE_RANDOM` 相同。 +2. 【可选】`applyToAllUDPPacket` - 布尔值。设为 `true` 时,模式应用于每个 UDP 数据包。设为 `false`(默认)时,模式仅应用于第一个 UDP 数据包。 +3. 【可选】`minLen` - 要操纵的最小字节数。值必须在 0 到 12 之间。当 `type` 为 `NONCE_TYPE_RANDOM` 或 `NONCE_TYPE_FIXED` 时,此字段将被忽略。 +4. 【可选】`maxLen` - 要操纵的最大字节数。值必须在 0 到 12 之间。当 `type` 为 `NONCE_TYPE_RANDOM` 或 `NONCE_TYPE_FIXED` 时,此字段将被忽略。 +5. 【可选】`customHexStrings` - 十六进制字符串列表(不含 `0x` 前缀),表示自定义 Nonce 前缀。例如,字符串 `"00010203"` 表示 4 字节的 Nonce 前缀 `[0, 1, 2, 3]`。每个 Nonce 前缀不能超过 12 字节。提供多个字符串时,每次随机选择一个使用。此字段仅在 `type` 为 `NONCE_TYPE_FIXED` 时生效。 + +使用可打印 Nonce 的示例: + +```js +"nonce": { + "type": "NONCE_TYPE_PRINTABLE", + "applyToAllUDPPacket": true, + "minLen": 6, + "maxLen": 8 +} +``` + +使用固定 Nonce 前缀的示例: + +```js +"nonce": { + "type": "NONCE_TYPE_FIXED", + "customHexStrings": ["00010203", "04050607"] +} +``` + +## 隐式模式生成 + +配置流量模式后,mieru 会自动为未显式设置的字段生成值,这称为隐式模式生成。 + +`seed` 字段控制生成过程。如果提供了 `seed`,生成的模式保持稳定。如果未提供 `seed`,则生成的流量模式在不同机器和不同 mieru 版本之间会不一样。 + +`unlockAll` 字段控制生成值的范围。 + +显式设置的字段不会受到隐式生成的影响。例如,如果你将 `tcpFragment.enable` 设为 `true`,无论 `seed` 和 `unlockAll` 的设置如何,它都将保持 `true`。 + +## 查看和导出流量模式 + +要查看有效的流量模式(包括显式设置和隐式生成的值),运行: + +客户端: + +```sh +mieru describe effective-traffic-pattern +``` + +服务器: + +```sh +mita describe effective-traffic-pattern +``` + +你可以将流量模式导出为 base64 编码的字符串,以便在第三方应用上使用。 + +客户端: + +```sh +mieru export traffic-pattern +``` + +服务器: + +```sh +mita export traffic-pattern +``` diff --git a/mieru/pkg/appctl/appctlpb/base.pb.go b/mieru/pkg/appctl/appctlpb/base.pb.go index 1a6a450757..f50b4f69bc 100644 --- a/mieru/pkg/appctl/appctlpb/base.pb.go +++ b/mieru/pkg/appctl/appctlpb/base.pb.go @@ -792,7 +792,7 @@ type NoncePattern struct { // Strategy to manipulate nonce. Type *NonceType `protobuf:"varint,1,opt,name=type,proto3,enum=mieru.appctl.NonceType,oneof" json:"type,omitempty"` // If the pattern applies to each UDP packet, - // or only applies to UDP packets used to manage a session. + // or only applies to the first UDP packet. ApplyToAllUDPPacket *bool `protobuf:"varint,2,opt,name=applyToAllUDPPacket,proto3,oneof" json:"applyToAllUDPPacket,omitempty"` // Minimum number of bytes to manipulate. // It is ignored when NonceType is NONCE_TYPE_RANDOM or NONCE_TYPE_FIXED. diff --git a/mieru/pkg/appctl/proto/base.proto b/mieru/pkg/appctl/proto/base.proto index 71ee68d2d1..a0b6f283a6 100644 --- a/mieru/pkg/appctl/proto/base.proto +++ b/mieru/pkg/appctl/proto/base.proto @@ -158,7 +158,7 @@ message NoncePattern { optional NonceType type = 1; // If the pattern applies to each UDP packet, - // or only applies to UDP packets used to manage a session. + // or only applies to the first UDP packet. optional bool applyToAllUDPPacket = 2; // Minimum number of bytes to manipulate. diff --git a/mieru/pkg/version/current.go b/mieru/pkg/version/current.go index 82495fdd11..33b18fceef 100644 --- a/mieru/pkg/version/current.go +++ b/mieru/pkg/version/current.go @@ -16,5 +16,5 @@ package version const ( - AppVersion = "3.27.0" + AppVersion = "3.28.0" ) diff --git a/mihomo/adapter/outbound/mieru.go b/mihomo/adapter/outbound/mieru.go index af790b6e05..a70a042bf5 100644 --- a/mihomo/adapter/outbound/mieru.go +++ b/mihomo/adapter/outbound/mieru.go @@ -15,6 +15,7 @@ import ( mieruclient "github.com/enfein/mieru/v3/apis/client" mierucommon "github.com/enfein/mieru/v3/apis/common" mierumodel "github.com/enfein/mieru/v3/apis/model" + mierutp "github.com/enfein/mieru/v3/apis/trafficpattern" mierupb "github.com/enfein/mieru/v3/pkg/appctl/appctlpb" "google.golang.org/protobuf/proto" ) @@ -28,16 +29,17 @@ type Mieru struct { type MieruOption struct { BasicOption - Name string `proxy:"name"` - Server string `proxy:"server"` - Port int `proxy:"port,omitempty"` - PortRange string `proxy:"port-range,omitempty"` - Transport string `proxy:"transport"` - UDP bool `proxy:"udp,omitempty"` - UserName string `proxy:"username"` - Password string `proxy:"password"` - Multiplexing string `proxy:"multiplexing,omitempty"` - HandshakeMode string `proxy:"handshake-mode,omitempty"` + Name string `proxy:"name"` + Server string `proxy:"server"` + Port int `proxy:"port,omitempty"` + PortRange string `proxy:"port-range,omitempty"` + Transport string `proxy:"transport"` + UDP bool `proxy:"udp,omitempty"` + UserName string `proxy:"username"` + Password string `proxy:"password"` + Multiplexing string `proxy:"multiplexing,omitempty"` + HandshakeMode string `proxy:"handshake-mode,omitempty"` + TrafficPattern string `proxy:"traffic-pattern,omitempty"` } type mieruPacketDialer struct { @@ -291,6 +293,10 @@ func buildMieruClientConfig(option MieruOption) (*mieruclient.ClientConfig, erro if handshakeMode, ok := mierupb.HandshakeMode_value[option.HandshakeMode]; ok { config.Profile.HandshakeMode = (*mierupb.HandshakeMode)(&handshakeMode) } + if option.TrafficPattern != "" { + trafficPattern, _ := mierutp.Decode(option.TrafficPattern) + config.Profile.TrafficPattern = trafficPattern + } return config, nil } @@ -345,6 +351,15 @@ func validateMieruOption(option MieruOption) error { return fmt.Errorf("invalid handshake mode: %s", option.HandshakeMode) } } + if option.TrafficPattern != "" { + trafficPattern, err := mierutp.Decode(option.TrafficPattern) + if err != nil { + return fmt.Errorf("failed to decode traffic pattern %q: %w", option.TrafficPattern, err) + } + if err := mierutp.Validate(trafficPattern); err != nil { + return fmt.Errorf("invalid traffic pattern %q: %w", option.TrafficPattern, err) + } + } return nil } diff --git a/mihomo/adapter/outbound/mieru_test.go b/mihomo/adapter/outbound/mieru_test.go index d80b27696c..f47cf998b2 100644 --- a/mihomo/adapter/outbound/mieru_test.go +++ b/mihomo/adapter/outbound/mieru_test.go @@ -31,12 +31,13 @@ func TestNewMieru(t *testing.T) { }, { option: MieruOption{ - Name: "test", - Server: "example.com", - Port: 10003, - Transport: "UDP", - UserName: "test", - Password: "test", + Name: "test", + Server: "example.com", + Port: 10003, + Transport: "UDP", + UserName: "test", + Password: "test", + TrafficPattern: "GgQIARAK", }, wantBaseAddr: "example.com:10003", }, diff --git a/mihomo/adapter/outboundgroup/groupbase.go b/mihomo/adapter/outboundgroup/groupbase.go index 9e705677e4..1a1b3cfd0b 100644 --- a/mihomo/adapter/outboundgroup/groupbase.go +++ b/mihomo/adapter/outboundgroup/groupbase.go @@ -272,7 +272,7 @@ func (gb *GroupBase) onDialFailed(adapterType C.AdapterType, err error, fn func( log.Debugln("ProxyGroup: %s failed count: %d", gb.Name(), gb.failedTimes) if gb.failedTimes >= gb.maxFailedTimes { - log.Warnln("because %s failed multiple times, active health check", gb.Name()) + log.Warnln("because %s failed multiple times, activate health check", gb.Name()) fn() } } diff --git a/mihomo/docs/config.yaml b/mihomo/docs/config.yaml index 63e54dfec8..864a9b80e5 100644 --- a/mihomo/docs/config.yaml +++ b/mihomo/docs/config.yaml @@ -1077,6 +1077,8 @@ proxies: # socks5 # multiplexing: MULTIPLEXING_LOW # 如果想开启 0-RTT 握手,请设置为 HANDSHAKE_NO_WAIT,否则请设置为 HANDSHAKE_STANDARD。默认值为 HANDSHAKE_STANDARD # handshake-mode: HANDSHAKE_STANDARD + # 一个 base64 字符串用于微调网络行为 + # traffic-pattern: "" # sudoku - name: sudoku @@ -1645,6 +1647,8 @@ listeners: users: username1: password1 username2: password2 + # 一个 base64 字符串用于微调网络行为 + # traffic-pattern: "" - name: sudoku-in-1 type: sudoku diff --git a/mihomo/go.mod b/mihomo/go.mod index 8b777d0c63..8fc052bad1 100644 --- a/mihomo/go.mod +++ b/mihomo/go.mod @@ -6,7 +6,7 @@ require ( github.com/bahlo/generic-list-go v0.2.0 github.com/coreos/go-iptables v0.8.0 github.com/dlclark/regexp2 v1.11.5 - github.com/enfein/mieru/v3 v3.26.2 + github.com/enfein/mieru/v3 v3.28.0 github.com/gobwas/ws v1.4.0 github.com/gofrs/uuid/v5 v5.4.0 github.com/golang/snappy v1.0.0 diff --git a/mihomo/go.sum b/mihomo/go.sum index 724cc909e6..fb65418c46 100644 --- a/mihomo/go.sum +++ b/mihomo/go.sum @@ -20,8 +20,8 @@ github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZ github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dunglas/httpsfv v1.0.2 h1:iERDp/YAfnojSDJ7PW3dj1AReJz4MrwbECSSE59JWL0= github.com/dunglas/httpsfv v1.0.2/go.mod h1:zID2mqw9mFsnt7YC3vYQ9/cjq30q41W+1AnDwH8TiMg= -github.com/enfein/mieru/v3 v3.26.2 h1:U/2XJc+3vrJD9r815FoFdwToQFEcqSOzzzWIPPhjfEU= -github.com/enfein/mieru/v3 v3.26.2/go.mod h1:zJBUCsi5rxyvHM8fjFf+GLaEl4OEjjBXr1s5F6Qd3hM= +github.com/enfein/mieru/v3 v3.28.0 h1:4OsFPUIjKfQ6ymfyX1Laqz7h+zB8TxuK1m0isnYJ8ww= +github.com/enfein/mieru/v3 v3.28.0/go.mod h1:zJBUCsi5rxyvHM8fjFf+GLaEl4OEjjBXr1s5F6Qd3hM= github.com/ericlagergren/aegis v0.0.0-20250325060835-cd0defd64358 h1:kXYqH/sL8dS/FdoFjr12ePjnLPorPo2FsnrHNuXSDyo= github.com/ericlagergren/aegis v0.0.0-20250325060835-cd0defd64358/go.mod h1:hkIFzoiIPZYxdFOOLyDho59b7SrDfo+w3h+yWdlg45I= github.com/ericlagergren/polyval v0.0.0-20220411101811-e25bc10ba391 h1:8j2RH289RJplhA6WfdaPqzg1MjH2K8wX5e0uhAxrw2g= diff --git a/mihomo/listener/inbound/mieru.go b/mihomo/listener/inbound/mieru.go index 8a5718a636..5f7e4ffee9 100644 --- a/mihomo/listener/inbound/mieru.go +++ b/mihomo/listener/inbound/mieru.go @@ -14,6 +14,7 @@ import ( "google.golang.org/protobuf/proto" mieruserver "github.com/enfein/mieru/v3/apis/server" + mierutp "github.com/enfein/mieru/v3/apis/trafficpattern" mierupb "github.com/enfein/mieru/v3/pkg/appctl/appctlpb" ) @@ -26,8 +27,9 @@ type Mieru struct { type MieruOption struct { BaseOption - Transport string `inbound:"transport"` - Users map[string]string `inbound:"users"` + Transport string `inbound:"transport"` + Users map[string]string `inbound:"users"` + TrafficPattern string `inbound:"traffic-pattern"` } type mieruListenerFactory struct{} @@ -154,10 +156,13 @@ func buildMieruServerConfig(option *MieruOption, ports utils.IntRanges[uint16]) Password: proto.String(password), }) } + var trafficPattern *mierupb.TrafficPattern + trafficPattern, _ = mierutp.Decode(option.TrafficPattern) return &mieruserver.ServerConfig{ Config: &mierupb.ServerConfig{ - PortBindings: portBindings, - Users: users, + PortBindings: portBindings, + Users: users, + TrafficPattern: trafficPattern, }, StreamListenerFactory: mieruListenerFactory{}, PacketListenerFactory: mieruListenerFactory{}, @@ -179,5 +184,14 @@ func validateMieruOption(option *MieruOption) error { return fmt.Errorf("password is empty") } } + if option.TrafficPattern != "" { + trafficPattern, err := mierutp.Decode(option.TrafficPattern) + if err != nil { + return fmt.Errorf("failed to decode traffic pattern %q: %w", option.TrafficPattern, err) + } + if err := mierutp.Validate(trafficPattern); err != nil { + return fmt.Errorf("invalid traffic pattern %q: %w", option.TrafficPattern, err) + } + } return nil } diff --git a/mihomo/listener/inbound/mieru_test.go b/mihomo/listener/inbound/mieru_test.go index d57f4df5ee..af9e7826e2 100644 --- a/mihomo/listener/inbound/mieru_test.go +++ b/mihomo/listener/inbound/mieru_test.go @@ -61,6 +61,20 @@ func TestNewMieru(t *testing.T) { }, wantErr: false, }, + { + name: "valid traffic pattern", + args: args{ + option: &inbound.MieruOption{ + BaseOption: inbound.BaseOption{ + Port: "8080", + }, + Transport: "TCP", + Users: map[string]string{"user": "pass"}, + TrafficPattern: "GgQIARAK", + }, + }, + wantErr: false, + }, { name: "invalid - no port", args: args{ @@ -135,6 +149,20 @@ func TestNewMieru(t *testing.T) { }, wantErr: true, }, + { + name: "invalid traffic pattern", + args: args{ + option: &inbound.MieruOption{ + BaseOption: inbound.BaseOption{ + Port: "8080", + }, + Transport: "TCP", + Users: map[string]string{"user": "pass"}, + TrafficPattern: "1212ababXYYX", + }, + }, + wantErr: true, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/openwrt-packages/luci-app-ddns-go/htdocs/luci-static/resources/view/ddns-go/config.js b/openwrt-packages/luci-app-ddns-go/htdocs/luci-static/resources/view/ddns-go/config.js index 3f2ea1d584..adf86c838a 100644 --- a/openwrt-packages/luci-app-ddns-go/htdocs/luci-static/resources/view/ddns-go/config.js +++ b/openwrt-packages/luci-app-ddns-go/htdocs/luci-static/resources/view/ddns-go/config.js @@ -135,7 +135,7 @@ return view.extend({ message = _('Password reset successfully to admin12345'); ui.showModal(_('Password Reset Successful'), [ - E('p', _('Admin password has been reset to: admin12345')), + E('p', _('Reset User:admin ,Reset password: admin12345')), E('p', _('You need to restart DDNS-Go service for the changes to take effect.')), E('div', { 'class': 'right' }, [ E('button', { @@ -340,4 +340,4 @@ return view.extend({ return m.render(); } -}); \ No newline at end of file +}); diff --git a/openwrt-packages/luci-app-ddns-go/po/zh_Hans/ddns-go.po b/openwrt-packages/luci-app-ddns-go/po/zh_Hans/ddns-go.po index e560c946a7..ecefebae17 100644 --- a/openwrt-packages/luci-app-ddns-go/po/zh_Hans/ddns-go.po +++ b/openwrt-packages/luci-app-ddns-go/po/zh_Hans/ddns-go.po @@ -109,8 +109,8 @@ msgstr "成功重置密码admin12345" msgid "Password Reset Successful" msgstr "重置密码成功" -msgid "User password has been reset to: admin12345" -msgstr "用户密码重置为admin12345" +msgid "Reset User:admin ,Reset password: admin12345" +msgstr "重置用户名:admin 重置密码:admin12345" msgid "You need to restart DDNS-Go service for the changes to take effect." msgstr "需要重启DDNS-GO服务更改才生效" diff --git a/openwrt-passwall/luci-app-passwall/luasrc/model/cbi/passwall/client/node_subscribe.lua b/openwrt-passwall/luci-app-passwall/luasrc/model/cbi/passwall/client/node_subscribe.lua index 40a266ef2d..f045bc87f7 100644 --- a/openwrt-passwall/luci-app-passwall/luasrc/model/cbi/passwall/client/node_subscribe.lua +++ b/openwrt-passwall/luci-app-passwall/luasrc/model/cbi/passwall/client/node_subscribe.lua @@ -77,6 +77,7 @@ o = s:option(DynamicList, "filter_keep_list", translate("Keep List")) if #ss_type > 0 then o = s:option(ListValue, "ss_type", translatef("%s Node Use Type", "Shadowsocks")) + o:value("", translate("Auto")) for key, value in pairs(ss_type) do o:value(value) end @@ -84,6 +85,7 @@ end if #trojan_type > 0 then o = s:option(ListValue, "trojan_type", translatef("%s Node Use Type", "Trojan")) + o:value("", translate("Auto")) for key, value in pairs(trojan_type) do o:value(value) end @@ -91,32 +93,26 @@ end if #vmess_type > 0 then o = s:option(ListValue, "vmess_type", translatef("%s Node Use Type", "VMess")) + o:value("", translate("Auto")) for key, value in pairs(vmess_type) do o:value(value) end - if has_xray then - o.default = "xray" - end end if #vless_type > 0 then o = s:option(ListValue, "vless_type", translatef("%s Node Use Type", "VLESS")) + o:value("", translate("Auto")) for key, value in pairs(vless_type) do o:value(value) end - if has_xray then - o.default = "xray" - end end if #hysteria2_type > 0 then o = s:option(ListValue, "hysteria2_type", translatef("%s Node Use Type", "Hysteria2")) + o:value("", translate("Auto")) for key, value in pairs(hysteria2_type) do o:value(value) end - if has_hysteria2 then - o.default = "hysteria2" - end end if #ss_type > 0 or #trojan_type > 0 or #vmess_type > 0 or #vless_type > 0 or #hysteria2_type > 0 then diff --git a/openwrt-passwall/luci-app-passwall/luasrc/model/cbi/passwall/client/type/ray.lua b/openwrt-passwall/luci-app-passwall/luasrc/model/cbi/passwall/client/type/ray.lua index e996956c0d..6e730639e2 100644 --- a/openwrt-passwall/luci-app-passwall/luasrc/model/cbi/passwall/client/type/ray.lua +++ b/openwrt-passwall/luci-app-passwall/luasrc/model/cbi/passwall/client/type/ray.lua @@ -250,6 +250,11 @@ if #protocols > 0 then end end +o = s:option(Value, _n("uuid"), translate("ID")) +o.password = true +o:depends({ [_n("protocol")] = "vmess" }) +o:depends({ [_n("protocol")] = "vless" }) + o = s:option(Value, _n("username"), translate("Username")) o:depends({ [_n("protocol")] = "http" }) o:depends({ [_n("protocol")] = "socks" }) @@ -288,11 +293,6 @@ o:depends({ [_n("protocol")] = "shadowsocks", [_n("ss_method")] = "xchacha20-pol o = s:option(Flag, _n("uot"), translate("UDP over TCP")) o:depends({ [_n("protocol")] = "shadowsocks" }) -o = s:option(Value, _n("uuid"), translate("ID")) -o.password = true -o:depends({ [_n("protocol")] = "vmess" }) -o:depends({ [_n("protocol")] = "vless" }) - o = s:option(ListValue, _n("flow"), translate("flow")) o.default = "" o:value("", translate("Disable")) diff --git a/openwrt-passwall/luci-app-passwall/luasrc/model/cbi/passwall/client/type/sing-box.lua b/openwrt-passwall/luci-app-passwall/luasrc/model/cbi/passwall/client/type/sing-box.lua index 4aed96e742..f5397118e1 100644 --- a/openwrt-passwall/luci-app-passwall/luasrc/model/cbi/passwall/client/type/sing-box.lua +++ b/openwrt-passwall/luci-app-passwall/luasrc/model/cbi/passwall/client/type/sing-box.lua @@ -218,6 +218,12 @@ if #protocols > 0 then end end +o = s:option(Value, _n("uuid"), translate("ID")) +o.password = true +o:depends({ [_n("protocol")] = "vmess" }) +o:depends({ [_n("protocol")] = "vless" }) +o:depends({ [_n("protocol")] = "tuic" }) + o = s:option(Value, _n("username"), translate("Username")) o:depends({ [_n("protocol")] = "http" }) o:depends({ [_n("protocol")] = "socks" }) @@ -247,12 +253,6 @@ o = s:option(Flag, _n("uot"), translate("UDP over TCP")) o:depends({ [_n("protocol")] = "socks" }) o:depends({ [_n("protocol")] = "shadowsocks" }) -o = s:option(Value, _n("uuid"), translate("ID")) -o.password = true -o:depends({ [_n("protocol")] = "vmess" }) -o:depends({ [_n("protocol")] = "vless" }) -o:depends({ [_n("protocol")] = "tuic" }) - o = s:option(Value, _n("alter_id"), "Alter ID") o.datatype = "uinteger" o.default = "0" diff --git a/openwrt-passwall/luci-app-passwall/luasrc/model/cbi/passwall/server/type/hysteria2.lua b/openwrt-passwall/luci-app-passwall/luasrc/model/cbi/passwall/server/type/hysteria2.lua index 49d264693c..c6903c1754 100644 --- a/openwrt-passwall/luci-app-passwall/luasrc/model/cbi/passwall/server/type/hysteria2.lua +++ b/openwrt-passwall/luci-app-passwall/luasrc/model/cbi/passwall/server/type/hysteria2.lua @@ -91,7 +91,7 @@ o.validate = function(self, value, t) if value and api.jsonc.parse(value) then return value else - return nil, translate("Must be JSON text!") + return nil, translate("Custom Config") .. " " .. translate("Must be JSON text!") end end o.custom_cfgvalue = function(self, section, value) @@ -101,7 +101,7 @@ o.custom_cfgvalue = function(self, section, value) end end o.custom_write = function(self, section, value) - m:set(section, "config_str", api.base64Encode(value)) + m:set(section, "config_str", api.base64Encode(value) or "") end o = s:option(Flag, _n("log"), translate("Log")) diff --git a/openwrt-passwall/luci-app-passwall/luasrc/model/cbi/passwall/server/type/sing-box.lua b/openwrt-passwall/luci-app-passwall/luasrc/model/cbi/passwall/server/type/sing-box.lua index 373179b9b6..3424cb8ffe 100644 --- a/openwrt-passwall/luci-app-passwall/luasrc/model/cbi/passwall/server/type/sing-box.lua +++ b/openwrt-passwall/luci-app-passwall/luasrc/model/cbi/passwall/server/type/sing-box.lua @@ -446,7 +446,7 @@ o.validate = function(self, value, t) if value and api.jsonc.parse(value) then return value else - return nil, translate("Must be JSON text!") + return nil, translate("Custom Config") .. " " .. translate("Must be JSON text!") end end o.custom_cfgvalue = function(self, section, value) @@ -456,7 +456,7 @@ o.custom_cfgvalue = function(self, section, value) end end o.custom_write = function(self, section, value) - m:set(section, "config_str", api.base64Encode(value)) + m:set(section, "config_str", api.base64Encode(value) or "") end o = s:option(Flag, _n("log"), translate("Log")) diff --git a/openwrt-passwall/luci-app-passwall/luasrc/model/cbi/passwall/server/type/ss-rust.lua b/openwrt-passwall/luci-app-passwall/luasrc/model/cbi/passwall/server/type/ss-rust.lua index 22b97424d9..e3ea065204 100644 --- a/openwrt-passwall/luci-app-passwall/luasrc/model/cbi/passwall/server/type/ss-rust.lua +++ b/openwrt-passwall/luci-app-passwall/luasrc/model/cbi/passwall/server/type/ss-rust.lua @@ -55,7 +55,7 @@ o.validate = function(self, value, t) if value and api.jsonc.parse(value) then return value else - return nil, translate("Must be JSON text!") + return nil, translate("Custom Config") .. " " .. translate("Must be JSON text!") end end o.custom_cfgvalue = function(self, section, value) @@ -65,7 +65,7 @@ o.custom_cfgvalue = function(self, section, value) end end o.custom_write = function(self, section, value) - m:set(section, "config_str", api.base64Encode(value)) + m:set(section, "config_str", api.base64Encode(value) or "") end o = s:option(Flag, _n("log"), translate("Log")) diff --git a/openwrt-passwall/luci-app-passwall/luasrc/model/cbi/passwall/server/type/ss.lua b/openwrt-passwall/luci-app-passwall/luasrc/model/cbi/passwall/server/type/ss.lua index cac798b84b..f8a2b58bad 100644 --- a/openwrt-passwall/luci-app-passwall/luasrc/model/cbi/passwall/server/type/ss.lua +++ b/openwrt-passwall/luci-app-passwall/luasrc/model/cbi/passwall/server/type/ss.lua @@ -58,7 +58,7 @@ o.validate = function(self, value, t) if value and api.jsonc.parse(value) then return value else - return nil, translate("Must be JSON text!") + return nil, translate("Custom Config") .. " " .. translate("Must be JSON text!") end end o.custom_cfgvalue = function(self, section, value) @@ -68,7 +68,7 @@ o.custom_cfgvalue = function(self, section, value) end end o.custom_write = function(self, section, value) - m:set(section, "config_str", api.base64Encode(value)) + m:set(section, "config_str", api.base64Encode(value) or "") end o = s:option(Flag, _n("log"), translate("Log")) diff --git a/openwrt-passwall/luci-app-passwall/luasrc/model/cbi/passwall/server/type/ssr.lua b/openwrt-passwall/luci-app-passwall/luasrc/model/cbi/passwall/server/type/ssr.lua index 924974bb6a..45fb79d244 100644 --- a/openwrt-passwall/luci-app-passwall/luasrc/model/cbi/passwall/server/type/ssr.lua +++ b/openwrt-passwall/luci-app-passwall/luasrc/model/cbi/passwall/server/type/ssr.lua @@ -82,7 +82,7 @@ o.validate = function(self, value, t) if value and api.jsonc.parse(value) then return value else - return nil, translate("Must be JSON text!") + return nil, translate("Custom Config") .. " " .. translate("Must be JSON text!") end end o.custom_cfgvalue = function(self, section, value) @@ -92,7 +92,7 @@ o.custom_cfgvalue = function(self, section, value) end end o.custom_write = function(self, section, value) - m:set(section, "config_str", api.base64Encode(value)) + m:set(section, "config_str", api.base64Encode(value) or "") end o = s:option(Flag, _n("udp_forward"), translate("UDP Forward")) diff --git a/openwrt-passwall/luci-app-passwall/luasrc/passwall/api.lua b/openwrt-passwall/luci-app-passwall/luasrc/passwall/api.lua index c47e8fdf6f..4e1ce9bd31 100644 --- a/openwrt-passwall/luci-app-passwall/luasrc/passwall/api.lua +++ b/openwrt-passwall/luci-app-passwall/luasrc/passwall/api.lua @@ -1489,3 +1489,18 @@ function match_node_rule(name, rule) end return true end + +function get_core(field, candidates) + local v = uci:get(appname, "@global_subscribe[0]", field) + if v and v ~= "" then + for _, c in ipairs(candidates) do + if c[2] == v and c[1] then + return v + end + end + end + for _, c in ipairs(candidates) do + if c[1] then return c[2] end + end + return nil +end diff --git a/openwrt-passwall/luci-app-passwall/luasrc/passwall/util_sing-box.lua b/openwrt-passwall/luci-app-passwall/luasrc/passwall/util_sing-box.lua index 63e3b06df4..d579b8fe7f 100644 --- a/openwrt-passwall/luci-app-passwall/luasrc/passwall/util_sing-box.lua +++ b/openwrt-passwall/luci-app-passwall/luasrc/passwall/util_sing-box.lua @@ -443,9 +443,10 @@ function gen_outbound(flag, node, tag, proxy_table) udp_relay_mode = node.tuic_udp_relay_mode or "native", udp_over_stream = false, zero_rtt_handshake = (node.tuic_zero_rtt_handshake == "1") and true or false, - heartbeat = node.tuic_heartbeat .. "s", + heartbeat = (tonumber(node.tuic_heartbeat) or 3) .. "s", tls = { enabled = true, + disable_sni = (node.tls_disable_sni == "1") and true or false, server_name = node.tls_serverName, insecure = (node.tls_allowInsecure == "1") and true or false, fragment = fragment, @@ -453,10 +454,10 @@ function gen_outbound(flag, node, tag, proxy_table) alpn = (node.tuic_alpn and node.tuic_alpn ~= "") and { node.tuic_alpn } or nil, - ech = { - enabled = (node.ech == "1") and true or false, + ech = (node.ech == "1") and { + enabled = true, config = node.ech_config and split(node.ech_config:gsub("\\n", "\n"), "\n") or {} - } + } or nil } } end @@ -1229,9 +1230,7 @@ function gen_config(var) if preproxy_node then local preproxy_outbound, exist if preproxy_node.protocol == "_urltest" then - if preproxy_node.urltest_node then - preproxy_outbound, exist = gen_urltest_outbound(preproxy_node) - end + preproxy_outbound, exist = gen_urltest_outbound(preproxy_node) else preproxy_outbound = gen_outbound(node[".name"], preproxy_node) end @@ -1316,11 +1315,9 @@ function gen_config(var) end local outbound, exist if node.protocol == "_urltest" then - if node.urltest_node then - outbound, exist = gen_urltest_outbound(node) - if exist then - return outbound.tag - end + outbound, exist = gen_urltest_outbound(node) + if exist then + return outbound.tag end elseif node.protocol == "_iface" then if node.iface then diff --git a/openwrt-passwall/luci-app-passwall/luasrc/view/passwall/node_config/link_share_man.htm b/openwrt-passwall/luci-app-passwall/luasrc/view/passwall/node_config/link_share_man.htm index c4c82b15a6..a1df843e24 100644 --- a/openwrt-passwall/luci-app-passwall/luasrc/view/passwall/node_config/link_share_man.htm +++ b/openwrt-passwall/luci-app-passwall/luasrc/view/passwall/node_config/link_share_man.htm @@ -11,21 +11,11 @@ local has_singbox = api.finded_com("sing-box") local has_xray = api.finded_com("xray") local has_hysteria2 = api.finded_com("hysteria") -local function get_core(field, candidates) - local v = map:get("@global_subscribe[0]", field) - if not v or v == "" then - for _, c in ipairs(candidates) do - if c[1] then return c[2] end - end - end - return v -end - -local ss_type = get_core("ss_type", {{has_ss,"shadowsocks-libev"},{has_ss_rust,"shadowsocks-rust"},{has_singbox,"sing-box"},{has_xray,"xray"}}) -local trojan_type = get_core("trojan_type", {{has_trojan_plus,"trojan-plus"},{has_singbox,"sing-box"},{has_xray,"xray"}}) -local vmess_type = get_core("vmess_type", {{has_xray,"xray"},{has_singbox,"sing-box"}}) -local vless_type = get_core("vless_type", {{has_xray,"xray"},{has_singbox,"sing-box"}}) -local hysteria2_type = get_core("hysteria2_type", {{has_hysteria2,"hysteria2"},{has_singbox,"sing-box"}, {has_xray,"xray"}}) +local ss_type = api.get_core("ss_type", {{has_ss,"shadowsocks-libev"},{has_ss_rust,"shadowsocks-rust"},{has_singbox,"sing-box"},{has_xray,"xray"}}) +local trojan_type = api.get_core("trojan_type", {{has_trojan_plus,"trojan-plus"},{has_singbox,"sing-box"},{has_xray,"xray"}}) +local vmess_type = api.get_core("vmess_type", {{has_xray,"xray"},{has_singbox,"sing-box"}}) +local vless_type = api.get_core("vless_type", {{has_xray,"xray"},{has_singbox,"sing-box"}}) +local hysteria2_type = api.get_core("hysteria2_type", {{has_hysteria2,"hysteria2"},{has_singbox,"sing-box"}, {has_xray,"xray"}}) local current_node = map:get(section) -%> @@ -652,6 +642,7 @@ local current_node = map:get(section) var params = ""; params += opt.query("sni", dom_prefix + "tls_serverName"); + params += opt.query("disable_sni", dom_prefix + "tls_disable_sni"); params += opt.query("alpn", dom_prefix + "tuic_alpn"); params += opt.query("congestion_control", dom_prefix + "tuic_congestion_control"); params += opt.query("udp_relay_mode", dom_prefix + "tuic_udp_relay_mode"); @@ -1641,6 +1632,14 @@ local current_node = map:get(section) var port = _parsedUrl.port; var search = _parsedUrl.search; var hash = _parsedUrl.hash; + if (!username || !password) { //修正某些链接会把uuid和password之间的:进行编码 + const decoded = decodeURIComponent(username || password || ""); + const i = decoded.indexOf(":"); + if (i > -1) { + username = decoded.slice(0, i); + password = decoded.slice(i + 1); + } + } opt.set(dom_prefix + 'uuid', decodeURIComponent(username)); opt.set(dom_prefix + 'password', decodeURIComponent(password)); opt.set(dom_prefix + 'address', unbracketIP(hostname)); @@ -1660,6 +1659,7 @@ local current_node = map:get(section) opt.set(dom_prefix + 'tuic_udp_relay_mode', queryParam.udp_relay_mode || 'native'); opt.set(dom_prefix + 'tuic_alpn', queryParam.alpn || 'default'); opt.set(dom_prefix + 'tls_serverName', queryParam.sni || ''); + opt.set(dom_prefix + 'tls_disable_sni', queryParam.disable_sni === "1"); opt.set(dom_prefix + 'tls_allowInsecure', true); if ((queryParam.allowinsecure ?? '0') === '0' && (queryParam.insecure ?? '0') === '0') { opt.set(dom_prefix + 'tls_allowInsecure', false); diff --git a/openwrt-passwall/luci-app-passwall/root/usr/share/passwall/app.sh b/openwrt-passwall/luci-app-passwall/root/usr/share/passwall/app.sh index eca81ae1e2..d22cb3ebd9 100755 --- a/openwrt-passwall/luci-app-passwall/root/usr/share/passwall/app.sh +++ b/openwrt-passwall/luci-app-passwall/root/usr/share/passwall/app.sh @@ -1900,7 +1900,7 @@ stop() { fi done pgrep -f "sleep.*(6s|9s|58s)" | xargs kill -9 >/dev/null 2>&1 - pgrep -af "${CONFIG}/" | awk '! /app\.sh|subscribe\.lua|rule_update\.lua|tasks\.sh|ujail/{print $1}' | xargs kill -9 >/dev/null 2>&1 + pgrep -af "${CONFIG}/" | awk '! /app\.sh|subscribe\.lua|rule_update\.lua|tasks\.sh|server_app\.lua|ujail/{print $1}' | xargs kill -9 >/dev/null 2>&1 stop_crontab source $APP_PATH/helper_smartdns.sh del rm -rf $GLOBAL_DNSMASQ_CONF diff --git a/openwrt-passwall/luci-app-passwall/root/usr/share/passwall/subscribe.lua b/openwrt-passwall/luci-app-passwall/root/usr/share/passwall/subscribe.lua index 3f8f762f83..024c6fdda5 100755 --- a/openwrt-passwall/luci-app-passwall/root/usr/share/passwall/subscribe.lua +++ b/openwrt-passwall/luci-app-passwall/root/usr/share/passwall/subscribe.lua @@ -32,20 +32,15 @@ local has_xray = api.finded_com("xray") local has_hysteria2 = api.finded_com("hysteria") local allowInsecure_default = nil -- 取节点使用core类型(节点订阅页面未设置时,自动取默认) -local function get_core(field, candidates) - local v = uci:get(appname, "@global_subscribe[0]", field) - if not v or v == "" then - for _, c in ipairs(candidates) do - if c[1] then return c[2] end - end - end - return v -end -local ss_type_default = get_core("ss_type", {{has_ss,"shadowsocks-libev"},{has_ss_rust,"shadowsocks-rust"},{has_singbox,"sing-box"},{has_xray,"xray"}}) -local trojan_type_default = get_core("trojan_type", {{has_trojan_plus,"trojan-plus"},{has_singbox,"sing-box"},{has_xray,"xray"}}) -local vmess_type_default = get_core("vmess_type", {{has_xray,"xray"},{has_singbox,"sing-box"}}) -local vless_type_default = get_core("vless_type", {{has_xray,"xray"},{has_singbox,"sing-box"}}) -local hysteria2_type_default = get_core("hysteria2_type", {{has_hysteria2,"hysteria2"},{has_singbox,"sing-box"},{has_xray,"xray"}}) +local ss_type_default = api.get_core("ss_type", {{has_ss,"shadowsocks-libev"},{has_ss_rust,"shadowsocks-rust"},{has_singbox,"sing-box"},{has_xray,"xray"}}) +local trojan_type_default = api.get_core("trojan_type", {{has_trojan_plus,"trojan-plus"},{has_singbox,"sing-box"},{has_xray,"xray"}}) +local vmess_type_default = api.get_core("vmess_type", {{has_xray,"xray"},{has_singbox,"sing-box"}}) +local vless_type_default = api.get_core("vless_type", {{has_xray,"xray"},{has_singbox,"sing-box"}}) +local hysteria2_type_default = api.get_core("hysteria2_type", {{has_hysteria2,"hysteria2"},{has_singbox,"sing-box"},{has_xray,"xray"}}) +local core_has = { + ["xray"] = has_xray, ["sing-box"] = has_singbox, ["shadowsocks-libev"] = has_ss,["shadowsocks-rust"] = has_ss_rust, + ["trojan-plus"] = has_trojan_plus, ["hysteria2"] = has_hysteria2 +} ---- local domain_strategy_default = uci:get(appname, "@global_subscribe[0]", "domain_strategy") or "" local domain_strategy_node = "" @@ -1444,12 +1439,17 @@ local function processData(szType, content, add_mode, group) end result.remarks = UrlDecode(alias) local Info = content - if content:find("@") then + if content:find("@", 1, true) then local contents = split(content, "@") - if contents[1]:find(":") then - local userinfo = split(contents[1], ":") - result.uuid = UrlDecode(userinfo[1]) - result.password = UrlDecode(userinfo[2]) + local auth = contents[1] or "" + local idx = auth:find(":", 1, true) + if not idx then --修正某些链接会把uuid和password之间的:进行编码 + auth = UrlDecode(auth) + idx = auth:find(":", 1, true) + end + if idx then + result.uuid = UrlDecode(auth:sub(1, idx - 1)) + result.password = UrlDecode(auth:sub(idx + 1)) end Info = (contents[2] or ""):gsub("/%?", "?") end @@ -1474,6 +1474,7 @@ local function processData(szType, content, add_mode, group) result.address = host_port end result.tls_serverName = params.sni + result.tls_disable_sni = params.disable_sni result.tuic_alpn = params.alpn or "default" result.tuic_congestion_control = params.congestion_control or "cubic" result.tuic_udp_relay_mode = params.udp_relay_mode or "native" @@ -2040,23 +2041,23 @@ local execute = function() filter_keyword_discard_list_default = value.filter_discard_list or {} end local ss_type = value.ss_type or "global" - if ss_type ~= "global" then + if ss_type ~= "global" and core_has[ss_type] then ss_type_default = ss_type end local trojan_type = value.trojan_type or "global" - if trojan_type ~= "global" then + if trojan_type ~= "global" and core_has[trojan_type] then trojan_type_default = trojan_type end local vmess_type = value.vmess_type or "global" - if vmess_type ~= "global" then + if vmess_type ~= "global" and core_has[vmess_type] then vmess_type_default = vmess_type end local vless_type = value.vless_type or "global" - if vless_type ~= "global" then + if vless_type ~= "global" and core_has[vless_type] then vless_type_default = vless_type end local hysteria2_type = value.hysteria2_type or "global" - if hysteria2_type ~= "global" then + if hysteria2_type ~= "global" and core_has[hysteria2_type] then hysteria2_type_default = hysteria2_type end local domain_strategy = value.domain_strategy or "global" @@ -2112,11 +2113,21 @@ local execute = function() filter_keyword_mode_default = uci:get(appname, "@global_subscribe[0]", "filter_keyword_mode") or "0" filter_keyword_discard_list_default = uci:get(appname, "@global_subscribe[0]", "filter_discard_list") or {} filter_keyword_keep_list_default = uci:get(appname, "@global_subscribe[0]", "filter_keep_list") or {} - ss_type_default = uci:get(appname, "@global_subscribe[0]", "ss_type") or "shadowsocks-libev" - trojan_type_default = uci:get(appname, "@global_subscribe[0]", "trojan_type") or "trojan-plus" - vmess_type_default = uci:get(appname, "@global_subscribe[0]", "vmess_type") or "xray" - vless_type_default = uci:get(appname, "@global_subscribe[0]", "vless_type") or "xray" - hysteria2_type_default = uci:get(appname, "@global_subscribe[0]", "hysteria2_type") or "hysteria2" + + ss_type = uci:get(appname, "@global_subscribe[0]", "ss_type") or "" + ss_type_default = core_has[ss_type] and ss_type or ss_type_default + + trojan_type = uci:get(appname, "@global_subscribe[0]", "trojan_type") or "" + trojan_type_default = core_has[trojan_type] and trojan_type or trojan_type_default + + vmess_type = uci:get(appname, "@global_subscribe[0]", "vmess_type") or "" + vmess_type_default = core_has[vmess_type] and vmess_type or vmess_type_default + + vless_type = uci:get(appname, "@global_subscribe[0]", "vless_type") or "" + vless_type_default = core_has[vless_type] and vless_type or vless_type_default + + hysteria2_type = uci:get(appname, "@global_subscribe[0]", "hysteria2_type") or "" + hysteria2_type_default = core_has[hysteria2_type] and hysteria2_type or hysteria2_type_default end if #fail_list > 0 then diff --git a/openwrt-passwall2/luci-app-passwall2/luasrc/model/cbi/passwall2/client/node_subscribe.lua b/openwrt-passwall2/luci-app-passwall2/luasrc/model/cbi/passwall2/client/node_subscribe.lua index 3ac1c4f20c..c76076deb8 100644 --- a/openwrt-passwall2/luci-app-passwall2/luasrc/model/cbi/passwall2/client/node_subscribe.lua +++ b/openwrt-passwall2/luci-app-passwall2/luasrc/model/cbi/passwall2/client/node_subscribe.lua @@ -69,6 +69,7 @@ o = s:option(DynamicList, "filter_keep_list", translate("Keep List")) if #ss_type > 0 then o = s:option(ListValue, "ss_type", translatef("%s Node Use Type", "Shadowsocks")) + o:value("", translate("Auto")) for key, value in pairs(ss_type) do o:value(value) end @@ -76,6 +77,7 @@ end if #trojan_type > 0 then o = s:option(ListValue, "trojan_type", translatef("%s Node Use Type", "Trojan")) + o:value("", translate("Auto")) for key, value in pairs(trojan_type) do o:value(value) end @@ -83,32 +85,26 @@ end if #vmess_type > 0 then o = s:option(ListValue, "vmess_type", translatef("%s Node Use Type", "VMess")) + o:value("", translate("Auto")) for key, value in pairs(vmess_type) do o:value(value) end - if has_xray then - o.default = "xray" - end end if #vless_type > 0 then o = s:option(ListValue, "vless_type", translatef("%s Node Use Type", "VLESS")) + o:value("", translate("Auto")) for key, value in pairs(vless_type) do o:value(value) end - if has_xray then - o.default = "xray" - end end if #hysteria2_type > 0 then o = s:option(ListValue, "hysteria2_type", translatef("%s Node Use Type", "Hysteria2")) + o:value("", translate("Auto")) for key, value in pairs(hysteria2_type) do o:value(value) end - if has_hysteria2 then - o.default = "hysteria2" - end end if #ss_type > 0 or #trojan_type > 0 or #vmess_type > 0 or #vless_type > 0 or #hysteria2_type > 0 then diff --git a/openwrt-passwall2/luci-app-passwall2/luasrc/model/cbi/passwall2/client/type/ray.lua b/openwrt-passwall2/luci-app-passwall2/luasrc/model/cbi/passwall2/client/type/ray.lua index 5745e25396..af2cd1d3e0 100644 --- a/openwrt-passwall2/luci-app-passwall2/luasrc/model/cbi/passwall2/client/type/ray.lua +++ b/openwrt-passwall2/luci-app-passwall2/luasrc/model/cbi/passwall2/client/type/ray.lua @@ -249,6 +249,11 @@ if #protocols > 0 then end end +o = s:option(Value, _n("uuid"), translate("ID")) +o.password = true +o:depends({ [_n("protocol")] = "vmess" }) +o:depends({ [_n("protocol")] = "vless" }) + o = s:option(Value, _n("username"), translate("Username")) o:depends({ [_n("protocol")] = "http" }) o:depends({ [_n("protocol")] = "socks" }) @@ -287,11 +292,6 @@ o:depends({ [_n("protocol")] = "shadowsocks", [_n("ss_method")] = "xchacha20-pol o = s:option(Flag, _n("uot"), translate("UDP over TCP")) o:depends({ [_n("protocol")] = "shadowsocks" }) -o = s:option(Value, _n("uuid"), translate("ID")) -o.password = true -o:depends({ [_n("protocol")] = "vmess" }) -o:depends({ [_n("protocol")] = "vless" }) - o = s:option(ListValue, _n("flow"), translate("flow")) o.default = "" o:value("", translate("Disable")) diff --git a/openwrt-passwall2/luci-app-passwall2/luasrc/model/cbi/passwall2/client/type/sing-box.lua b/openwrt-passwall2/luci-app-passwall2/luasrc/model/cbi/passwall2/client/type/sing-box.lua index 2c31dcd5b5..e14c7b5364 100644 --- a/openwrt-passwall2/luci-app-passwall2/luasrc/model/cbi/passwall2/client/type/sing-box.lua +++ b/openwrt-passwall2/luci-app-passwall2/luasrc/model/cbi/passwall2/client/type/sing-box.lua @@ -222,6 +222,12 @@ if #protocols > 0 then end end +o = s:option(Value, _n("uuid"), translate("ID")) +o.password = true +o:depends({ [_n("protocol")] = "vmess" }) +o:depends({ [_n("protocol")] = "vless" }) +o:depends({ [_n("protocol")] = "tuic" }) + o = s:option(Value, _n("username"), translate("Username")) o:depends({ [_n("protocol")] = "http" }) o:depends({ [_n("protocol")] = "socks" }) @@ -285,12 +291,6 @@ o = s:option(Flag, _n("uot"), translate("UDP over TCP")) o:depends({ [_n("protocol")] = "socks" }) o:depends({ [_n("protocol")] = "shadowsocks" }) -o = s:option(Value, _n("uuid"), translate("ID")) -o.password = true -o:depends({ [_n("protocol")] = "vmess" }) -o:depends({ [_n("protocol")] = "vless" }) -o:depends({ [_n("protocol")] = "tuic" }) - o = s:option(Value, _n("alter_id"), "Alter ID") o.datatype = "uinteger" o.default = "0" diff --git a/openwrt-passwall2/luci-app-passwall2/luasrc/model/cbi/passwall2/server/type/hysteria2.lua b/openwrt-passwall2/luci-app-passwall2/luasrc/model/cbi/passwall2/server/type/hysteria2.lua index 8bdacf88bf..6983d0d509 100644 --- a/openwrt-passwall2/luci-app-passwall2/luasrc/model/cbi/passwall2/server/type/hysteria2.lua +++ b/openwrt-passwall2/luci-app-passwall2/luasrc/model/cbi/passwall2/server/type/hysteria2.lua @@ -89,7 +89,7 @@ o.validate = function(self, value, t) if value and api.jsonc.parse(value) then return value else - return nil, translate("Must be JSON text!") + return nil, translate("Custom Config") .. " " .. translate("Must be JSON text!") end end o.custom_cfgvalue = function(self, section, value) @@ -99,7 +99,7 @@ o.custom_cfgvalue = function(self, section, value) end end o.custom_write = function(self, section, value) - m:set(section, "config_str", api.base64Encode(value)) + m:set(section, "config_str", api.base64Encode(value) or "") end o = s:option(Flag, _n("log"), translate("Log")) diff --git a/openwrt-passwall2/luci-app-passwall2/luasrc/model/cbi/passwall2/server/type/sing-box.lua b/openwrt-passwall2/luci-app-passwall2/luasrc/model/cbi/passwall2/server/type/sing-box.lua index 08c2181e4f..d1e4f4a557 100644 --- a/openwrt-passwall2/luci-app-passwall2/luasrc/model/cbi/passwall2/server/type/sing-box.lua +++ b/openwrt-passwall2/luci-app-passwall2/luasrc/model/cbi/passwall2/server/type/sing-box.lua @@ -457,7 +457,7 @@ o.validate = function(self, value, t) if value and api.jsonc.parse(value) then return value else - return nil, translate("Must be JSON text!") + return nil, translate("Custom Config") .. " " .. translate("Must be JSON text!") end end o.custom_cfgvalue = function(self, section, value) @@ -467,7 +467,7 @@ o.custom_cfgvalue = function(self, section, value) end end o.custom_write = function(self, section, value) - m:set(section, "config_str", api.base64Encode(value)) + m:set(section, "config_str", api.base64Encode(value) or "") end o = s:option(Flag, _n("log"), translate("Log")) diff --git a/openwrt-passwall2/luci-app-passwall2/luasrc/model/cbi/passwall2/server/type/ss-rust.lua b/openwrt-passwall2/luci-app-passwall2/luasrc/model/cbi/passwall2/server/type/ss-rust.lua index 4bcf51620f..2b800be29d 100644 --- a/openwrt-passwall2/luci-app-passwall2/luasrc/model/cbi/passwall2/server/type/ss-rust.lua +++ b/openwrt-passwall2/luci-app-passwall2/luasrc/model/cbi/passwall2/server/type/ss-rust.lua @@ -55,7 +55,7 @@ o.validate = function(self, value, t) if value and api.jsonc.parse(value) then return value else - return nil, translate("Must be JSON text!") + return nil, translate("Custom Config") .. " " .. translate("Must be JSON text!") end end o.custom_cfgvalue = function(self, section, value) @@ -65,7 +65,7 @@ o.custom_cfgvalue = function(self, section, value) end end o.custom_write = function(self, section, value) - m:set(section, "config_str", api.base64Encode(value)) + m:set(section, "config_str", api.base64Encode(value) or "") end o = s:option(Flag, _n("log"), translate("Log")) diff --git a/openwrt-passwall2/luci-app-passwall2/luasrc/model/cbi/passwall2/server/type/ss.lua b/openwrt-passwall2/luci-app-passwall2/luasrc/model/cbi/passwall2/server/type/ss.lua index e659577193..093ab70c43 100644 --- a/openwrt-passwall2/luci-app-passwall2/luasrc/model/cbi/passwall2/server/type/ss.lua +++ b/openwrt-passwall2/luci-app-passwall2/luasrc/model/cbi/passwall2/server/type/ss.lua @@ -58,7 +58,7 @@ o.validate = function(self, value, t) if value and api.jsonc.parse(value) then return value else - return nil, translate("Must be JSON text!") + return nil, translate("Custom Config") .. " " .. translate("Must be JSON text!") end end o.custom_cfgvalue = function(self, section, value) @@ -68,7 +68,7 @@ o.custom_cfgvalue = function(self, section, value) end end o.custom_write = function(self, section, value) - m:set(section, "config_str", api.base64Encode(value)) + m:set(section, "config_str", api.base64Encode(value) or "") end o = s:option(Flag, _n("log"), translate("Log")) diff --git a/openwrt-passwall2/luci-app-passwall2/luasrc/model/cbi/passwall2/server/type/ssr.lua b/openwrt-passwall2/luci-app-passwall2/luasrc/model/cbi/passwall2/server/type/ssr.lua index 65058c9f3e..3bcf559772 100644 --- a/openwrt-passwall2/luci-app-passwall2/luasrc/model/cbi/passwall2/server/type/ssr.lua +++ b/openwrt-passwall2/luci-app-passwall2/luasrc/model/cbi/passwall2/server/type/ssr.lua @@ -82,7 +82,7 @@ o.validate = function(self, value, t) if value and api.jsonc.parse(value) then return value else - return nil, translate("Must be JSON text!") + return nil, translate("Custom Config") .. " " .. translate("Must be JSON text!") end end o.custom_cfgvalue = function(self, section, value) @@ -92,7 +92,7 @@ o.custom_cfgvalue = function(self, section, value) end end o.custom_write = function(self, section, value) - m:set(section, "config_str", api.base64Encode(value)) + m:set(section, "config_str", api.base64Encode(value) or "") end o = s:option(Flag, _n("udp_forward"), translate("UDP Forward")) diff --git a/openwrt-passwall2/luci-app-passwall2/luasrc/passwall2/api.lua b/openwrt-passwall2/luci-app-passwall2/luasrc/passwall2/api.lua index ebca183b59..9672f29708 100644 --- a/openwrt-passwall2/luci-app-passwall2/luasrc/passwall2/api.lua +++ b/openwrt-passwall2/luci-app-passwall2/luasrc/passwall2/api.lua @@ -1519,3 +1519,18 @@ function match_node_rule(name, rule) end return true end + +function get_core(field, candidates) + local v = uci:get(appname, "@global_subscribe[0]", field) + if v and v ~= "" then + for _, c in ipairs(candidates) do + if c[2] == v and c[1] then + return v + end + end + end + for _, c in ipairs(candidates) do + if c[1] then return c[2] end + end + return nil +end diff --git a/openwrt-passwall2/luci-app-passwall2/luasrc/passwall2/util_sing-box.lua b/openwrt-passwall2/luci-app-passwall2/luasrc/passwall2/util_sing-box.lua index fc6778c108..967f46df57 100644 --- a/openwrt-passwall2/luci-app-passwall2/luasrc/passwall2/util_sing-box.lua +++ b/openwrt-passwall2/luci-app-passwall2/luasrc/passwall2/util_sing-box.lua @@ -452,9 +452,10 @@ function gen_outbound(flag, node, tag, proxy_table) udp_relay_mode = node.tuic_udp_relay_mode or "native", udp_over_stream = false, zero_rtt_handshake = (node.tuic_zero_rtt_handshake == "1") and true or false, - heartbeat = node.tuic_heartbeat .. "s", + heartbeat = (tonumber(node.tuic_heartbeat) or 3) .. "s", tls = { enabled = true, + disable_sni = (node.tls_disable_sni == "1") and true or false, server_name = node.tls_serverName, insecure = (node.tls_allowInsecure == "1") and true or false, fragment = fragment, @@ -462,12 +463,12 @@ function gen_outbound(flag, node, tag, proxy_table) alpn = (node.tuic_alpn and node.tuic_alpn ~= "") and { node.tuic_alpn } or nil, - ech = { - enabled = (node.ech == "1") and true or false, + ech = (node.ech == "1") and { + enabled = true, config = node.ech_config and split(node.ech_config:gsub("\\n", "\n"), "\n") or {}, pq_signature_schemes_enabled = node.pq_signature_schemes_enabled and true or false, dynamic_record_sizing_disabled = node.dynamic_record_sizing_disabled and true or false - } + } or nil } } end @@ -1237,9 +1238,7 @@ function gen_config(var) if preproxy_node then local preproxy_outbound, exist if preproxy_node.protocol == "_urltest" then - if preproxy_node.urltest_node then - preproxy_outbound, exist = gen_urltest_outbound(preproxy_node) - end + preproxy_outbound, exist = gen_urltest_outbound(preproxy_node) else preproxy_outbound = gen_outbound(node[".name"], preproxy_node) end @@ -1324,11 +1323,9 @@ function gen_config(var) end local outbound, exist if node.protocol == "_urltest" then - if node.urltest_node then - outbound, exist = gen_urltest_outbound(node) - if exist then - return outbound.tag - end + outbound, exist = gen_urltest_outbound(node) + if exist then + return outbound.tag end elseif node.protocol == "_iface" then if node.iface then diff --git a/openwrt-passwall2/luci-app-passwall2/luasrc/view/passwall2/node_config/link_share_man.htm b/openwrt-passwall2/luci-app-passwall2/luasrc/view/passwall2/node_config/link_share_man.htm index 471e867d93..5747efd189 100644 --- a/openwrt-passwall2/luci-app-passwall2/luasrc/view/passwall2/node_config/link_share_man.htm +++ b/openwrt-passwall2/luci-app-passwall2/luasrc/view/passwall2/node_config/link_share_man.htm @@ -10,21 +10,11 @@ local has_singbox = api.finded_com("sing-box") local has_xray = api.finded_com("xray") local has_hysteria2 = api.finded_com("hysteria") -local function get_core(field, candidates) - local v = map:get("@global_subscribe[0]", field) - if not v or v == "" then - for _, c in ipairs(candidates) do - if c[1] then return c[2] end - end - end - return v -end - -local ss_type = get_core("ss_type", {{has_ss,"shadowsocks-libev"},{has_ss_rust,"shadowsocks-rust"},{has_singbox,"sing-box"},{has_xray,"xray"}}) -local trojan_type = get_core("trojan_type", {{has_singbox,"sing-box"},{has_xray,"xray"}}) -local vmess_type = get_core("vmess_type", {{has_xray,"xray"},{has_singbox,"sing-box"}}) -local vless_type = get_core("vless_type", {{has_xray,"xray"},{has_singbox,"sing-box"}}) -local hysteria2_type = get_core("hysteria2_type", {{has_hysteria2,"hysteria2"},{has_singbox,"sing-box"}, {has_xray,"xray"}}) +local ss_type = api.get_core("ss_type", {{has_ss,"shadowsocks-libev"},{has_ss_rust,"shadowsocks-rust"},{has_singbox,"sing-box"},{has_xray,"xray"}}) +local trojan_type = api.get_core("trojan_type", {{has_singbox,"sing-box"},{has_xray,"xray"}}) +local vmess_type = api.get_core("vmess_type", {{has_xray,"xray"},{has_singbox,"sing-box"}}) +local vless_type = api.get_core("vless_type", {{has_xray,"xray"},{has_singbox,"sing-box"}}) +local hysteria2_type = api.get_core("hysteria2_type", {{has_hysteria2,"hysteria2"},{has_singbox,"sing-box"}, {has_xray,"xray"}}) local current_node = map:get(section) -%> @@ -648,6 +638,7 @@ local current_node = map:get(section) var params = ""; params += opt.query("sni", dom_prefix + "tls_serverName"); + params += opt.query("disable_sni", dom_prefix + "tls_disable_sni"); params += opt.query("alpn", dom_prefix + "tuic_alpn"); params += opt.query("congestion_control", dom_prefix + "tuic_congestion_control"); params += opt.query("udp_relay_mode", dom_prefix + "tuic_udp_relay_mode"); @@ -1635,6 +1626,14 @@ local current_node = map:get(section) var port = _parsedUrl.port; var search = _parsedUrl.search; var hash = _parsedUrl.hash; + if (!username || !password) { //Fix for some links will encode the ':' between the UUID and password. + const decoded = decodeURIComponent(username || password || ""); + const i = decoded.indexOf(":"); + if (i > -1) { + username = decoded.slice(0, i); + password = decoded.slice(i + 1); + } + } opt.set(dom_prefix + 'uuid', decodeURIComponent(username)); opt.set(dom_prefix + 'password', decodeURIComponent(password)); opt.set(dom_prefix + 'address', unbracketIP(hostname)); @@ -1654,6 +1653,7 @@ local current_node = map:get(section) opt.set(dom_prefix + 'tuic_udp_relay_mode', queryParam.udp_relay_mode || 'native'); opt.set(dom_prefix + 'tuic_alpn', queryParam.alpn || 'default'); opt.set(dom_prefix + 'tls_serverName', queryParam.sni || ''); + opt.set(dom_prefix + 'tls_disable_sni', queryParam.disable_sni === "1"); opt.set(dom_prefix + 'tls_allowInsecure', true); if ((queryParam.allowinsecure ?? '0') === '0' && (queryParam.insecure ?? '0') === '0') { opt.set(dom_prefix + 'tls_allowInsecure', false); diff --git a/openwrt-passwall2/luci-app-passwall2/root/usr/share/passwall2/app.sh b/openwrt-passwall2/luci-app-passwall2/root/usr/share/passwall2/app.sh index 9fef71cb9a..dcfc3d16c2 100755 --- a/openwrt-passwall2/luci-app-passwall2/root/usr/share/passwall2/app.sh +++ b/openwrt-passwall2/luci-app-passwall2/root/usr/share/passwall2/app.sh @@ -1206,7 +1206,7 @@ stop() { fi done pgrep -f "sleep.*(6s|9s|58s)" | xargs kill -9 >/dev/null 2>&1 - pgrep -af "${CONFIG}/" | awk '! /app\.sh|subscribe\.lua|rule_update\.lua|tasks\.sh|ujail/{print $1}' | xargs kill -9 >/dev/null 2>&1 + pgrep -af "${CONFIG}/" | awk '! /app\.sh|subscribe\.lua|rule_update\.lua|tasks\.sh|server_app\.lua|ujail/{print $1}' | xargs kill -9 >/dev/null 2>&1 unset V2RAY_LOCATION_ASSET unset XRAY_LOCATION_ASSET stop_crontab diff --git a/openwrt-passwall2/luci-app-passwall2/root/usr/share/passwall2/subscribe.lua b/openwrt-passwall2/luci-app-passwall2/root/usr/share/passwall2/subscribe.lua index e6517416e1..e0d28b7a44 100755 --- a/openwrt-passwall2/luci-app-passwall2/root/usr/share/passwall2/subscribe.lua +++ b/openwrt-passwall2/luci-app-passwall2/root/usr/share/passwall2/subscribe.lua @@ -33,20 +33,15 @@ local has_xray = api.finded_com("xray") local has_hysteria2 = api.finded_com("hysteria") local allowInsecure_default = true -- Nodes should be retrieved using the core type (if not set on the node subscription page, the default type will be used automatically). -local function get_core(field, candidates) - local v = uci:get(appname, "@global_subscribe[0]", field) - if not v or v == "" then - for _, c in ipairs(candidates) do - if c[1] then return c[2] end - end - end - return v -end -local ss_type_default = get_core("ss_type", {{has_ss,"shadowsocks-libev"},{has_ss_rust,"shadowsocks-rust"},{has_singbox,"sing-box"},{has_xray,"xray"}}) -local trojan_type_default = get_core("trojan_type", {{has_singbox,"sing-box"},{has_xray,"xray"}}) -local vmess_type_default = get_core("vmess_type", {{has_xray,"xray"},{has_singbox,"sing-box"}}) -local vless_type_default = get_core("vless_type", {{has_xray,"xray"},{has_singbox,"sing-box"}}) -local hysteria2_type_default = get_core("hysteria2_type", {{has_hysteria2,"hysteria2"},{has_singbox,"sing-box"},{has_xray,"xray"}}) +local ss_type_default = api.get_core("ss_type", {{has_ss,"shadowsocks-libev"},{has_ss_rust,"shadowsocks-rust"},{has_singbox,"sing-box"},{has_xray,"xray"}}) +local trojan_type_default = api.get_core("trojan_type", {{has_singbox,"sing-box"},{has_xray,"xray"}}) +local vmess_type_default = api.get_core("vmess_type", {{has_xray,"xray"},{has_singbox,"sing-box"}}) +local vless_type_default = api.get_core("vless_type", {{has_xray,"xray"},{has_singbox,"sing-box"}}) +local hysteria2_type_default = api.get_core("hysteria2_type", {{has_hysteria2,"hysteria2"},{has_singbox,"sing-box"},{has_xray,"xray"}}) +local core_has = { + ["xray"] = has_xray, ["sing-box"] = has_singbox, ["shadowsocks-libev"] = has_ss,["shadowsocks-rust"] = has_ss_rust, + ["hysteria2"] = has_hysteria2 +} local domain_strategy_default = uci:get(appname, "@global_subscribe[0]", "domain_strategy") or "" local domain_strategy_node = "" local preproxy_node_group, to_node_group, chain_node_type = "", "", "" @@ -1429,12 +1424,17 @@ local function processData(szType, content, add_mode, group) end result.remarks = UrlDecode(alias) local Info = content - if content:find("@") then + if content:find("@", 1, true) then local contents = split(content, "@") - if contents[1]:find(":") then - local userinfo = split(contents[1], ":") - result.uuid = UrlDecode(userinfo[1]) - result.password = UrlDecode(userinfo[2]) + local auth = contents[1] or "" + local idx = auth:find(":", 1, true) + if not idx then -- Fix for some links will encode the ':' between the UUID and password. + auth = UrlDecode(auth) + idx = auth:find(":", 1, true) + end + if idx then + result.uuid = UrlDecode(auth:sub(1, idx - 1)) + result.password = UrlDecode(auth:sub(idx + 1)) end Info = (contents[2] or ""):gsub("/%?", "?") end @@ -1459,6 +1459,7 @@ local function processData(szType, content, add_mode, group) result.address = host_port end result.tls_serverName = params.sni + result.tls_disable_sni = params.disable_sni result.tuic_alpn = params.alpn or "default" result.tuic_congestion_control = params.congestion_control or "cubic" result.tuic_udp_relay_mode = params.udp_relay_mode or "native" @@ -2031,23 +2032,23 @@ local execute = function() filter_keyword_discard_list_default = value.filter_discard_list or {} end local ss_type = value.ss_type or "global" - if ss_type ~= "global" then + if ss_type ~= "global" and core_has[ss_type] then ss_type_default = ss_type end local trojan_type = value.trojan_type or "global" - if trojan_type ~= "global" then + if trojan_type ~= "global" and core_has[trojan_type] then trojan_type_default = trojan_type end local vmess_type = value.vmess_type or "global" - if vmess_type ~= "global" then + if vmess_type ~= "global" and core_has[vmess_type] then vmess_type_default = vmess_type end local vless_type = value.vless_type or "global" - if vless_type ~= "global" then + if vless_type ~= "global" and core_has[vless_type] then vless_type_default = vless_type end local hysteria2_type = value.hysteria2_type or "global" - if hysteria2_type ~= "global" then + if hysteria2_type ~= "global" and core_has[hysteria2_type] then hysteria2_type_default = hysteria2_type end local domain_strategy = value.domain_strategy or "global" @@ -2103,11 +2104,21 @@ local execute = function() filter_keyword_mode_default = uci:get(appname, "@global_subscribe[0]", "filter_keyword_mode") or "0" filter_keyword_discard_list_default = uci:get(appname, "@global_subscribe[0]", "filter_discard_list") or {} filter_keyword_keep_list_default = uci:get(appname, "@global_subscribe[0]", "filter_keep_list") or {} - ss_type_default = uci:get(appname, "@global_subscribe[0]", "ss_type") or "shadowsocks-libev" - trojan_type_default = uci:get(appname, "@global_subscribe[0]", "trojan_type") or "sing-box" - vmess_type_default = uci:get(appname, "@global_subscribe[0]", "vmess_type") or "xray" - vless_type_default = uci:get(appname, "@global_subscribe[0]", "vless_type") or "xray" - hysteria2_type_default = uci:get(appname, "@global_subscribe[0]", "hysteria2_type") or "hysteria2" + + ss_type = uci:get(appname, "@global_subscribe[0]", "ss_type") or "" + ss_type_default = core_has[ss_type] and ss_type or ss_type_default + + trojan_type = uci:get(appname, "@global_subscribe[0]", "trojan_type") or "" + trojan_type_default = core_has[trojan_type] and trojan_type or trojan_type_default + + vmess_type = uci:get(appname, "@global_subscribe[0]", "vmess_type") or "" + vmess_type_default = core_has[vmess_type] and vmess_type or vmess_type_default + + vless_type = uci:get(appname, "@global_subscribe[0]", "vless_type") or "" + vless_type_default = core_has[vless_type] and vless_type or vless_type_default + + hysteria2_type = uci:get(appname, "@global_subscribe[0]", "hysteria2_type") or "" + hysteria2_type_default = core_has[hysteria2_type] and hysteria2_type or hysteria2_type_default end if #fail_list > 0 then diff --git a/sing-box/.fpm_pacman b/sing-box/.fpm_pacman new file mode 100644 index 0000000000..8c86dfd91a --- /dev/null +++ b/sing-box/.fpm_pacman @@ -0,0 +1,23 @@ +-s dir +--name sing-box +--category net +--license GPL-3.0-or-later +--description "The universal proxy platform." +--url "https://sing-box.sagernet.org/" +--maintainer "nekohasekai " +--config-files etc/sing-box/config.json +--after-install release/config/sing-box.postinst + +release/config/config.json=/etc/sing-box/config.json + +release/config/sing-box.service=/usr/lib/systemd/system/sing-box.service +release/config/sing-box@.service=/usr/lib/systemd/system/sing-box@.service +release/config/sing-box.sysusers=/usr/lib/sysusers.d/sing-box.conf +release/config/sing-box.rules=usr/share/polkit-1/rules.d/sing-box.rules +release/config/sing-box-split-dns.xml=/usr/share/dbus-1/system.d/sing-box-split-dns.conf + +release/completions/sing-box.bash=/usr/share/bash-completion/completions/sing-box.bash +release/completions/sing-box.fish=/usr/share/fish/vendor_completions.d/sing-box.fish +release/completions/sing-box.zsh=/usr/share/zsh/site-functions/_sing-box + +LICENSE=/usr/share/licenses/sing-box/LICENSE diff --git a/sing-box/.github/CRONET_GO_VERSION b/sing-box/.github/CRONET_GO_VERSION index 52565262a9..e438ee5d1b 100644 --- a/sing-box/.github/CRONET_GO_VERSION +++ b/sing-box/.github/CRONET_GO_VERSION @@ -1 +1 @@ -34ec1a064c64f274c4e70bf7a9c7de4bb12331f6 +17c7ef18afa63b205e835c6270277b29382eb8e3 diff --git a/sing-box/.github/workflows/build.yml b/sing-box/.github/workflows/build.yml index bbd61b854f..788b20afdc 100644 --- a/sing-box/.github/workflows/build.yml +++ b/sing-box/.github/workflows/build.yml @@ -373,7 +373,7 @@ jobs: sudo gem install fpm sudo apt-get update sudo apt-get install -y libarchive-tools - cp .fpm_systemd .fpm + cp .fpm_pacman .fpm fpm -t pacman \ -v "$PKG_VERSION" \ -p "dist/sing-box_${{ needs.calculate_version.outputs.version }}_${{ matrix.os }}_${{ matrix.pacman }}.pkg.tar.zst" \ diff --git a/sing-box/Makefile b/sing-box/Makefile index 88eb89c60c..c30cd78f6f 100644 --- a/sing-box/Makefile +++ b/sing-box/Makefile @@ -249,8 +249,8 @@ lib_apple_new: $(SING_FFI) generate --config $(LIBBOX_FFI_CONFIG) --platform-type apple lib_install: - go install -v github.com/sagernet/gomobile/cmd/gomobile@v0.1.11 - go install -v github.com/sagernet/gomobile/cmd/gobind@v0.1.11 + go install -v github.com/sagernet/gomobile/cmd/gomobile@v0.1.12 + go install -v github.com/sagernet/gomobile/cmd/gobind@v0.1.12 docs: venv/bin/mkdocs serve diff --git a/sing-box/clients/android/.editorconfig b/sing-box/clients/android/.editorconfig deleted file mode 100644 index a7e5c08079..0000000000 --- a/sing-box/clients/android/.editorconfig +++ /dev/null @@ -1,37 +0,0 @@ -root = true - -[*] -charset = utf-8 -end_of_line = lf -insert_final_newline = true -trim_trailing_whitespace = true - -[*.{kt,kts}] -indent_size = 4 -indent_style = space -max_line_length = 140 -ij_kotlin_allow_trailing_comma = true -ij_kotlin_allow_trailing_comma_on_call_site = true -ktlint_function_naming_ignore_when_annotated_with = Composable -ktlint_standard_function-naming = disabled -ktlint_standard_no-wildcard-imports = disabled -ktlint_standard_property-naming = disabled - -[*.xml] -indent_size = 2 -indent_style = space - -[*.gradle] -indent_size = 4 -indent_style = space - -[*.gradle.kts] -indent_size = 4 -indent_style = space - -[*.json] -indent_size = 2 -indent_style = space - -[*.md] -trim_trailing_whitespace = false diff --git a/sing-box/clients/android/.gitignore b/sing-box/clients/android/.gitignore index 94ba292810..30679fb463 100644 --- a/sing-box/clients/android/.gitignore +++ b/sing-box/clients/android/.gitignore @@ -3,7 +3,7 @@ /local.properties /.idea/ .DS_Store -build/ +/build /captures .externalNativeBuild .cxx diff --git a/sing-box/clients/android/app/build.gradle b/sing-box/clients/android/app/build.gradle new file mode 100644 index 0000000000..228d38ff44 --- /dev/null +++ b/sing-box/clients/android/app/build.gradle @@ -0,0 +1,202 @@ +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + id "com.android.application" + id "kotlin-android" + id "kotlin-parcelize" + id "com.google.devtools.ksp" + id "org.jetbrains.kotlin.plugin.compose" + id "com.github.triplet.play" +} + +android { + namespace "io.nekohasekai.sfa" + compileSdk 36 + + ndkVersion "28.0.13004108" + + def ndkPathFromEnv = System.getenv("ANDROID_NDK_HOME") + if (ndkPathFromEnv != null) { + ndkPath ndkPathFromEnv + } + + ksp { + arg("room.incremental", "true") + arg("room.schemaLocation", "$projectDir/schemas") + } + + defaultConfig { + applicationId "io.nekohasekai.sfa" + minSdk 21 + targetSdk 35 + versionCode getVersionProps("VERSION_CODE").toInteger() + versionName getVersionProps("VERSION_NAME") + setProperty("archivesBaseName", "SFA-" + versionName) + } + + signingConfigs { + release { + storeFile file("release.keystore") + storePassword getProps("KEYSTORE_PASS") + keyAlias getProps("ALIAS_NAME") + keyPassword getProps("ALIAS_PASS") + } + } + + buildTypes { + debug { + if (getProps("KEYSTORE_PASS") != "") { + signingConfig signingConfigs.release + } + } + release { + minifyEnabled true + proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" + signingConfig signingConfigs.release + vcsInfo.include false + } + } + + dependenciesInfo { + includeInApk = false + } + + flavorDimensions "vendor" + productFlavors { + play { + } + other { + } + } + + splits { + abi { + enable true + universalApk true + reset() + include "armeabi-v7a", "arm64-v8a", "x86", "x86_64" + } + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + buildFeatures { + viewBinding true + aidl true + compose true + } + + applicationVariants.configureEach { variant -> + variant.outputs.configureEach { + outputFileName = (outputFileName as String).replace("-release", "") + outputFileName = (outputFileName as String).replace("-play", "") + outputFileName = (outputFileName as String).replace("-other", "-foss") + } + } +} + +dependencies { + implementation(fileTree("libs")) + + implementation "androidx.core:core-ktx:1.16.0" + implementation "androidx.appcompat:appcompat:1.7.1" + implementation "com.google.android.material:material:1.12.0" + implementation "androidx.constraintlayout:constraintlayout:2.2.1" + implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.9.2" + implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.9.2" + implementation "androidx.navigation:navigation-fragment-ktx:2.9.3" + implementation "androidx.navigation:navigation-ui-ktx:2.9.3" + implementation "com.google.zxing:core:3.5.3" + implementation "androidx.room:room-runtime:2.7.2" + implementation "androidx.coordinatorlayout:coordinatorlayout:1.3.0" + implementation "androidx.preference:preference-ktx:1.2.1" + implementation "androidx.camera:camera-view:1.4.2" + implementation "androidx.camera:camera-lifecycle:1.4.2" + implementation "androidx.camera:camera-camera2:1.4.2" + ksp "androidx.room:room-compiler:2.7.2" + implementation "androidx.work:work-runtime-ktx:2.10.3" + implementation "androidx.browser:browser:1.9.0" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2" + + // DO NOT UPDATE (minSdkVersion updated) + implementation "com.blacksquircle.ui:editorkit:2.2.0" + implementation "com.blacksquircle.ui:language-json:2.2.0" + + implementation("com.android.tools.smali:smali-dexlib2:3.0.9") { + exclude group: "com.google.guava", module: "guava" + } + implementation "com.google.guava:guava:33.4.8-android" + playImplementation "com.google.android.play:app-update-ktx:2.1.0" + playImplementation "com.google.android.gms:play-services-mlkit-barcode-scanning:18.3.1" + + def composeBom = platform('androidx.compose:compose-bom:2025.07.00') + implementation composeBom + androidTestImplementation composeBom + implementation 'androidx.compose.material3:material3' + implementation 'androidx.compose.ui:ui-tooling-preview' + debugImplementation 'androidx.compose.ui:ui-tooling' + androidTestImplementation 'androidx.compose.ui:ui-test-junit4' + debugImplementation 'androidx.compose.ui:ui-test-manifest' + implementation 'androidx.compose.material:material-icons-extended' + implementation 'androidx.activity:activity-compose:1.10.1' + implementation 'me.zhanghai.compose.preference:library:1.1.1' + implementation "androidx.navigation:navigation-compose:2.9.3" +} + +def playCredentialsJSON = rootProject.file("service-account-credentials.json") +if (playCredentialsJSON.exists()) { + play { + serviceAccountCredentials = playCredentialsJSON + defaultToAppBundles = true + def version = getVersionProps("VERSION_NAME") + if (version.contains("alpha") || version.contains("beta") || version.contains("rc")) { + track = "beta" + } else { + track = "production" + } + } +} + +tasks.withType(KotlinCompile.class).configureEach { + kotlinOptions { + jvmTarget = "1.8" + } +} + +def getProps(String propName) { + def propsInEnv = System.getenv("LOCAL_PROPERTIES") + if (propsInEnv != null) { + def props = new Properties() + props.load(new ByteArrayInputStream(Base64.decoder.decode(propsInEnv))) + String value = props[propName] + if (value != null) { + return value + } + } + def propsFile = rootProject.file("local.properties") + if (propsFile.exists()) { + def props = new Properties() + props.load(new FileInputStream(propsFile)) + String value = props[propName] + if (value != null) { + return value + } + } + return "" +} + +def getVersionProps(String propName) { + def propsFile = rootProject.file("version.properties") + if (propsFile.exists()) { + def props = new Properties() + props.load(new FileInputStream(propsFile)) + String value = props[propName] + if (value != null) { + return value + } + } + return "" +} \ No newline at end of file diff --git a/sing-box/clients/android/app/build.gradle.kts b/sing-box/clients/android/app/build.gradle.kts deleted file mode 100644 index a7394e5f04..0000000000 --- a/sing-box/clients/android/app/build.gradle.kts +++ /dev/null @@ -1,366 +0,0 @@ -import org.gradle.api.file.DuplicatesStrategy -import org.gradle.api.tasks.Sync -import org.jetbrains.kotlin.gradle.dsl.JvmTarget -import org.jetbrains.kotlin.gradle.tasks.KotlinCompile -import java.io.ByteArrayInputStream -import java.io.FileInputStream -import java.util.Base64 -import java.util.Properties - -plugins { - id("com.android.application") - id("org.jetbrains.kotlin.android") - id("org.jetbrains.kotlin.plugin.parcelize") - id("com.google.devtools.ksp") - id("org.jetbrains.kotlin.plugin.compose") - id("org.jetbrains.kotlin.plugin.serialization") - id("com.github.triplet.play") - alias(libs.plugins.spotless) -} - -fun getProps(propName: String): String { - val propsInEnv = System.getenv("LOCAL_PROPERTIES") - if (propsInEnv != null) { - val props = Properties() - props.load(ByteArrayInputStream(Base64.getDecoder().decode(propsInEnv))) - val value = props.getProperty(propName) - if (value != null) { - return value - } - } - val propsFile = rootProject.file("local.properties") - if (propsFile.exists()) { - val props = Properties() - props.load(FileInputStream(propsFile)) - val value = props.getProperty(propName) - if (value != null) { - return value - } - } - return "" -} - -fun getVersionProps(propName: String): String { - val propsFile = rootProject.file("version.properties") - if (propsFile.exists()) { - val props = Properties() - props.load(FileInputStream(propsFile)) - val value = props.getProperty(propName) - if (value != null) { - return value - } - } - return "" -} - -android { - namespace = "io.nekohasekai.sfa" - compileSdk = 36 - - ndkVersion = "28.0.13004108" - - System.getenv("ANDROID_NDK_HOME")?.let { ndkPath = it } - - ksp { - arg("room.incremental", "true") - arg("room.schemaLocation", "${projectDir}/schemas") - } - - defaultConfig { - applicationId = "io.nekohasekai.sfa" - minSdk = 21 - targetSdk = 35 - versionCode = getVersionProps("VERSION_CODE").toInt() - versionName = getVersionProps("VERSION_NAME") - base.archivesName.set("SFA-${versionName}") - } - - signingConfigs { - create("release") { - storeFile = file("release.keystore") - storePassword = getProps("KEYSTORE_PASS") - keyAlias = getProps("ALIAS_NAME") - keyPassword = getProps("ALIAS_PASS") - } - } - - buildTypes { - debug { - if (getProps("KEYSTORE_PASS").isNotEmpty()) { - signingConfig = signingConfigs.getByName("release") - } - } - release { - isMinifyEnabled = true - proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") - signingConfig = signingConfigs.getByName("release") - vcsInfo.include = false - } - } - - dependenciesInfo { - includeInApk = false - } - - flavorDimensions += "vendor" - productFlavors { - create("play") { - minSdk = 23 - } - create("other") { - minSdk = 23 - } - create("otherLegacy") { - minSdk = 21 - } - } - - sourceSets { - getByName("play") { - java.directories.add("src/minApi23/java") - aidl.directories.add("src/minApi23/aidl") - } - getByName("other") { - java.directories.addAll(listOf("src/minApi23/java", "src/github/java")) - aidl.directories.add("src/minApi23/aidl") - } - getByName("otherLegacy") { - java.directories.addAll(listOf("src/minApi21/java", "src/github/java")) - aidl.directories.add("src/minApi23/aidl") - } - } - - splits { - abi { - isEnable = true - isUniversalApk = true - reset() - include("armeabi-v7a", "arm64-v8a", "x86", "x86_64") - } - } - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - - androidResources { - generateLocaleConfig = true - } - - buildFeatures { - viewBinding = true - aidl = true - compose = true - buildConfig = true - } - - packaging { - jniLibs { - useLegacyPackaging = true - } - } - - applicationVariants.configureEach { - outputs.configureEach { - val output = this as com.android.build.gradle.internal.api.BaseVariantOutputImpl - var fileName = output.outputFileName - fileName = fileName.replace("-release", "") - fileName = fileName.replace("-play", "-play") - fileName = fileName.replace("-otherLegacy", "-legacy-android-5") - fileName = fileName.replace("-other", "") - output.outputFileName = fileName - } - } -} - -dependencies { - // libbox - "playImplementation"(files("libs/libbox.aar")) - "otherImplementation"(files("libs/libbox.aar")) - "otherLegacyImplementation"(files("libs/libbox-legacy.aar")) - - // API level specific versions - val lifecycleVersion23 = "2.10.0" - val roomVersion23 = "2.8.4" - val workVersion23 = "2.11.1" - val cameraVersion23 = "1.5.3" - val browserVersion23 = "1.9.0" - - val lifecycleVersion21 = "2.9.4" - val roomVersion21 = "2.7.2" - val workVersion21 = "2.10.5" - val cameraVersion21 = "1.4.2" - val browserVersion21 = "1.9.0" - - // Common dependencies (no API level difference) - implementation("androidx.core:core-ktx:1.17.0") - implementation("androidx.appcompat:appcompat:1.7.1") - implementation("com.google.android.material:material:1.13.0") - implementation("androidx.constraintlayout:constraintlayout:2.2.1") - implementation("androidx.navigation:navigation-fragment-ktx:2.9.7") - implementation("androidx.navigation:navigation-ui-ktx:2.9.7") - implementation("com.google.zxing:core:3.5.4") - implementation("androidx.coordinatorlayout:coordinatorlayout:1.3.0") - implementation("androidx.preference:preference-ktx:1.2.1") - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2") - implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.10.0") - implementation("com.blacksquircle.ui:editorkit:2.2.0") - implementation("com.blacksquircle.ui:language-json:2.2.0") - implementation("com.android.tools.smali:smali-dexlib2:3.0.9") { - exclude(group = "com.google.guava", module = "guava") - } - implementation("com.google.guava:guava:33.5.0-android") - - // API 23+ dependencies (play/other) - "playImplementation"("androidx.lifecycle:lifecycle-livedata-ktx:$lifecycleVersion23") - "playImplementation"("androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion23") - "playImplementation"("androidx.lifecycle:lifecycle-process:$lifecycleVersion23") - "playImplementation"("androidx.room:room-runtime:$roomVersion23") - "playImplementation"("androidx.work:work-runtime-ktx:$workVersion23") - "playImplementation"("androidx.camera:camera-view:$cameraVersion23") - "playImplementation"("androidx.camera:camera-lifecycle:$cameraVersion23") - "playImplementation"("androidx.camera:camera-camera2:$cameraVersion23") - "playImplementation"("androidx.browser:browser:$browserVersion23") - "playAnnotationProcessor"("androidx.room:room-compiler:$roomVersion23") - "kspPlay"("androidx.room:room-compiler:$roomVersion23") - - "otherImplementation"("androidx.lifecycle:lifecycle-livedata-ktx:$lifecycleVersion23") - "otherImplementation"("androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion23") - "otherImplementation"("androidx.lifecycle:lifecycle-process:$lifecycleVersion23") - "otherImplementation"("androidx.room:room-runtime:$roomVersion23") - "otherImplementation"("androidx.work:work-runtime-ktx:$workVersion23") - "otherImplementation"("androidx.camera:camera-view:$cameraVersion23") - "otherImplementation"("androidx.camera:camera-lifecycle:$cameraVersion23") - "otherImplementation"("androidx.camera:camera-camera2:$cameraVersion23") - "otherImplementation"("androidx.browser:browser:$browserVersion23") - "kspOther"("androidx.room:room-compiler:$roomVersion23") - - // API 21 dependencies (otherLegacy) - "otherLegacyImplementation"("androidx.lifecycle:lifecycle-livedata-ktx:$lifecycleVersion21") - "otherLegacyImplementation"("androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion21") - "otherLegacyImplementation"("androidx.lifecycle:lifecycle-process:$lifecycleVersion21") - "otherLegacyImplementation"("androidx.room:room-runtime:$roomVersion21") - "otherLegacyImplementation"("androidx.work:work-runtime-ktx:$workVersion21") - "otherLegacyImplementation"("androidx.camera:camera-view:$cameraVersion21") - "otherLegacyImplementation"("androidx.camera:camera-lifecycle:$cameraVersion21") - "otherLegacyImplementation"("androidx.camera:camera-camera2:$cameraVersion21") - "otherLegacyImplementation"("androidx.browser:browser:$browserVersion21") - "kspOtherLegacy"("androidx.room:room-compiler:$roomVersion21") - - // Play Store specific - "playImplementation"("com.google.android.play:app-update-ktx:2.1.0") - "playImplementation"("com.google.android.gms:play-services-mlkit-barcode-scanning:18.3.1") - - // Shizuku (play and other flavors, API 23+ only) - val shizukuVersion = "12.2.0" - "playImplementation"("dev.rikka.shizuku:api:$shizukuVersion") - "playImplementation"("dev.rikka.shizuku:provider:$shizukuVersion") - "otherImplementation"("dev.rikka.shizuku:api:$shizukuVersion") - "otherImplementation"("dev.rikka.shizuku:provider:$shizukuVersion") - - // libsu for ROOT package query (all flavors) - val libsuVersion = "6.0.0" - "playImplementation"("com.github.topjohnwu.libsu:core:$libsuVersion") - "playImplementation"("com.github.topjohnwu.libsu:service:$libsuVersion") - "otherImplementation"("com.github.topjohnwu.libsu:core:$libsuVersion") - "otherImplementation"("com.github.topjohnwu.libsu:service:$libsuVersion") - "otherLegacyImplementation"("com.github.topjohnwu.libsu:core:$libsuVersion") - "otherLegacyImplementation"("com.github.topjohnwu.libsu:service:$libsuVersion") - - // Compose dependencies - API 23+ (play/other) - val composeBom23 = platform("androidx.compose:compose-bom:2026.02.00") - val activityVersion23 = "1.12.4" - val lifecycleComposeVersion23 = "2.10.0" - - "playImplementation"(composeBom23) - "playImplementation"("androidx.compose.material3:material3") - "playImplementation"("androidx.compose.material3.adaptive:adaptive") - "playImplementation"("androidx.compose.ui:ui") - "playImplementation"("androidx.compose.ui:ui-tooling-preview") - "playImplementation"("androidx.compose.material:material-icons-extended") - "playImplementation"("androidx.activity:activity-compose:$activityVersion23") - "playImplementation"("androidx.navigation:navigation-compose:2.9.7") - "playImplementation"("androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycleComposeVersion23") - "playImplementation"("androidx.compose.runtime:runtime-livedata") - - "otherImplementation"(composeBom23) - "otherImplementation"("androidx.compose.material3:material3") - "otherImplementation"("androidx.compose.material3.adaptive:adaptive") - "otherImplementation"("androidx.compose.ui:ui") - "otherImplementation"("androidx.compose.ui:ui-tooling-preview") - "otherImplementation"("androidx.compose.material:material-icons-extended") - "otherImplementation"("androidx.activity:activity-compose:$activityVersion23") - "otherImplementation"("androidx.navigation:navigation-compose:2.9.7") - "otherImplementation"("androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycleComposeVersion23") - "otherImplementation"("androidx.compose.runtime:runtime-livedata") - - // Compose dependencies - API 21 (otherLegacy) - val composeBom21 = platform("androidx.compose:compose-bom:2025.01.00") - val activityVersion21 = "1.11.0" - val lifecycleComposeVersion21 = "2.9.4" - - "otherLegacyImplementation"(composeBom21) - "otherLegacyImplementation"("androidx.compose.material3:material3") - "otherLegacyImplementation"("androidx.compose.material3.adaptive:adaptive") - "otherLegacyImplementation"("androidx.compose.ui:ui") - "otherLegacyImplementation"("androidx.compose.ui:ui-tooling-preview") - "otherLegacyImplementation"("androidx.compose.material:material-icons-extended") - "otherLegacyImplementation"("androidx.activity:activity-compose:$activityVersion21") - "otherLegacyImplementation"("androidx.navigation:navigation-compose:2.9.7") - "otherLegacyImplementation"("androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycleComposeVersion21") - "otherLegacyImplementation"("androidx.compose.runtime:runtime-livedata") - - // Debug/Test dependencies - debugImplementation("androidx.compose.ui:ui-tooling") - debugImplementation("androidx.compose.ui:ui-test-manifest") - androidTestImplementation("androidx.compose.ui:ui-test-junit4") - - // Common Compose-related libraries - implementation("sh.calvin.reorderable:reorderable:3.0.0") - implementation("com.github.jeziellago:compose-markdown:0.5.8") - implementation("org.kodein.emoji:emoji-kt:2.3.0") - - // Xposed API for self-hooking VPN hide module - compileOnly("de.robv.android.xposed:api:82") - compileOnly(project(":libxposed-api")) -} - -val playCredentialsJSON = rootProject.file("service-account-credentials.json") -if (playCredentialsJSON.exists()) { - play { - serviceAccountCredentials.set(playCredentialsJSON) - defaultToAppBundles.set(true) - val version = getVersionProps("VERSION_NAME") - track.set( - if (version.contains("alpha") || version.contains("beta")/* || version.contains("rc")*/) { - "beta" - } else { - "production" - } - ) - } -} - -tasks.withType().configureEach { - compilerOptions { - jvmTarget.set(JvmTarget.JVM_17) - } -} - -spotless { - kotlin { - target("src/**/*.kt") - ktlint(libs.versions.ktlint.get()) - .editorConfigOverride(mapOf( - "ktlint_standard_backing-property-naming" to "disabled", - "ktlint_standard_filename" to "disabled", - "ktlint_standard_max-line-length" to "disabled", - "ktlint_standard_property-naming" to "disabled", - )) - } - java { - target("src/**/*.java") - googleJavaFormat() - } -} diff --git a/sing-box/clients/android/app/schemas/io.nekohasekai.sfa.database.ProfileDatabase/1.json b/sing-box/clients/android/app/schemas/io.nekohasekai.sfa.database.ProfileDatabase/1.json index b7cac4cdd3..ec8282d392 100644 --- a/sing-box/clients/android/app/schemas/io.nekohasekai.sfa.database.ProfileDatabase/1.json +++ b/sing-box/clients/android/app/schemas/io.nekohasekai.sfa.database.ProfileDatabase/1.json @@ -2,11 +2,11 @@ "formatVersion": 1, "database": { "version": 1, - "identityHash": "24de05fe91b147c75b870f91b2f4871b", + "identityHash": "b7bfa362ec191b0a18660e615da81e46", "entities": [ { "tableName": "profiles", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userOrder` INTEGER NOT NULL, `name` TEXT NOT NULL, `icon` TEXT, `typed` BLOB NOT NULL)", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userOrder` INTEGER NOT NULL, `name` TEXT NOT NULL, `typed` BLOB NOT NULL)", "fields": [ { "fieldPath": "id", @@ -26,11 +26,6 @@ "affinity": "TEXT", "notNull": true }, - { - "fieldPath": "icon", - "columnName": "icon", - "affinity": "TEXT" - }, { "fieldPath": "typed", "columnName": "typed", @@ -43,12 +38,15 @@ "columnNames": [ "id" ] - } + }, + "indices": [], + "foreignKeys": [] } ], + "views": [], "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, '24de05fe91b147c75b870f91b2f4871b')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b7bfa362ec191b0a18660e615da81e46')" ] } } \ No newline at end of file diff --git a/sing-box/clients/android/app/schemas/io.nekohasekai.sfa.database.ProfileDatabase/2.json b/sing-box/clients/android/app/schemas/io.nekohasekai.sfa.database.ProfileDatabase/2.json deleted file mode 100644 index bd414a07eb..0000000000 --- a/sing-box/clients/android/app/schemas/io.nekohasekai.sfa.database.ProfileDatabase/2.json +++ /dev/null @@ -1,55 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 2, - "identityHash": "dc5fb65e389df8c8391b3435652f4c64", - "entities": [ - { - "tableName": "profiles", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userOrder` INTEGER NOT NULL, `name` TEXT NOT NULL, `icon` TEXT DEFAULT NULL, `typed` BLOB NOT NULL)", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "userOrder", - "columnName": "userOrder", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "icon", - "columnName": "icon", - "affinity": "TEXT", - "defaultValue": "NULL" - }, - { - "fieldPath": "typed", - "columnName": "typed", - "affinity": "BLOB", - "notNull": true - } - ], - "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, 'dc5fb65e389df8c8391b3435652f4c64')" - ] - } -} \ No newline at end of file diff --git a/sing-box/clients/android/app/src/github/AndroidManifest.xml b/sing-box/clients/android/app/src/github/AndroidManifest.xml deleted file mode 100644 index 85fdf1e052..0000000000 --- a/sing-box/clients/android/app/src/github/AndroidManifest.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/sing-box/clients/android/app/src/github/java/io/nekohasekai/sfa/vendor/ApkDownloader.kt b/sing-box/clients/android/app/src/github/java/io/nekohasekai/sfa/vendor/ApkDownloader.kt deleted file mode 100644 index 98ce882d59..0000000000 --- a/sing-box/clients/android/app/src/github/java/io/nekohasekai/sfa/vendor/ApkDownloader.kt +++ /dev/null @@ -1,43 +0,0 @@ -package io.nekohasekai.sfa.vendor - -import io.nekohasekai.libbox.Libbox -import io.nekohasekai.sfa.Application -import io.nekohasekai.sfa.update.UpdateState -import io.nekohasekai.sfa.utils.HTTPClient -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import java.io.Closeable -import java.io.File - -class ApkDownloader : Closeable { - private val client = Libbox.newHTTPClient().apply { - modernTLS() - keepAlive() - } - - suspend fun download(url: String): File = withContext(Dispatchers.IO) { - val cacheDir = File(Application.application.cacheDir, "updates") - cacheDir.mkdirs() - val apkFile = File(cacheDir, "update.apk") - - if (apkFile.exists()) apkFile.delete() - - val request = client.newRequest() - request.setUserAgent(HTTPClient.userAgent) - request.setURL(url) - - val response = request.execute() - response.writeTo(apkFile.absolutePath) - - if (!apkFile.exists() || apkFile.length() == 0L) { - throw Exception("Download failed: empty file") - } - - UpdateState.saveApkPath(apkFile) - apkFile - } - - override fun close() { - client.close() - } -} diff --git a/sing-box/clients/android/app/src/github/java/io/nekohasekai/sfa/vendor/GitHubUpdateChecker.kt b/sing-box/clients/android/app/src/github/java/io/nekohasekai/sfa/vendor/GitHubUpdateChecker.kt deleted file mode 100644 index d241a2c37f..0000000000 --- a/sing-box/clients/android/app/src/github/java/io/nekohasekai/sfa/vendor/GitHubUpdateChecker.kt +++ /dev/null @@ -1,149 +0,0 @@ -package io.nekohasekai.sfa.vendor - -import android.os.Build -import io.nekohasekai.libbox.Libbox -import io.nekohasekai.sfa.BuildConfig -import io.nekohasekai.sfa.ktx.unwrap -import io.nekohasekai.sfa.update.UpdateInfo -import io.nekohasekai.sfa.update.UpdateTrack -import io.nekohasekai.sfa.utils.HTTPClient -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json -import java.io.Closeable - -class GitHubUpdateChecker : Closeable { - companion object { - private const val RELEASES_URL = "https://api.github.com/repos/SagerNet/sing-box/releases" - private const val METADATA_FILENAME = "SFA-version-metadata.json" - } - - private val client = Libbox.newHTTPClient().apply { - modernTLS() - keepAlive() - } - - private val json = Json { ignoreUnknownKeys = true } - - fun checkUpdate(track: UpdateTrack): UpdateInfo? { - val releases = getReleases() - var selected: ReleaseCandidate? = null - - for (release in releases) { - if (!isReleaseInTrack(release, track)) { - continue - } - val metadata = runCatching { downloadMetadata(release) }.getOrNull() ?: continue - if (!isNewerThanCurrent(metadata.versionName)) { - continue - } - val currentBest = selected - if (currentBest == null || isBetterVersion(metadata, currentBest.metadata)) { - selected = ReleaseCandidate(release, metadata) - } - } - - val release = selected?.release ?: return null - val metadata = selected.metadata - - val isLegacy = Build.VERSION.SDK_INT < Build.VERSION_CODES.M - val apkAsset = release.assets.find { asset -> - asset.name.endsWith(".apk") && - !asset.name.contains("play") && - asset.name.contains("legacy-android-5") == isLegacy - } - - return UpdateInfo( - versionCode = metadata.versionCode, - versionName = metadata.versionName, - downloadUrl = apkAsset?.browserDownloadUrl ?: release.htmlUrl, - releaseUrl = release.htmlUrl, - releaseNotes = release.body, - isPrerelease = release.prerelease, - fileSize = apkAsset?.size ?: 0, - ) - } - - private fun getReleases(): List { - val request = client.newRequest() - request.setURL(RELEASES_URL) - request.setHeader("Accept", "application/vnd.github.v3+json") - request.setUserAgent(HTTPClient.userAgent) - - val response = request.execute() - val content = response.content.unwrap - - return json.decodeFromString(content) - } - - private fun isReleaseInTrack(release: GitHubRelease, track: UpdateTrack): Boolean { - if (release.draft) { - return false - } - return when (track) { - UpdateTrack.STABLE -> !release.prerelease - UpdateTrack.BETA -> true - } - } - - private fun isNewerThanCurrent(versionName: String): Boolean { - return Libbox.compareSemver(versionName, BuildConfig.VERSION_NAME) - } - - private fun isBetterVersion(version: VersionMetadata, other: VersionMetadata): Boolean { - if (Libbox.compareSemver(version.versionName, other.versionName)) { - return true - } - if (Libbox.compareSemver(other.versionName, version.versionName)) { - return false - } - return version.versionCode > other.versionCode - } - - private fun downloadMetadata(release: GitHubRelease): VersionMetadata? { - val metadataAsset = release.assets.find { it.name == METADATA_FILENAME } - ?: return null - - val request = client.newRequest() - request.setURL(metadataAsset.browserDownloadUrl) - request.setUserAgent(HTTPClient.userAgent) - - val response = request.execute() - val content = response.content.unwrap - - return json.decodeFromString(content) - } - - override fun close() { - client.close() - } - - @Serializable - data class GitHubRelease( - @SerialName("tag_name") val tagName: String = "", - val name: String = "", - val body: String? = null, - val draft: Boolean = false, - val prerelease: Boolean = false, - @SerialName("html_url") val htmlUrl: String = "", - val assets: List = emptyList(), - ) - - @Serializable - data class GitHubAsset( - val name: String = "", - @SerialName("browser_download_url") val browserDownloadUrl: String = "", - val size: Long = 0, - ) - - @Serializable - data class VersionMetadata( - @SerialName("version_code") val versionCode: Int = 0, - @SerialName("version_name") val versionName: String = "", - ) - - private data class ReleaseCandidate( - val release: GitHubRelease, - val metadata: VersionMetadata, - ) -} diff --git a/sing-box/clients/android/app/src/github/java/io/nekohasekai/sfa/vendor/InstallResultReceiver.kt b/sing-box/clients/android/app/src/github/java/io/nekohasekai/sfa/vendor/InstallResultReceiver.kt deleted file mode 100644 index b3a082f796..0000000000 --- a/sing-box/clients/android/app/src/github/java/io/nekohasekai/sfa/vendor/InstallResultReceiver.kt +++ /dev/null @@ -1,47 +0,0 @@ -package io.nekohasekai.sfa.vendor - -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.pm.PackageInstaller -import android.util.Log -import io.nekohasekai.sfa.update.UpdateState - -class InstallResultReceiver : BroadcastReceiver() { - companion object { - const val ACTION_INSTALL_COMPLETE = "io.nekohasekai.sfa.INSTALL_COMPLETE" - private const val TAG = "InstallResultReceiver" - } - - override fun onReceive(context: Context, intent: Intent) { - if (intent.action != ACTION_INSTALL_COMPLETE) return - - val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE) - val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE) - - Log.d(TAG, "Install result: status=$status, message=$message") - - when (status) { - PackageInstaller.STATUS_PENDING_USER_ACTION -> { - val confirmIntent = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) { - intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java) - } else { - @Suppress("DEPRECATION") - intent.getParcelableExtra(Intent.EXTRA_INTENT) - } - confirmIntent?.let { - it.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - context.startActivity(it) - } - } - PackageInstaller.STATUS_SUCCESS -> { - Log.d(TAG, "Installation successful") - UpdateState.setInstallStatus(UpdateState.InstallStatus.Success) - } - else -> { - Log.e(TAG, "Installation failed: $status - $message") - UpdateState.setInstallStatus(UpdateState.InstallStatus.Failed(message ?: "Unknown error")) - } - } - } -} diff --git a/sing-box/clients/android/app/src/github/java/io/nekohasekai/sfa/vendor/RootInstaller.kt b/sing-box/clients/android/app/src/github/java/io/nekohasekai/sfa/vendor/RootInstaller.kt deleted file mode 100644 index 482ca04086..0000000000 --- a/sing-box/clients/android/app/src/github/java/io/nekohasekai/sfa/vendor/RootInstaller.kt +++ /dev/null @@ -1,79 +0,0 @@ -package io.nekohasekai.sfa.vendor - -import android.content.Intent -import android.content.ServiceConnection -import android.os.Handler -import android.os.IBinder -import android.os.Looper -import android.os.ParcelFileDescriptor -import com.topjohnwu.superuser.ipc.RootService -import io.nekohasekai.sfa.Application -import io.nekohasekai.sfa.bg.IRootService -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlinx.coroutines.withContext -import java.io.File -import kotlin.coroutines.resume -import kotlin.coroutines.resumeWithException - -object RootInstaller { - - suspend fun install(apkFile: File) { - withContext(Dispatchers.IO) { - bindRootService().use { handle -> - ParcelFileDescriptor.open(apkFile, ParcelFileDescriptor.MODE_READ_ONLY).use { pfd -> - handle.service.installPackage( - pfd, - apkFile.length(), - android.os.Process.myUserHandle().hashCode(), - ) - } - } - } - } - - private suspend fun bindRootService(): RootServiceHandle { - return withContext(Dispatchers.Main) { - suspendCancellableCoroutine { continuation -> - val conn = object : ServiceConnection { - override fun onServiceConnected(name: android.content.ComponentName?, binder: IBinder?) { - val svc = if (binder != null && binder.pingBinder()) { - IRootService.Stub.asInterface(binder) - } else { - null - } - if (svc == null) { - continuation.resumeWithException(IllegalStateException("Invalid root service binder")) - return - } - continuation.resume(RootServiceHandle(this, svc)) - } - - override fun onServiceDisconnected(name: android.content.ComponentName?) { - // Ignored - } - } - - try { - val intent = Intent(Application.application, Class.forName("io.nekohasekai.sfa.bg.RootServer")) - RootService.bind(intent, conn) - } catch (e: Throwable) { - continuation.resumeWithException(e) - return@suspendCancellableCoroutine - } - - continuation.invokeOnCancellation { - RootService.unbind(conn) - } - } - } - } - - private class RootServiceHandle(val connection: ServiceConnection, val service: IRootService) : java.io.Closeable { - override fun close() { - Handler(Looper.getMainLooper()).post { - RootService.unbind(connection) - } - } - } -} diff --git a/sing-box/clients/android/app/src/github/java/io/nekohasekai/sfa/vendor/SystemPackageInstaller.kt b/sing-box/clients/android/app/src/github/java/io/nekohasekai/sfa/vendor/SystemPackageInstaller.kt deleted file mode 100644 index 149c4f74d0..0000000000 --- a/sing-box/clients/android/app/src/github/java/io/nekohasekai/sfa/vendor/SystemPackageInstaller.kt +++ /dev/null @@ -1,45 +0,0 @@ -package io.nekohasekai.sfa.vendor - -import android.app.PendingIntent -import android.content.Context -import android.content.Intent -import android.os.Build -import java.io.File -import java.io.FileInputStream -import android.content.pm.PackageInstaller as AndroidPackageInstaller - -object SystemPackageInstaller { - - fun canSystemSilentInstall(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S - - fun install(context: Context, apkFile: File) { - val packageInstaller = context.packageManager.packageInstaller - val params = AndroidPackageInstaller.SessionParams(AndroidPackageInstaller.SessionParams.MODE_FULL_INSTALL) - params.setAppPackageName(context.packageName) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - params.setRequireUserAction(AndroidPackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED) - } - - val sessionId = packageInstaller.createSession(params) - packageInstaller.openSession(sessionId).use { session -> - session.openWrite("update.apk", 0, apkFile.length()).use { outputStream -> - FileInputStream(apkFile).use { inputStream -> - inputStream.copyTo(outputStream) - } - session.fsync(outputStream) - } - - val intent = Intent(context, InstallResultReceiver::class.java).apply { - action = InstallResultReceiver.ACTION_INSTALL_COMPLETE - } - val pendingIntent = PendingIntent.getBroadcast( - context, - sessionId, - intent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE, - ) - - session.commit(pendingIntent.intentSender) - } - } -} diff --git a/sing-box/clients/android/app/src/github/java/io/nekohasekai/sfa/vendor/UpdateWorker.kt b/sing-box/clients/android/app/src/github/java/io/nekohasekai/sfa/vendor/UpdateWorker.kt deleted file mode 100644 index 7b1457312b..0000000000 --- a/sing-box/clients/android/app/src/github/java/io/nekohasekai/sfa/vendor/UpdateWorker.kt +++ /dev/null @@ -1,90 +0,0 @@ -package io.nekohasekai.sfa.vendor - -import android.content.Context -import android.util.Log -import androidx.work.BackoffPolicy -import androidx.work.Constraints -import androidx.work.CoroutineWorker -import androidx.work.ExistingPeriodicWorkPolicy -import androidx.work.NetworkType -import androidx.work.PeriodicWorkRequestBuilder -import androidx.work.WorkManager -import androidx.work.WorkerParameters -import io.nekohasekai.sfa.database.Settings -import io.nekohasekai.sfa.update.UpdateState -import io.nekohasekai.sfa.update.UpdateTrack -import java.util.concurrent.TimeUnit - -class UpdateWorker(private val appContext: Context, params: WorkerParameters) : CoroutineWorker(appContext, params) { - - companion object { - private const val WORK_NAME = "AutoUpdate" - private const val TAG = "UpdateWorker" - - fun schedule(context: Context) { - if (!Settings.autoUpdateEnabled) { - WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME) - Log.d(TAG, "Auto update disabled, cancelled scheduled work") - return - } - - val constraints = Constraints.Builder() - .setRequiredNetworkType(NetworkType.CONNECTED) - .setRequiresBatteryNotLow(true) - .build() - - val workRequest = PeriodicWorkRequestBuilder( - 24, - TimeUnit.HOURS, - ) - .setConstraints(constraints) - .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 1, TimeUnit.HOURS) - .build() - - WorkManager.getInstance(context).enqueueUniquePeriodicWork( - WORK_NAME, - ExistingPeriodicWorkPolicy.KEEP, - workRequest, - ) - Log.d(TAG, "Auto update scheduled") - } - } - - override suspend fun doWork(): Result { - if (!Settings.autoUpdateEnabled) { - Log.d(TAG, "Auto update disabled, skipping") - return Result.success() - } - - Log.d(TAG, "Checking for updates...") - - return try { - val track = UpdateTrack.fromString(Settings.updateTrack) - val updateInfo = GitHubUpdateChecker().use { it.checkUpdate(track) } - - if (updateInfo == null) { - Log.d(TAG, "No update available") - return Result.success() - } - - Log.d(TAG, "Update available: ${updateInfo.versionName}") - UpdateState.setUpdate(updateInfo) - - if (Settings.silentInstallEnabled && ApkInstaller.canSilentInstall()) { - Log.d(TAG, "Downloading update...") - val apkFile = ApkDownloader().use { it.download(updateInfo.downloadUrl) } - - Log.d(TAG, "Installing update...") - ApkInstaller.install(appContext, apkFile) - Log.d(TAG, "Update installed successfully") - } else { - Log.d(TAG, "Silent install not available, update will be shown on next app launch") - } - - Result.success() - } catch (e: Exception) { - Log.e(TAG, "Auto update failed", e) - Result.retry() - } - } -} diff --git a/sing-box/clients/android/app/src/main/AndroidManifest.xml b/sing-box/clients/android/app/src/main/AndroidManifest.xml index 5d9d8ffc72..04b6a734f6 100644 --- a/sing-box/clients/android/app/src/main/AndroidManifest.xml +++ b/sing-box/clients/android/app/src/main/AndroidManifest.xml @@ -21,10 +21,6 @@ - - - @@ -33,7 +29,6 @@ android:name=".Application" android:allowBackup="true" android:dataExtractionRules="@xml/data_extraction_rules" - android:description="@string/xposed_description" android:fullBackupContent="@xml/backup_rules" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" @@ -41,23 +36,30 @@ android:theme="@style/AppTheme" tools:targetApi="31"> + + + android:launchMode="singleTask"> + + + + + - - - - - @@ -91,24 +93,50 @@ - - - - - - - - - - - - - + + + + + + + + + + + + + + + + - - - - - - - - - + \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/aidl/io/github/libxposed/service/IXposedScopeCallback.aidl b/sing-box/clients/android/app/src/main/aidl/io/github/libxposed/service/IXposedScopeCallback.aidl deleted file mode 100644 index 6dcf980a12..0000000000 --- a/sing-box/clients/android/app/src/main/aidl/io/github/libxposed/service/IXposedScopeCallback.aidl +++ /dev/null @@ -1,9 +0,0 @@ -package io.github.libxposed.service; - -interface IXposedScopeCallback { - oneway void onScopeRequestPrompted(String packageName) = 1; - oneway void onScopeRequestApproved(String packageName) = 2; - oneway void onScopeRequestDenied(String packageName) = 3; - oneway void onScopeRequestTimeout(String packageName) = 4; - oneway void onScopeRequestFailed(String packageName, String message) = 5; -} diff --git a/sing-box/clients/android/app/src/main/aidl/io/github/libxposed/service/IXposedService.aidl b/sing-box/clients/android/app/src/main/aidl/io/github/libxposed/service/IXposedService.aidl deleted file mode 100644 index 58fcae80df..0000000000 --- a/sing-box/clients/android/app/src/main/aidl/io/github/libxposed/service/IXposedService.aidl +++ /dev/null @@ -1,36 +0,0 @@ -package io.github.libxposed.service; -import io.github.libxposed.service.IXposedScopeCallback; - -interface IXposedService { - const int API = 100; - - const int FRAMEWORK_PRIVILEGE_ROOT = 0; - const int FRAMEWORK_PRIVILEGE_CONTAINER = 1; - const int FRAMEWORK_PRIVILEGE_APP = 2; - const int FRAMEWORK_PRIVILEGE_EMBEDDED = 3; - - const String AUTHORITY_SUFFIX = ".XposedService"; - const String SEND_BINDER = "SendBinder"; - - // framework details - int getAPIVersion() = 1; - String getFrameworkName() = 2; - String getFrameworkVersion() = 3; - long getFrameworkVersionCode() = 4; - int getFrameworkPrivilege() = 5; - - // scope utilities - List getScope() = 10; - oneway void requestScope(String packageName, IXposedScopeCallback callback) = 11; - String removeScope(String packageName) = 12; - - // remote preference utilities - Bundle requestRemotePreferences(String group) = 20; - void updateRemotePreferences(String group, in Bundle diff) = 21; - void deleteRemotePreferences(String group) = 22; - - // remote file utilities - String[] listRemoteFiles() = 30; - ParcelFileDescriptor openRemoteFile(String name) = 31; - boolean deleteRemoteFile(String name) = 32; -} diff --git a/sing-box/clients/android/app/src/main/aidl/io/nekohasekai/sfa/bg/IRootService.aidl b/sing-box/clients/android/app/src/main/aidl/io/nekohasekai/sfa/bg/IRootService.aidl deleted file mode 100644 index fc58161157..0000000000 --- a/sing-box/clients/android/app/src/main/aidl/io/nekohasekai/sfa/bg/IRootService.aidl +++ /dev/null @@ -1,14 +0,0 @@ -package io.nekohasekai.sfa.bg; - -import android.os.ParcelFileDescriptor; -import io.nekohasekai.sfa.bg.ParceledListSlice; - -interface IRootService { - void destroy() = 16777114; // Destroy method defined by Shizuku server - - ParceledListSlice getInstalledPackages(int flags, int userId) = 1; - - void installPackage(in ParcelFileDescriptor apk, long size, int userId) = 2; - - String exportDebugInfo(String outputPath) = 3; -} diff --git a/sing-box/clients/android/app/src/main/aidl/io/nekohasekai/sfa/bg/IShizukuService.aidl b/sing-box/clients/android/app/src/main/aidl/io/nekohasekai/sfa/bg/IShizukuService.aidl deleted file mode 100644 index 8241f560dc..0000000000 --- a/sing-box/clients/android/app/src/main/aidl/io/nekohasekai/sfa/bg/IShizukuService.aidl +++ /dev/null @@ -1,12 +0,0 @@ -package io.nekohasekai.sfa.bg; - -import android.os.ParcelFileDescriptor; -import io.nekohasekai.sfa.bg.ParceledListSlice; - -interface IShizukuService { - void destroy() = 16777114; // Destroy method defined by Shizuku server - - ParceledListSlice getInstalledPackages(int flags, int userId) = 1; - - void installPackage(in ParcelFileDescriptor apk, long size, int userId) = 2; -} diff --git a/sing-box/clients/android/app/src/main/aidl/io/nekohasekai/sfa/bg/LogEntry.aidl b/sing-box/clients/android/app/src/main/aidl/io/nekohasekai/sfa/bg/LogEntry.aidl deleted file mode 100644 index 62ca37609b..0000000000 --- a/sing-box/clients/android/app/src/main/aidl/io/nekohasekai/sfa/bg/LogEntry.aidl +++ /dev/null @@ -1,3 +0,0 @@ -package io.nekohasekai.sfa.bg; - -parcelable LogEntry; diff --git a/sing-box/clients/android/app/src/main/aidl/io/nekohasekai/sfa/bg/PackageEntry.aidl b/sing-box/clients/android/app/src/main/aidl/io/nekohasekai/sfa/bg/PackageEntry.aidl deleted file mode 100644 index db569f7049..0000000000 --- a/sing-box/clients/android/app/src/main/aidl/io/nekohasekai/sfa/bg/PackageEntry.aidl +++ /dev/null @@ -1,3 +0,0 @@ -package io.nekohasekai.sfa.bg; - -parcelable PackageEntry; diff --git a/sing-box/clients/android/app/src/main/aidl/io/nekohasekai/sfa/bg/ParceledListSlice.aidl b/sing-box/clients/android/app/src/main/aidl/io/nekohasekai/sfa/bg/ParceledListSlice.aidl deleted file mode 100644 index 4eaec8b854..0000000000 --- a/sing-box/clients/android/app/src/main/aidl/io/nekohasekai/sfa/bg/ParceledListSlice.aidl +++ /dev/null @@ -1,3 +0,0 @@ -package io.nekohasekai.sfa.bg; - -parcelable ParceledListSlice; diff --git a/sing-box/clients/android/app/src/main/java/android/content/IIntentReceiver.java b/sing-box/clients/android/app/src/main/java/android/content/IIntentReceiver.java deleted file mode 100644 index b0461681b4..0000000000 --- a/sing-box/clients/android/app/src/main/java/android/content/IIntentReceiver.java +++ /dev/null @@ -1,15 +0,0 @@ -package android.content; - -import android.os.Bundle; -import android.os.IInterface; - -public interface IIntentReceiver extends IInterface { - void performReceive( - Intent intent, - int resultCode, - String data, - Bundle extras, - boolean ordered, - boolean sticky, - int sendingUser); -} diff --git a/sing-box/clients/android/app/src/main/java/android/content/IIntentSender.java b/sing-box/clients/android/app/src/main/java/android/content/IIntentSender.java deleted file mode 100644 index 51d705733b..0000000000 --- a/sing-box/clients/android/app/src/main/java/android/content/IIntentSender.java +++ /dev/null @@ -1,29 +0,0 @@ -package android.content; - -import android.os.Binder; -import android.os.Bundle; -import android.os.IBinder; -import android.os.IInterface; - -public interface IIntentSender extends IInterface { - - void send( - int code, - Intent intent, - String resolvedType, - IBinder whitelistToken, - IIntentReceiver finishedReceiver, - String requiredPermission, - Bundle options); - - abstract class Stub extends Binder implements IIntentSender { - public static IIntentSender asInterface(IBinder binder) { - throw new UnsupportedOperationException(); - } - - @Override - public IBinder asBinder() { - return this; - } - } -} diff --git a/sing-box/clients/android/app/src/main/java/android/content/pm/IPackageInstaller.java b/sing-box/clients/android/app/src/main/java/android/content/pm/IPackageInstaller.java deleted file mode 100644 index b04ba0d70e..0000000000 --- a/sing-box/clients/android/app/src/main/java/android/content/pm/IPackageInstaller.java +++ /dev/null @@ -1,26 +0,0 @@ -package android.content.pm; - -import android.os.Binder; -import android.os.IBinder; -import android.os.IInterface; -import android.os.RemoteException; - -public interface IPackageInstaller extends IInterface { - - int createSession( - PackageInstaller.SessionParams params, - String installerPackageName, - String installerAttributionTag, - int userId) - throws RemoteException; - - IPackageInstallerSession openSession(int sessionId) throws RemoteException; - - void abandonSession(int sessionId) throws RemoteException; - - abstract class Stub extends Binder implements IPackageInstaller { - public static IPackageInstaller asInterface(IBinder binder) { - throw new UnsupportedOperationException(); - } - } -} diff --git a/sing-box/clients/android/app/src/main/java/android/content/pm/IPackageInstallerSession.java b/sing-box/clients/android/app/src/main/java/android/content/pm/IPackageInstallerSession.java deleted file mode 100644 index 6f200af9dd..0000000000 --- a/sing-box/clients/android/app/src/main/java/android/content/pm/IPackageInstallerSession.java +++ /dev/null @@ -1,14 +0,0 @@ -package android.content.pm; - -import android.os.Binder; -import android.os.IBinder; -import android.os.IInterface; - -public interface IPackageInstallerSession extends IInterface { - - abstract class Stub extends Binder implements IPackageInstallerSession { - public static IPackageInstallerSession asInterface(IBinder binder) { - throw new UnsupportedOperationException(); - } - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/github/libxposed/service/RemotePreferences.java b/sing-box/clients/android/app/src/main/java/io/github/libxposed/service/RemotePreferences.java deleted file mode 100644 index c822080dbe..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/github/libxposed/service/RemotePreferences.java +++ /dev/null @@ -1,238 +0,0 @@ -package io.github.libxposed.service; - -import android.content.SharedPreferences; -import android.os.Bundle; -import android.os.Handler; -import android.os.Looper; -import android.os.RemoteException; -import android.util.Log; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.TreeMap; -import java.util.WeakHashMap; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.locks.Lock; -import java.util.concurrent.locks.ReentrantLock; - -@SuppressWarnings("unchecked") -public final class RemotePreferences implements SharedPreferences { - - private static final String TAG = "RemotePreferences"; - private static final Object CONTENT = new Object(); - private static final Handler HANDLER = new Handler(Looper.getMainLooper()); - - private final XposedService mService; - private final String mGroup; - private final Lock mLock = new ReentrantLock(); - private final Map mMap = new ConcurrentHashMap<>(); - private final Map mListeners = - Collections.synchronizedMap(new WeakHashMap<>()); - - private volatile boolean isDeleted = false; - - private RemotePreferences(XposedService service, String group) { - this.mService = service; - this.mGroup = group; - } - - @Nullable - static RemotePreferences newInstance(XposedService service, String group) throws RemoteException { - Bundle output = service.getRaw().requestRemotePreferences(group); - if (output == null) return null; - RemotePreferences prefs = new RemotePreferences(service, group); - if (output.containsKey("map")) { - prefs.mMap.putAll((Map) output.getSerializable("map")); - } - return prefs; - } - - void setDeleted() { - this.isDeleted = true; - } - - @Override - public Map getAll() { - return new TreeMap<>(mMap); - } - - @Nullable - @Override - public String getString(String key, @Nullable String defValue) { - return (String) mMap.getOrDefault(key, defValue); - } - - @Nullable - @Override - public Set getStringSet(String key, @Nullable Set defValues) { - return (Set) mMap.getOrDefault(key, defValues); - } - - @Override - public int getInt(String key, int defValue) { - Integer v = (Integer) mMap.getOrDefault(key, defValue); - assert v != null; - return v; - } - - @Override - public long getLong(String key, long defValue) { - Long v = (Long) mMap.getOrDefault(key, defValue); - assert v != null; - return v; - } - - @Override - public float getFloat(String key, float defValue) { - Float v = (Float) mMap.getOrDefault(key, defValue); - assert v != null; - return v; - } - - @Override - public boolean getBoolean(String key, boolean defValue) { - Boolean v = (Boolean) mMap.getOrDefault(key, defValue); - assert v != null; - return v; - } - - @Override - public boolean contains(String key) { - return mMap.containsKey(key); - } - - @Override - public void registerOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) { - mListeners.put(listener, CONTENT); - } - - @Override - public void unregisterOnSharedPreferenceChangeListener( - OnSharedPreferenceChangeListener listener) { - mListeners.remove(listener); - } - - @Override - public Editor edit() { - return new Editor(); - } - - public class Editor implements SharedPreferences.Editor { - - private final HashSet mDelete = new HashSet<>(); - private final HashMap mPut = new HashMap<>(); - - private void put(String key, @NonNull Object value) { - mDelete.remove(key); - mPut.put(key, value); - } - - @Override - public SharedPreferences.Editor putString(String key, @Nullable String value) { - if (value == null) remove(key); - else put(key, value); - return this; - } - - @Override - public SharedPreferences.Editor putStringSet(String key, @Nullable Set values) { - if (values == null) remove(key); - else put(key, values); - return this; - } - - @Override - public SharedPreferences.Editor putInt(String key, int value) { - put(key, value); - return this; - } - - @Override - public SharedPreferences.Editor putLong(String key, long value) { - put(key, value); - return this; - } - - @Override - public SharedPreferences.Editor putFloat(String key, float value) { - put(key, value); - return this; - } - - @Override - public SharedPreferences.Editor putBoolean(String key, boolean value) { - put(key, value); - return this; - } - - @Override - public SharedPreferences.Editor remove(String key) { - mDelete.add(key); - mPut.remove(key); - return this; - } - - @Override - public SharedPreferences.Editor clear() { - mDelete.clear(); - mPut.clear(); - return this; - } - - private void doUpdate(boolean throwing) { - mService.deletionLock.readLock().lock(); - try { - if (isDeleted) { - throw new IllegalStateException("This preferences group has been deleted"); - } - mDelete.forEach(mMap::remove); - mMap.putAll(mPut); - List changes = new ArrayList<>(mDelete.size() + mMap.size()); - changes.addAll(mDelete); - changes.addAll(mMap.keySet()); - for (String key : changes) { - mListeners - .keySet() - .forEach(listener -> listener.onSharedPreferenceChanged(RemotePreferences.this, key)); - } - - Bundle bundle = new Bundle(); - bundle.putSerializable("delete", mDelete); - bundle.putSerializable("put", mPut); - try { - mService.getRaw().updateRemotePreferences(mGroup, bundle); - } catch (RemoteException e) { - if (throwing) { - throw new RuntimeException(e); - } else { - Log.e(TAG, "Failed to update remote preferences", e); - } - } - } finally { - mService.deletionLock.readLock().unlock(); - } - } - - @Override - public boolean commit() { - if (!mLock.tryLock()) return false; - try { - doUpdate(true); - return true; - } finally { - mLock.unlock(); - } - } - - @Override - public void apply() { - HANDLER.post(() -> doUpdate(false)); - } - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/github/libxposed/service/XposedProvider.java b/sing-box/clients/android/app/src/main/java/io/github/libxposed/service/XposedProvider.java deleted file mode 100644 index f63e929353..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/github/libxposed/service/XposedProvider.java +++ /dev/null @@ -1,73 +0,0 @@ -package io.github.libxposed.service; - -import android.content.ContentProvider; -import android.content.ContentValues; -import android.database.Cursor; -import android.net.Uri; -import android.os.Bundle; -import android.os.IBinder; -import android.util.Log; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -public final class XposedProvider extends ContentProvider { - - private static final String TAG = "XposedProvider"; - - @Override - public boolean onCreate() { - return false; - } - - @Nullable - @Override - public Cursor query( - @NonNull Uri uri, - @Nullable String[] projection, - @Nullable String selection, - @Nullable String[] selectionArgs, - @Nullable String sortOrder) { - return null; - } - - @Nullable - @Override - public String getType(@NonNull Uri uri) { - return null; - } - - @Nullable - @Override - public Uri insert(@NonNull Uri uri, @Nullable ContentValues values) { - return null; - } - - @Override - public int delete( - @NonNull Uri uri, @Nullable String selection, @Nullable String[] selectionArgs) { - return 0; - } - - @Override - public int update( - @NonNull Uri uri, - @Nullable ContentValues values, - @Nullable String selection, - @Nullable String[] selectionArgs) { - return 0; - } - - @Nullable - @Override - public Bundle call(@NonNull String method, @Nullable String arg, @Nullable Bundle extras) { - if (method.equals(IXposedService.SEND_BINDER) && extras != null) { - IBinder binder = extras.getBinder("binder"); - if (binder != null) { - Log.d(TAG, "binder received: " + binder); - XposedServiceHelper.onBinderReceived(binder); - } - return new Bundle(); - } - return null; - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/github/libxposed/service/XposedService.java b/sing-box/clients/android/app/src/main/java/io/github/libxposed/service/XposedService.java deleted file mode 100644 index 12cc98bfab..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/github/libxposed/service/XposedService.java +++ /dev/null @@ -1,372 +0,0 @@ -package io.github.libxposed.service; - -import android.content.SharedPreferences; -import android.os.ParcelFileDescriptor; -import android.os.RemoteException; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.WeakHashMap; -import java.util.concurrent.locks.ReentrantReadWriteLock; - -@SuppressWarnings("unused") -public final class XposedService { - - public static final class ServiceException extends RuntimeException { - ServiceException(String message) { - super(message); - } - - ServiceException(RemoteException e) { - super("Xposed service error", e); - } - } - - private static final Map scopeCallbacks = - new WeakHashMap<>(); - - /** Callback interface for module scope request. */ - public interface OnScopeEventListener { - /** - * Callback when the request notification / window prompted. - * - * @param packageName Package name of requested app - */ - default void onScopeRequestPrompted(String packageName) {} - - /** - * Callback when the request is approved. - * - * @param packageName Package name of requested app - */ - default void onScopeRequestApproved(String packageName) {} - - /** - * Callback when the request is denied. - * - * @param packageName Package name of requested app - */ - default void onScopeRequestDenied(String packageName) {} - - /** - * Callback when the request is timeout or revoked. - * - * @param packageName Package name of requested app - */ - default void onScopeRequestTimeout(String packageName) {} - - /** - * Callback when the request is failed. - * - * @param packageName Package name of requested app - * @param message Error message - */ - default void onScopeRequestFailed(String packageName, String message) {} - - private IXposedScopeCallback asInterface() { - return scopeCallbacks.computeIfAbsent( - this, - (listener) -> - new IXposedScopeCallback.Stub() { - @Override - public void onScopeRequestPrompted(String packageName) { - listener.onScopeRequestPrompted(packageName); - } - - @Override - public void onScopeRequestApproved(String packageName) { - listener.onScopeRequestApproved(packageName); - } - - @Override - public void onScopeRequestDenied(String packageName) { - listener.onScopeRequestDenied(packageName); - } - - @Override - public void onScopeRequestTimeout(String packageName) { - listener.onScopeRequestTimeout(packageName); - } - - @Override - public void onScopeRequestFailed(String packageName, String message) { - listener.onScopeRequestFailed(packageName, message); - } - }); - } - } - - public enum Privilege { - /** Unknown privilege value. */ - FRAMEWORK_PRIVILEGE_UNKNOWN, - - /** The framework is running as root. */ - FRAMEWORK_PRIVILEGE_ROOT, - - /** The framework is running in a container with a fake system_server. */ - FRAMEWORK_PRIVILEGE_CONTAINER, - - /** The framework is running as a different app, which may have at most shell permission. */ - FRAMEWORK_PRIVILEGE_APP, - - /** - * The framework is embedded in the hooked app, which means {@link #getRemotePreferences} will - * be null and remote file is unsupported. - */ - FRAMEWORK_PRIVILEGE_EMBEDDED - } - - private final IXposedService mService; - private final Map mRemotePrefs = new HashMap<>(); - - final ReentrantReadWriteLock deletionLock = new ReentrantReadWriteLock(); - - XposedService(IXposedService service) { - mService = service; - } - - IXposedService getRaw() { - return mService; - } - - /** - * Get the Xposed API version of current implementation. - * - * @return API version - * @throws ServiceException If the service is dead or an error occurred - */ - public int getAPIVersion() { - try { - return mService.getAPIVersion(); - } catch (RemoteException e) { - throw new ServiceException(e); - } - } - - /** - * Get the Xposed framework name of current implementation. - * - * @return Framework name - * @throws ServiceException If the service is dead or an error occurred - */ - @NonNull - public String getFrameworkName() { - try { - return mService.getFrameworkName(); - } catch (RemoteException e) { - throw new ServiceException(e); - } - } - - /** - * Get the Xposed framework version of current implementation. - * - * @return Framework version - * @throws ServiceException If the service is dead or an error occurred - */ - @NonNull - public String getFrameworkVersion() { - try { - return mService.getFrameworkVersion(); - } catch (RemoteException e) { - throw new ServiceException(e); - } - } - - /** - * Get the Xposed framework version code of current implementation. - * - * @return Framework version code - * @throws ServiceException If the service is dead or an error occurred - */ - public long getFrameworkVersionCode() { - try { - return mService.getFrameworkVersionCode(); - } catch (RemoteException e) { - throw new ServiceException(e); - } - } - - /** - * Get the Xposed framework privilege of current implementation. - * - * @return Framework privilege - * @throws ServiceException If the service is dead or an error occurred - */ - @NonNull - public Privilege getFrameworkPrivilege() { - try { - int value = mService.getFrameworkPrivilege(); - return (value >= 0 && value <= 3) - ? Privilege.values()[value + 1] - : Privilege.FRAMEWORK_PRIVILEGE_UNKNOWN; - } catch (RemoteException e) { - throw new ServiceException(e); - } - } - - /** - * Get the application scope of current module. - * - * @return Module scope - * @throws ServiceException If the service is dead or an error occurred - */ - @NonNull - public List getScope() { - try { - return mService.getScope(); - } catch (RemoteException e) { - throw new ServiceException(e); - } - } - - /** - * Request to add a new app to the module scope. - * - * @param packageName Package name of the app to be added - * @param callback Callback to be invoked when the request is completed or error occurred - * @throws ServiceException If the service is dead or an error occurred - */ - public void requestScope(@NonNull String packageName, @NonNull OnScopeEventListener callback) { - try { - mService.requestScope(packageName, callback.asInterface()); - } catch (RemoteException e) { - throw new ServiceException(e); - } - } - - /** - * Remove an app from the module scope. - * - * @param packageName Package name of the app to be added - * @return null if successful, or non-null with error message - * @throws ServiceException If the service is dead or an error occurred - */ - @Nullable - public String removeScope(@NonNull String packageName) { - try { - return mService.removeScope(packageName); - } catch (RemoteException e) { - throw new ServiceException(e); - } - } - - /** - * Get remote preferences from Xposed framework. If the group does not exist, it will be created. - * - * @param group Group name - * @return The preferences - * @throws ServiceException If the service is dead or an error occurred - * @throws UnsupportedOperationException If the framework is embedded - */ - @NonNull - public SharedPreferences getRemotePreferences(@NonNull String group) { - return mRemotePrefs.computeIfAbsent( - group, - k -> { - try { - RemotePreferences instance = RemotePreferences.newInstance(this, k); - if (instance == null) { - throw new ServiceException("Framework returns null"); - } - return instance; - } catch (RemoteException e) { - if (e.getCause() instanceof UnsupportedOperationException cause) { - throw cause; - } - throw new ServiceException(e); - } - }); - } - - /** - * Delete a group of remote preferences. - * - * @param group Group name - * @throws ServiceException If the service is dead or an error occurred - * @throws UnsupportedOperationException If the framework is embedded - */ - public void deleteRemotePreferences(@NonNull String group) { - deletionLock.writeLock().lock(); - try { - mService.deleteRemotePreferences(group); - mRemotePrefs.computeIfPresent( - group, - (k, v) -> { - v.setDeleted(); - return null; - }); - } catch (RemoteException e) { - if (e.getCause() instanceof UnsupportedOperationException cause) { - throw cause; - } - throw new ServiceException(e); - } finally { - deletionLock.writeLock().unlock(); - } - } - - /** - * List all files in the module's shared data directory. - * - * @return The file list - * @throws ServiceException If the service is dead or an error occurred - * @throws UnsupportedOperationException If the framework is embedded - */ - @NonNull - public String[] listRemoteFiles() { - try { - String[] files = mService.listRemoteFiles(); - if (files == null) throw new ServiceException("Framework returns null"); - return files; - } catch (RemoteException e) { - if (e.getCause() instanceof UnsupportedOperationException cause) { - throw cause; - } - throw new ServiceException(e); - } - } - - /** - * Open a file in the module's shared data directory. The file will be created if not exists. - * - * @param name File name, must not contain path separators and . or .. - * @return The file descriptor - * @throws ServiceException If the service is dead or an error occurred - * @throws UnsupportedOperationException If the framework is embedded - */ - @NonNull - public ParcelFileDescriptor openRemoteFile(@NonNull String name) { - try { - ParcelFileDescriptor file = mService.openRemoteFile(name); - if (file == null) throw new ServiceException("Framework returns null"); - return file; - } catch (RemoteException e) { - if (e.getCause() instanceof UnsupportedOperationException cause) { - throw cause; - } - throw new ServiceException(e); - } - } - - /** - * Delete a file in the module's shared data directory. - * - * @param name File name, must not contain path separators and . or .. - * @return true if successful, false if the file does not exist - * @throws ServiceException If the service is dead or an error occurred - * @throws UnsupportedOperationException If the framework is embedded - */ - public boolean deleteRemoteFile(@NonNull String name) { - try { - return mService.deleteRemoteFile(name); - } catch (RemoteException e) { - if (e.getCause() instanceof UnsupportedOperationException cause) { - throw cause; - } - throw new ServiceException(e); - } - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/github/libxposed/service/XposedServiceHelper.java b/sing-box/clients/android/app/src/main/java/io/github/libxposed/service/XposedServiceHelper.java deleted file mode 100644 index 0936ad129d..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/github/libxposed/service/XposedServiceHelper.java +++ /dev/null @@ -1,72 +0,0 @@ -package io.github.libxposed.service; - -import android.os.IBinder; -import android.util.Log; -import androidx.annotation.NonNull; -import java.util.HashSet; -import java.util.Iterator; -import java.util.Set; - -@SuppressWarnings("unused") -public final class XposedServiceHelper { - - /** Callback interface for Xposed service. */ - public interface OnServiceListener { - /** - * Callback when the service is connected.
- * This method could be called multiple times if multiple Xposed frameworks exist. - * - * @param service Service instance - */ - void onServiceBind(@NonNull XposedService service); - - /** Callback when the service is dead. */ - void onServiceDied(@NonNull XposedService service); - } - - private static final String TAG = "XposedServiceHelper"; - private static final Set mCache = new HashSet<>(); - private static OnServiceListener mListener = null; - - static void onBinderReceived(IBinder binder) { - if (binder == null) return; - synchronized (mCache) { - try { - XposedService service = new XposedService(IXposedService.Stub.asInterface(binder)); - if (mListener == null) { - mCache.add(service); - } else { - binder.linkToDeath(() -> mListener.onServiceDied(service), 0); - mListener.onServiceBind(service); - } - } catch (Throwable t) { - Log.e(TAG, "onBinderReceived", t); - } - } - } - - /** - * Register a ServiceListener to receive service binders from Xposed frameworks.
- * This method should only be called once. - * - * @param listener Listener to register - */ - public static void registerListener(OnServiceListener listener) { - synchronized (mCache) { - mListener = listener; - if (!mCache.isEmpty()) { - for (Iterator it = mCache.iterator(); it.hasNext(); ) { - try { - XposedService service = it.next(); - service.getRaw().asBinder().linkToDeath(() -> mListener.onServiceDied(service), 0); - mListener.onServiceBind(service); - } catch (Throwable t) { - Log.e(TAG, "registerListener", t); - it.remove(); - } - } - mCache.clear(); - } - } - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/Application.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/Application.kt index 02b2467b9a..8c3144bb5f 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/Application.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/Application.kt @@ -16,11 +16,6 @@ import io.nekohasekai.libbox.SetupOptions import io.nekohasekai.sfa.bg.AppChangeReceiver import io.nekohasekai.sfa.bg.UpdateProfileWork import io.nekohasekai.sfa.constant.Bugs -import io.nekohasekai.sfa.utils.AppLifecycleObserver -import io.nekohasekai.sfa.utils.HookModuleUpdateNotifier -import io.nekohasekai.sfa.utils.HookStatusClient -import io.nekohasekai.sfa.utils.PrivilegeSettingsClient -import io.nekohasekai.sfa.vendor.Vendor import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch @@ -29,6 +24,7 @@ import java.util.Locale import io.nekohasekai.sfa.Application as BoxApplication class Application : Application() { + override fun attachBaseContext(base: Context?) { super.attachBaseContext(base) application = this @@ -36,30 +32,21 @@ class Application : Application() { override fun onCreate() { super.onCreate() - AppLifecycleObserver.register(this) -// Seq.setContext(this) + Seq.setContext(this) Libbox.setLocale(Locale.getDefault().toLanguageTag().replace("-", "_")) - HookStatusClient.register(this) - PrivilegeSettingsClient.register(this) @Suppress("OPT_IN_USAGE") GlobalScope.launch(Dispatchers.IO) { initialize() UpdateProfileWork.reconfigureUpdater() - HookModuleUpdateNotifier.sync(this@Application) } - if (Vendor.isPerAppProxyAvailable()) { - registerReceiver( - AppChangeReceiver(), - IntentFilter().apply { - addAction(Intent.ACTION_PACKAGE_ADDED) - addAction(Intent.ACTION_PACKAGE_REPLACED) - addDataScheme("package") - }, - ) - } + registerReceiver(AppChangeReceiver(), IntentFilter().apply { + addAction(Intent.ACTION_PACKAGE_ADDED) + addDataScheme("package") + }) + } private fun initialize() { @@ -69,16 +56,12 @@ class Application : Application() { workingDir.mkdirs() val tempDir = cacheDir tempDir.mkdirs() - Libbox.setup( - SetupOptions().also { - it.basePath = baseDir.path - it.workingPath = workingDir.path - it.tempPath = tempDir.path - it.fixAndroidStack = Bugs.fixAndroidStack - it.logMaxLines = 3000 - it.debug = BuildConfig.DEBUG - }, - ) + Libbox.setup(SetupOptions().also { + it.basePath = baseDir.path + it.workingPath = workingDir.path + it.tempPath = tempDir.path + it.fixAndroidStack = Bugs.fixAndroidStack + }) Libbox.redirectStderr(File(workingDir, "stderr.log").path) } @@ -92,4 +75,5 @@ class Application : Application() { val wifiManager by lazy { application.getSystemService()!! } val clipboard by lazy { application.getSystemService()!! } } -} + +} \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/WorkingDirectoryProvider.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/WorkingDirectoryProvider.kt deleted file mode 100644 index b293dec27b..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/WorkingDirectoryProvider.kt +++ /dev/null @@ -1,174 +0,0 @@ -package io.nekohasekai.sfa - -import android.database.Cursor -import android.database.MatrixCursor -import android.os.CancellationSignal -import android.os.ParcelFileDescriptor -import android.provider.DocumentsContract -import android.provider.DocumentsProvider -import android.webkit.MimeTypeMap -import java.io.File - -class WorkingDirectoryProvider : DocumentsProvider() { - - companion object { - private const val ROOT_ID = "working_directory" - private const val ROOT_DOC_ID = "root" - - private val DEFAULT_ROOT_PROJECTION = arrayOf( - DocumentsContract.Root.COLUMN_ROOT_ID, - DocumentsContract.Root.COLUMN_FLAGS, - DocumentsContract.Root.COLUMN_ICON, - DocumentsContract.Root.COLUMN_TITLE, - DocumentsContract.Root.COLUMN_SUMMARY, - DocumentsContract.Root.COLUMN_DOCUMENT_ID, - ) - - private val DEFAULT_DOCUMENT_PROJECTION = arrayOf( - DocumentsContract.Document.COLUMN_DOCUMENT_ID, - DocumentsContract.Document.COLUMN_MIME_TYPE, - DocumentsContract.Document.COLUMN_DISPLAY_NAME, - DocumentsContract.Document.COLUMN_LAST_MODIFIED, - DocumentsContract.Document.COLUMN_FLAGS, - DocumentsContract.Document.COLUMN_SIZE, - ) - } - - private val baseDir: File - get() = context!!.getExternalFilesDir(null)!! - - override fun onCreate(): Boolean = true - - override fun queryRoots(projection: Array?): Cursor { - val result = MatrixCursor(projection ?: DEFAULT_ROOT_PROJECTION) - result.newRow().apply { - add(DocumentsContract.Root.COLUMN_ROOT_ID, ROOT_ID) - add( - DocumentsContract.Root.COLUMN_FLAGS, - DocumentsContract.Root.FLAG_SUPPORTS_CREATE or - DocumentsContract.Root.FLAG_SUPPORTS_IS_CHILD, - ) - add(DocumentsContract.Root.COLUMN_ICON, R.mipmap.ic_launcher) - add(DocumentsContract.Root.COLUMN_TITLE, context!!.getString(R.string.app_name)) - add(DocumentsContract.Root.COLUMN_SUMMARY, context!!.getString(R.string.working_directory)) - add(DocumentsContract.Root.COLUMN_DOCUMENT_ID, ROOT_DOC_ID) - } - return result - } - - override fun queryDocument(documentId: String, projection: Array?): Cursor { - val result = MatrixCursor(projection ?: DEFAULT_DOCUMENT_PROJECTION) - val file = getFileForDocId(documentId) - includeFile(result, documentId, file) - return result - } - - override fun queryChildDocuments(parentDocumentId: String, projection: Array?, sortOrder: String?): Cursor { - val result = MatrixCursor(projection ?: DEFAULT_DOCUMENT_PROJECTION) - val parent = getFileForDocId(parentDocumentId) - parent.listFiles()?.forEach { file -> - includeFile(result, getDocIdForFile(file), file) - } - return result - } - - override fun openDocument(documentId: String, mode: String, signal: CancellationSignal?): ParcelFileDescriptor { - val file = getFileForDocId(documentId) - val accessMode = ParcelFileDescriptor.parseMode(mode) - return ParcelFileDescriptor.open(file, accessMode) - } - - override fun createDocument(parentDocumentId: String, mimeType: String, displayName: String): String { - val parent = getFileForDocId(parentDocumentId) - val file = File(parent, displayName) - - if (mimeType == DocumentsContract.Document.MIME_TYPE_DIR) { - file.mkdirs() - } else { - file.createNewFile() - } - - return getDocIdForFile(file) - } - - override fun deleteDocument(documentId: String) { - val file = getFileForDocId(documentId) - if (file.isDirectory) { - file.deleteRecursively() - } else { - file.delete() - } - } - - override fun renameDocument(documentId: String, displayName: String): String { - val file = getFileForDocId(documentId) - val newFile = File(file.parentFile, displayName) - file.renameTo(newFile) - return getDocIdForFile(newFile) - } - - override fun isChildDocument(parentDocumentId: String, documentId: String): Boolean { - val parent = getFileForDocId(parentDocumentId) - val child = getFileForDocId(documentId) - return child.absolutePath.startsWith(parent.absolutePath) - } - - private fun getFileForDocId(documentId: String): File { - if (documentId == ROOT_DOC_ID) { - return baseDir - } - return File(baseDir, documentId) - } - - private fun getDocIdForFile(file: File): String { - val path = file.absolutePath - val basePath = baseDir.absolutePath - - return if (path == basePath) { - ROOT_DOC_ID - } else { - path.removePrefix("$basePath/") - } - } - - private fun includeFile(result: MatrixCursor, documentId: String, file: File) { - var flags = 0 - - if (file.isDirectory) { - flags = flags or DocumentsContract.Document.FLAG_DIR_SUPPORTS_CREATE - } else { - flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_WRITE - } - - if (file.parentFile?.canWrite() == true) { - flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_DELETE - flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_RENAME - } - - val mimeType = if (file.isDirectory) { - DocumentsContract.Document.MIME_TYPE_DIR - } else { - getMimeType(file) - } - - result.newRow().apply { - add(DocumentsContract.Document.COLUMN_DOCUMENT_ID, documentId) - add(DocumentsContract.Document.COLUMN_MIME_TYPE, mimeType) - add(DocumentsContract.Document.COLUMN_DISPLAY_NAME, file.name) - add(DocumentsContract.Document.COLUMN_LAST_MODIFIED, file.lastModified()) - add(DocumentsContract.Document.COLUMN_FLAGS, flags) - add(DocumentsContract.Document.COLUMN_SIZE, file.length()) - } - } - - private fun getMimeType(file: File): String { - val extension = file.extension.lowercase() - if (extension.isNotEmpty()) { - val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) - if (mimeType != null) { - return mimeType - } - } - return "application/octet-stream" - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/AppChangeReceiver.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/AppChangeReceiver.kt index 8b563017cf..3d251b4fd8 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/AppChangeReceiver.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/AppChangeReceiver.kt @@ -4,49 +4,48 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.util.Log -import android.widget.Toast -import io.nekohasekai.sfa.R -import io.nekohasekai.sfa.compose.screen.profileoverride.PerAppProxyScanner import io.nekohasekai.sfa.database.Settings -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext +import io.nekohasekai.sfa.ui.profileoverride.PerAppProxyActivity class AppChangeReceiver : BroadcastReceiver() { + companion object { private const val TAG = "AppChangeReceiver" } override fun onReceive(context: Context, intent: Intent) { Log.d(TAG, "onReceive: ${intent.action}") + checkUpdate(intent) + } + + private fun checkUpdate(intent: Intent) { if (!Settings.perAppProxyEnabled) { Log.d(TAG, "per app proxy disabled") return } - if (!Settings.perAppProxyManagedMode) { - Log.d(TAG, "managed mode disabled") + if (intent.getBooleanExtra(Intent.EXTRA_REPLACING, false)) { + Log.d(TAG, "skip app update") return } - val pendingResult = goAsync() - CoroutineScope(Dispatchers.IO).launch { - try { - rescanAllApps() - } catch (e: Exception) { - Log.e(TAG, "Failed to rescan apps", e) - withContext(Dispatchers.Main) { - Toast.makeText(context, R.string.error_title, Toast.LENGTH_SHORT).show() - } - } finally { - pendingResult.finish() - } + val perAppProxyUpdateOnChange = Settings.perAppProxyUpdateOnChange + if (perAppProxyUpdateOnChange == Settings.PER_APP_PROXY_DISABLED) { + Log.d(TAG, "update on change disabled") + return + } + val packageName = intent.dataString?.substringAfter("package:") + if (packageName.isNullOrBlank()) { + Log.d(TAG, "missing package name in intent") + return + } + val isChinaApp = PerAppProxyActivity.scanChinaPackage(packageName) + Log.d(TAG, "scan china app result for $packageName: $isChinaApp") + if ((perAppProxyUpdateOnChange == Settings.PER_APP_PROXY_INCLUDE) xor !isChinaApp) { + Settings.perAppProxyList += packageName + Log.d(TAG, "added to list") + } else { + Settings.perAppProxyList -= packageName + Log.d(TAG, "removed from list") } } - private suspend fun rescanAllApps() { - Log.d(TAG, "rescanning all apps") - val chinaApps = PerAppProxyScanner.scanAllChinaApps() - Settings.perAppProxyManagedList = chinaApps - Log.d(TAG, "rescan complete, found ${chinaApps.size} china apps") - } -} +} \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/BootReceiver.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/BootReceiver.kt index 013406c997..f4dc8f024d 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/BootReceiver.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/BootReceiver.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext class BootReceiver : BroadcastReceiver() { + @OptIn(DelicateCoroutinesApi::class) override fun onReceive(context: Context, intent: Intent) { when (intent.action) { @@ -27,4 +28,5 @@ class BootReceiver : BroadcastReceiver() { } } } + } diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/BoxService.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/BoxService.kt index 1761e650f7..1206ea5230 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/BoxService.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/BoxService.kt @@ -13,29 +13,27 @@ import android.os.Build import android.os.IBinder import android.os.ParcelFileDescriptor import android.os.PowerManager -import android.util.Log import androidx.annotation.RequiresApi import androidx.core.app.NotificationCompat import androidx.core.content.ContextCompat import androidx.lifecycle.MutableLiveData import go.Seq +import io.nekohasekai.libbox.BoxService import io.nekohasekai.libbox.CommandServer import io.nekohasekai.libbox.CommandServerHandler import io.nekohasekai.libbox.Libbox import io.nekohasekai.libbox.Notification -import io.nekohasekai.libbox.OverrideOptions import io.nekohasekai.libbox.PlatformInterface import io.nekohasekai.libbox.SystemProxyStatus import io.nekohasekai.sfa.Application import io.nekohasekai.sfa.R -import io.nekohasekai.sfa.compose.MainActivity import io.nekohasekai.sfa.constant.Action import io.nekohasekai.sfa.constant.Alert import io.nekohasekai.sfa.constant.Status import io.nekohasekai.sfa.database.ProfileManager import io.nekohasekai.sfa.database.Settings import io.nekohasekai.sfa.ktx.hasPermission -import io.nekohasekai.sfa.vendor.Vendor +import io.nekohasekai.sfa.ui.MainActivity import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope @@ -44,26 +42,26 @@ import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import java.io.File -class BoxService(private val service: Service, private val platformInterface: PlatformInterface) : CommandServerHandler { +class BoxService( + private val service: Service, private val platformInterface: PlatformInterface +) : CommandServerHandler { + companion object { - private const val PROFILE_UPDATE_INTERVAL = 15L * 60 * 1000 // 15 minutes in milliseconds - private const val TAG = "BoxService" fun start() { - val intent = - runBlocking { - withContext(Dispatchers.IO) { - Intent(Application.application, Settings.serviceClass()) - } + val intent = runBlocking { + withContext(Dispatchers.IO) { + Intent(Application.application, Settings.serviceClass()) } + } ContextCompat.startForegroundService(Application.application, intent) } fun stop() { Application.application.sendBroadcast( Intent(Action.SERVICE_CLOSE).setPackage( - Application.application.packageName, - ), + Application.application.packageName + ) ) } } @@ -73,34 +71,33 @@ class BoxService(private val service: Service, private val platformInterface: Pl private val status = MutableLiveData(Status.Stopped) private val binder = ServiceBinder(status) private val notification = ServiceNotification(status, service) - private lateinit var commandServer: CommandServer - + private var boxService: BoxService? = null + private var commandServer: CommandServer? = null private var receiverRegistered = false - private val receiver = - object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - when (intent.action) { - Action.SERVICE_CLOSE -> { - stopService() - } + private val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + when (intent.action) { + Action.SERVICE_CLOSE -> { + stopService() + } - PowerManager.ACTION_DEVICE_IDLE_MODE_CHANGED -> { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - serviceUpdateIdleMode() - } + + PowerManager.ACTION_DEVICE_IDLE_MODE_CHANGED -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + serviceUpdateIdleMode() } } } } + } private fun startCommandServer() { - val commandServer = CommandServer(this, platformInterface) + val commandServer = CommandServer(this, 300) commandServer.start() this.commandServer = commandServer } private var lastProfileName = "" - private suspend fun startService() { try { withContext(Dispatchers.Main) { @@ -131,43 +128,32 @@ class BoxService(private val service: Service, private val platformInterface: Pl } DefaultNetworkMonitor.start() + Libbox.setMemoryLimit(!Settings.disableMemoryLimit) - try { - commandServer.startOrReloadService( - content, - OverrideOptions().apply { - autoRedirect = Settings.autoRedirect - if (Vendor.isPerAppProxyAvailable() && Settings.perAppProxyEnabled) { - val appList = Settings.getEffectivePerAppProxyList() - if (Settings.getEffectivePerAppProxyMode() == Settings.PER_APP_PROXY_INCLUDE) { - includePackage = - PlatformInterfaceWrapper.StringArray((appList + Application.application.packageName).iterator()) - } else { - excludePackage = - PlatformInterfaceWrapper.StringArray((appList - Application.application.packageName).iterator()) - } - } - }, - ) + val newService = try { + Libbox.newService(content, platformInterface) } catch (e: Exception) { stopAndAlert(Alert.CreateService, e.message) return } - if (commandServer.needWIFIState()) { - val wifiPermission = - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { - android.Manifest.permission.ACCESS_FINE_LOCATION - } else { - android.Manifest.permission.ACCESS_BACKGROUND_LOCATION - } + newService.start() + + if (newService.needWIFIState()) { + val wifiPermission = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + android.Manifest.permission.ACCESS_FINE_LOCATION + } else { + android.Manifest.permission.ACCESS_BACKGROUND_LOCATION + } if (!service.hasPermission(wifiPermission)) { - closeService() + newService.close() stopAndAlert(Alert.RequestLocationPermission) return } } + boxService = newService + commandServer?.setService(boxService) status.postValue(Status.Started) withContext(Dispatchers.Main) { notification.show(lastProfileName, R.string.status_started) @@ -179,7 +165,7 @@ class BoxService(private val service: Service, private val platformInterface: Pl } } - override fun serviceStop() { + override fun serviceReload() { notification.close() status.postValue(Status.Starting) val pfd = fileDescriptor @@ -187,70 +173,27 @@ class BoxService(private val service: Service, private val platformInterface: Pl pfd.close() fileDescriptor = null } - closeService() - } - - override fun serviceReload() { - runBlocking { - serviceReload0() - } - } - - suspend fun serviceReload0() { - val selectedProfileId = Settings.selectedProfile - if (selectedProfileId == -1L) { - stopAndAlert(Alert.EmptyConfiguration) - return - } - - val profile = ProfileManager.get(selectedProfileId) - if (profile == null) { - stopAndAlert(Alert.EmptyConfiguration) - return - } - - val content = File(profile.typed.path).readText() - if (content.isBlank()) { - stopAndAlert(Alert.EmptyConfiguration) - return - } - lastProfileName = profile.name - try { - commandServer.startOrReloadService( - content, - OverrideOptions().apply { - autoRedirect = Settings.autoRedirect - if (Vendor.isPerAppProxyAvailable() && Settings.perAppProxyEnabled) { - val appList = Settings.getEffectivePerAppProxyList() - if (Settings.getEffectivePerAppProxyMode() == Settings.PER_APP_PROXY_INCLUDE) { - includePackage = PlatformInterfaceWrapper.StringArray((appList + Application.application.packageName).iterator()) - } else { - excludePackage = PlatformInterfaceWrapper.StringArray((appList - Application.application.packageName).iterator()) - } - } - }, - ) - } catch (e: Exception) { - stopAndAlert(Alert.CreateService, e.message) - return - } - - if (commandServer.needWIFIState()) { - val wifiPermission = - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { - android.Manifest.permission.ACCESS_FINE_LOCATION - } else { - android.Manifest.permission.ACCESS_BACKGROUND_LOCATION - } - if (!service.hasPermission(wifiPermission)) { - closeService() - stopAndAlert(Alert.RequestLocationPermission) - return + boxService?.apply { + runCatching { + close() + }.onFailure { + writeLog("service: error when closing: $it") } + Seq.destroyRef(refnum) + } + commandServer?.setService(null) + commandServer?.resetLog() + boxService = null + runBlocking { + startService() } } - override fun getSystemProxyStatus(): SystemProxyStatus? { + override fun postServiceClose() { + // Not used on Android + } + + override fun getSystemProxyStatus(): SystemProxyStatus { val status = SystemProxyStatus() if (service is VPNService) { status.available = service.systemProxyAvailable @@ -266,9 +209,9 @@ class BoxService(private val service: Service, private val platformInterface: Pl @RequiresApi(Build.VERSION_CODES.M) private fun serviceUpdateIdleMode() { if (Application.powerManager.isDeviceIdleMode) { - commandServer.pause() + boxService?.pause() } else { - commandServer.wake() + boxService?.wake() } } @@ -287,12 +230,23 @@ class BoxService(private val service: Service, private val platformInterface: Pl pfd.close() fileDescriptor = null } - DefaultNetworkMonitor.stop() - closeService() - commandServer.apply { - close() -// Seq.destroyRef(refnum) + boxService?.apply { + runCatching { + close() + }.onFailure { + writeLog("service: error when closing: $it") + } + Seq.destroyRef(refnum) } + commandServer?.setService(null) + boxService = null + DefaultNetworkMonitor.stop() + + commandServer?.apply { + close() + Seq.destroyRef(refnum) + } + commandServer = null Settings.startedByUser = false withContext(Dispatchers.Main) { status.value = Status.Stopped @@ -301,14 +255,6 @@ class BoxService(private val service: Service, private val platformInterface: Pl } } - private fun closeService() { - runCatching { - commandServer.closeService() - }.onFailure { - commandServer.setError("android: close service: ${it.message}") - } - } - private suspend fun stopAndAlert(type: Alert, message: String? = null) { Settings.startedByUser = false withContext(Dispatchers.Main) { @@ -331,17 +277,12 @@ class BoxService(private val service: Service, private val platformInterface: Pl status.value = Status.Starting if (!receiverRegistered) { - ContextCompat.registerReceiver( - service, - receiver, - IntentFilter().apply { - addAction(Action.SERVICE_CLOSE) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - addAction(PowerManager.ACTION_DEVICE_IDLE_MODE_CHANGED) - } - }, - ContextCompat.RECEIVER_NOT_EXPORTED, - ) + ContextCompat.registerReceiver(service, receiver, IntentFilter().apply { + addAction(Action.SERVICE_CLOSE) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + addAction(PowerManager.ACTION_DEVICE_IDLE_MODE_CHANGED) + } + }, ContextCompat.RECEIVER_NOT_EXPORTED) receiverRegistered = true } @@ -358,7 +299,9 @@ class BoxService(private val service: Service, private val platformInterface: Pl return Service.START_NOT_STICKY } - internal fun onBind(): IBinder = binder + internal fun onBind(): IBinder { + return binder + } internal fun onDestroy() { binder.close() @@ -368,13 +311,20 @@ class BoxService(private val service: Service, private val platformInterface: Pl stopService() } + internal fun writeLog(message: String) { + commandServer?.writeMessage(message) + } + internal fun sendNotification(notification: Notification) { val builder = NotificationCompat.Builder(service, notification.identifier).setShowWhen(false) - .setContentTitle(notification.title).setContentText(notification.body) - .setOnlyAlertOnce(true).setSmallIcon(R.drawable.ic_menu) + .setContentTitle(notification.title) + .setContentText(notification.body) + .setOnlyAlertOnce(true) + .setSmallIcon(R.drawable.ic_menu) .setCategory(NotificationCompat.CATEGORY_EVENT) - .setPriority(NotificationCompat.PRIORITY_HIGH).setAutoCancel(true) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setAutoCancel(true) if (!notification.subtitle.isNullOrBlank()) { builder.setContentInfo(notification.subtitle) } @@ -384,14 +334,13 @@ class BoxService(private val service: Service, private val platformInterface: Pl service, 0, Intent( - service, - MainActivity::class.java, + service, MainActivity::class.java ).apply { setAction(Action.OPEN_URL).setData(Uri.parse(notification.openURL)) setFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT) }, ServiceNotification.flags, - ), + ) ) } GlobalScope.launch(Dispatchers.Main) { @@ -400,15 +349,11 @@ class BoxService(private val service: Service, private val platformInterface: Pl NotificationChannel( notification.identifier, notification.typeName, - NotificationManager.IMPORTANCE_HIGH, - ), + NotificationManager.IMPORTANCE_HIGH + ) ) } Application.notification.notify(notification.typeID, builder.build()) } } - - override fun writeDebugMessage(message: String?) { - Log.d("sing-box", message!!) - } -} +} \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/DebugInfoExporter.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/DebugInfoExporter.kt deleted file mode 100644 index a45c8d2d00..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/DebugInfoExporter.kt +++ /dev/null @@ -1,312 +0,0 @@ -package io.nekohasekai.sfa.bg - -import android.content.Context -import android.util.Log -import io.nekohasekai.sfa.utils.HookErrorClient -import java.io.BufferedInputStream -import java.io.BufferedOutputStream -import java.io.File -import java.io.FileInputStream -import java.io.FileOutputStream -import java.io.PrintWriter -import java.io.StringWriter -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale -import java.util.zip.ZipEntry -import java.util.zip.ZipOutputStream - -object DebugInfoExporter { - private const val TAG = "DebugInfoExporter" - - fun export(context: Context, outputPath: String, packageName: String): String { - Log.i(TAG, "export start: output=$outputPath, package=$packageName") - val outFile = File(outputPath) - if (!outFile.name.lowercase(Locale.US).endsWith(".zip")) { - Log.e(TAG, "export failed: output path must end with .zip") - throw IllegalArgumentException("output path must end with .zip") - } - val parent = outFile.parentFile!! - if (!parent.exists()) { - Log.i(TAG, "creating output directory: ${parent.path}") - if (!parent.mkdirs()) { - Log.e(TAG, "export failed: failed to create output directory: ${parent.path}") - throw IllegalStateException("failed to create output directory") - } - } - val warnings = mutableListOf() - var entriesAdded = 0 - try { - ZipOutputStream(BufferedOutputStream(FileOutputStream(outFile))).use { zip -> - Log.i(TAG, "adding export_info.txt") - addTextEntry(zip, "system/export_info.txt", buildExportInfo(context, packageName)) - entriesAdded++ - Log.i(TAG, "adding framework entries") - val frameworkCount = addFrameworkEntries(zip, warnings) - entriesAdded += frameworkCount - Log.i(TAG, "added $frameworkCount framework entries") - Log.i(TAG, "adding apex entries") - val apexCount = addApexEntries(zip, warnings) - entriesAdded += apexCount - Log.i(TAG, "added $apexCount apex entries") - Log.i(TAG, "adding log entries") - val logCount = addLogEntries(zip, warnings, context) - entriesAdded += logCount - Log.i(TAG, "added $logCount log entries") - Log.i(TAG, "adding system entries") - val systemCount = addSystemEntries(zip, warnings, packageName) - entriesAdded += systemCount - Log.i(TAG, "added $systemCount system entries") - if (warnings.isNotEmpty()) { - addTextEntry(zip, "logs/debug_export.txt", warnings.joinToString("\n")) - entriesAdded++ - } - } - Log.i(TAG, "zip closed, total entries: $entriesAdded, file size: ${outFile.length()}") - } catch (e: Throwable) { - outFile.delete() - val error = buildError("zip", "export failed", e, warnings, outputPath) - Log.e(TAG, error, e) - throw e - } - if (outFile.length() == 0L) { - val error = "output file is empty after writing $entriesAdded entries" - Log.e(TAG, error) - outFile.delete() - throw IllegalStateException(error) - } - outFile.setReadable(true, false) - if (warnings.isNotEmpty()) { - Log.w(TAG, "export finished with ${warnings.size} warnings, output size: ${outFile.length()}") - } else { - Log.i(TAG, "export finished: output=$outputPath, size=${outFile.length()}") - } - return outFile.absolutePath - } - - private fun buildExportInfo(context: Context, packageName: String): String { - val sb = StringBuilder() - sb.append("package=").append(packageName).append('\n') - sb.append("timestamp=").append(System.currentTimeMillis()).append('\n') - sb.append("context_class=").append(context.javaClass.name).append('\n') - return sb.toString() - } - - private fun addFrameworkEntries(zip: ZipOutputStream, warnings: MutableList): Int { - var count = 0 - val roots = - listOf( - File("/system/framework"), - File("/system_ext/framework"), - File("/product/framework"), - File("/vendor/framework"), - ) - val targetFiles = setOf("framework.jar", "services.jar") - for (root in roots) { - if (!root.isDirectory) continue - val destPrefix = "framework/${root.name}" - val files = root.listFiles() ?: emptyArray() - for (file in files) { - if (!file.isFile) continue - if (file.name !in targetFiles) continue - if (addFileEntry(zip, file, "$destPrefix/${file.name}", warnings)) { - count++ - } - } - } - return count - } - - private fun addApexEntries(zip: ZipOutputStream, warnings: MutableList): Int { - var count = 0 - val tetheringApex = File("/apex/com.android.tethering/javalib") - if (!tetheringApex.isDirectory) return 0 - val destPrefix = "framework/apex_com.android.tethering" - val files = tetheringApex.listFiles() ?: emptyArray() - for (file in files) { - if (!file.isFile) continue - if (!file.name.lowercase(Locale.US).endsWith(".jar")) continue - if (addFileEntry(zip, file, "$destPrefix/${file.name}", warnings)) { - count++ - } - } - return count - } - - private fun addLogEntries(zip: ZipOutputStream, warnings: MutableList, context: Context): Int { - var count = 0 - if (streamCommandToZip(zip, "logs/logcat.txt", warnings, listOf("logcat", "-d", "-b", "all")) != null) count++ - if (streamCommandToZip(zip, "logs/dmesg.txt", warnings, listOf("dmesg")) != null) count++ - val serviceLogsResult = HookErrorClient.query(context) - if (serviceLogsResult.logs.isNotEmpty()) { - val formatted = formatLogEntries(serviceLogsResult.logs) - addTextEntry(zip, "logs/service_logs.txt", formatted) - count++ - } else if (serviceLogsResult.failure != null) { - warnings.add("service logs: ${serviceLogsResult.failure}${serviceLogsResult.detail?.let { " ($it)" } ?: ""}") - } - val lspdDir = File("/data/adb/lspd/log") - if (lspdDir.isDirectory) { - val files = lspdDir.listFiles() ?: emptyArray() - for (file in files) { - if (!file.isFile) continue - if (addFileEntry(zip, file, "logs/lspd/${file.name}", warnings)) count++ - } - } else { - warnings.add("lspd logs not found: /data/adb/lspd/log") - } - return count - } - - private fun formatLogEntries(entries: List): String { - val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US) - return entries.joinToString("\n---\n") { entry -> - val levelName = when (entry.level) { - LogEntry.LEVEL_DEBUG -> "DEBUG" - LogEntry.LEVEL_INFO -> "INFO" - LogEntry.LEVEL_WARN -> "WARN" - LogEntry.LEVEL_ERROR -> "ERROR" - else -> "UNKNOWN" - } - val timestamp = dateFormat.format(Date(entry.timestamp)) - buildString { - append(levelName).append("[").append(timestamp).append("] ") - append("[").append(entry.source).append("]: ") - append(entry.message) - if (!entry.stackTrace.isNullOrEmpty()) { - append("\n").append(entry.stackTrace) - } - } - } - } - - private fun addSystemEntries(zip: ZipOutputStream, warnings: MutableList, packageName: String): Int { - var count = 0 - if (streamCommandToZip(zip, "system/getprop.txt", warnings, listOf("getprop")) != null) count++ - if (streamCommandToZip(zip, "system/uname.txt", warnings, listOf("uname", "-a")) != null) count++ - if (streamCommandToZip(zip, "system/id.txt", warnings, listOf("id")) != null) count++ - if (addFileEntry(zip, File("/proc/version"), "system/proc_version.txt", warnings)) count++ - if (addFileEntry(zip, File("/proc/cpuinfo"), "system/cpuinfo.txt", warnings)) count++ - if (addFileEntry(zip, File("/proc/meminfo"), "system/meminfo.txt", warnings)) count++ - if (addFileEntry(zip, File("/proc/pressure/cpu"), "system/pressure_cpu.txt", warnings)) count++ - if (addFileEntry(zip, File("/proc/pressure/memory"), "system/pressure_memory.txt", warnings)) count++ - if (addFileEntry(zip, File("/proc/pressure/io"), "system/pressure_io.txt", warnings)) count++ - val cmdPackages = - streamCommandToZip( - zip, - "system/packages_cmd.txt", - warnings, - listOf("cmd", "package", "list", "packages", "-f"), - ) - if (cmdPackages != null) count++ - if ((cmdPackages == null || cmdPackages.bytes == 0L) && (cmdPackages?.exitCode ?: 1) != 0) { - if (streamCommandToZip( - zip, - "system/packages_pm.txt", - warnings, - listOf("pm", "list", "packages", "-f"), - ) != null - ) { - count++ - } - } - if (streamCommandToZip( - zip, - "system/dumpsys_package_$packageName.txt", - warnings, - listOf("dumpsys", "package", packageName), - ) != null - ) { - count++ - } - return count - } - - private fun addFileEntry(zip: ZipOutputStream, file: File, entryName: String, warnings: MutableList): Boolean { - if (!file.isFile) { - warnings.add("missing file: ${file.path}") - return false - } - try { - val entry = ZipEntry(entryName) - zip.putNextEntry(entry) - BufferedInputStream(FileInputStream(file)).use { input -> - val buffer = ByteArray(16 * 1024) - while (true) { - val read = input.read(buffer) - if (read <= 0) break - zip.write(buffer, 0, read) - } - } - zip.closeEntry() - return true - } catch (e: Throwable) { - warnings.add("zip failed ${file.path}: ${e.message}") - return false - } - } - - private fun addTextEntry(zip: ZipOutputStream, entryName: String, content: String) { - val entry = ZipEntry(entryName) - zip.putNextEntry(entry) - val bytes = content.toByteArray() - zip.write(bytes) - zip.closeEntry() - } - - private data class CommandResult(val exitCode: Int, val bytes: Long) - - private fun streamCommandToZip( - zip: ZipOutputStream, - entryName: String, - warnings: MutableList, - command: List, - ): CommandResult? = try { - val process = ProcessBuilder(command).redirectErrorStream(true).start() - val entry = ZipEntry(entryName) - zip.putNextEntry(entry) - var bytes = 0L - process.inputStream.use { input -> - val buffer = ByteArray(16 * 1024) - while (true) { - val read = input.read(buffer) - if (read <= 0) break - zip.write(buffer, 0, read) - bytes += read - } - } - zip.closeEntry() - val code = process.waitFor() - if (code != 0) { - warnings.add("command failed (${command.joinToString(" ")}): exit=$code") - } - CommandResult(code, bytes) - } catch (e: Throwable) { - warnings.add("command failed (${command.joinToString(" ")}): ${e.message}") - runCatching { zip.closeEntry() } - null - } - - private fun buildError(stage: String, detail: String, throwable: Throwable?, warnings: List, outputPath: String?): String { - val sb = StringBuilder() - sb.append("stage=").append(stage).append('\n') - if (!outputPath.isNullOrBlank()) { - sb.append("output=").append(outputPath).append('\n') - } - if (detail.isNotBlank()) { - sb.append("detail=").append(detail).append('\n') - } - if (throwable != null) { - sb.append("exception=").append(throwable.javaClass.name) - .append(": ").append(throwable.message ?: "").append('\n') - val sw = StringWriter() - throwable.printStackTrace(PrintWriter(sw)) - sb.append(sw.toString()) - } - if (warnings.isNotEmpty()) { - if (!sb.endsWith('\n')) sb.append('\n') - sb.append("warnings:\n").append(warnings.joinToString("\n")) - } - return sb.toString().trimEnd() - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/DefaultNetworkListener.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/DefaultNetworkListener.kt index d7dfc2b4e0..239daae6c4 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/DefaultNetworkListener.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/DefaultNetworkListener.kt @@ -40,7 +40,6 @@ import kotlinx.coroutines.runBlocking object DefaultNetworkListener { private sealed class NetworkMessage { class Start(val key: Any, val listener: (Network?) -> Unit) : NetworkMessage() - class Get : NetworkMessage() { val response = CompletableDeferred() } @@ -48,88 +47,69 @@ object DefaultNetworkListener { class Stop(val key: Any) : NetworkMessage() class Put(val network: Network) : NetworkMessage() - class Update(val network: Network) : NetworkMessage() - class Lost(val network: Network) : NetworkMessage() } @OptIn(DelicateCoroutinesApi::class, ObsoleteCoroutinesApi::class) - private val networkActor = - GlobalScope.actor(Dispatchers.Unconfined) { - val listeners = mutableMapOf Unit>() - var network: Network? = null - val pendingRequests = arrayListOf() - for (message in channel) { - when (message) { - is NetworkMessage.Start -> { - if (listeners.isEmpty()) register() - listeners[message.key] = message.listener - if (network != null) message.listener(network) - } + private val networkActor = GlobalScope.actor(Dispatchers.Unconfined) { + val listeners = mutableMapOf Unit>() + var network: Network? = null + val pendingRequests = arrayListOf() + for (message in channel) when (message) { + is NetworkMessage.Start -> { + if (listeners.isEmpty()) register() + listeners[message.key] = message.listener + if (network != null) message.listener(network) + } - is NetworkMessage.Get -> { - check(listeners.isNotEmpty()) { "Getting network without any listeners is not supported" } - if (network == null) { - pendingRequests += message - } else { - message.response.complete( - network, - ) - } - } + is NetworkMessage.Get -> { + check(listeners.isNotEmpty()) { "Getting network without any listeners is not supported" } + if (network == null) pendingRequests += message else message.response.complete( + network + ) + } - is NetworkMessage.Stop -> - if (listeners.isNotEmpty() && - // was not empty - listeners.remove(message.key) != null && - listeners.isEmpty() - ) { - network = null - unregister() - } + is NetworkMessage.Stop -> if (listeners.isNotEmpty() && // was not empty + listeners.remove(message.key) != null && listeners.isEmpty() + ) { + network = null + unregister() + } - is NetworkMessage.Put -> { - network = message.network - pendingRequests.forEach { it.response.complete(message.network) } - pendingRequests.clear() - listeners.values.forEach { it(network) } - } + is NetworkMessage.Put -> { + network = message.network + pendingRequests.forEach { it.response.complete(message.network) } + pendingRequests.clear() + listeners.values.forEach { it(network) } + } - is NetworkMessage.Update -> - if (network == message.network) { - listeners.values.forEach { - it( - network, - ) - } - } + is NetworkMessage.Update -> if (network == message.network) listeners.values.forEach { + it( + network + ) + } - is NetworkMessage.Lost -> - if (network == message.network) { - network = null - listeners.values.forEach { it(null) } - } - } + is NetworkMessage.Lost -> if (network == message.network) { + network = null + listeners.values.forEach { it(null) } } } + } suspend fun start(key: Any, listener: (Network?) -> Unit) = networkActor.send( NetworkMessage.Start( key, - listener, - ), + listener + ) ) - suspend fun get(): Network = if (fallback) { - @TargetApi(23) + suspend fun get() = if (fallback) @TargetApi(23) { Application.connectivity.activeNetwork ?: error("missing default network") // failed to listen, return current if available - } else { - NetworkMessage.Get().run { - networkActor.send(this) - response.await() - } + } else NetworkMessage.Get().run { + networkActor.send(this) + response.await() } suspend fun stop(key: Any) = networkActor.send(NetworkMessage.Stop(key)) @@ -139,12 +119,15 @@ object DefaultNetworkListener { override fun onAvailable(network: Network) = runBlocking { networkActor.send( NetworkMessage.Put( - network, - ), + network + ) ) } - override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) { + override fun onCapabilitiesChanged( + network: Network, + networkCapabilities: NetworkCapabilities + ) { // it's a good idea to refresh capabilities runBlocking { networkActor.send(NetworkMessage.Update(network)) } } @@ -152,22 +135,21 @@ object DefaultNetworkListener { override fun onLost(network: Network) = runBlocking { networkActor.send( NetworkMessage.Lost( - network, - ), + network + ) ) } } private var fallback = false - private val request = - NetworkRequest.Builder().apply { - addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) - addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED) - if (Build.VERSION.SDK_INT == 23) { // workarounds for OEM bugs - removeCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) - removeCapability(NetworkCapabilities.NET_CAPABILITY_CAPTIVE_PORTAL) - } - }.build() + private val request = NetworkRequest.Builder().apply { + addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED) + if (Build.VERSION.SDK_INT == 23) { // workarounds for OEM bugs + removeCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + removeCapability(NetworkCapabilities.NET_CAPABILITY_CAPTIVE_PORTAL) + } + }.build() private val mainHandler = Handler(Looper.getMainLooper()) /** @@ -182,42 +164,33 @@ object DefaultNetworkListener { */ private fun register() { when (Build.VERSION.SDK_INT) { - in 31..Int.MAX_VALUE -> - @TargetApi(31) - { - Application.connectivity.registerBestMatchingNetworkCallback( - request, - Callback, - mainHandler, - ) - } + in 31..Int.MAX_VALUE -> @TargetApi(31) { + Application.connectivity.registerBestMatchingNetworkCallback( + request, + Callback, + mainHandler + ) + } - in 28 until 31 -> - @TargetApi(28) - { // we want REQUEST here instead of LISTEN - Application.connectivity.requestNetwork(request, Callback, mainHandler) - } + in 28 until 31 -> @TargetApi(28) { // we want REQUEST here instead of LISTEN + Application.connectivity.requestNetwork(request, Callback, mainHandler) + } - in 26 until 28 -> - @TargetApi(26) - { - Application.connectivity.registerDefaultNetworkCallback(Callback, mainHandler) - } + in 26 until 28 -> @TargetApi(26) { + Application.connectivity.registerDefaultNetworkCallback(Callback, mainHandler) + } - in 24 until 26 -> - @TargetApi(24) - { - Application.connectivity.registerDefaultNetworkCallback(Callback) - } + in 24 until 26 -> @TargetApi(24) { + Application.connectivity.registerDefaultNetworkCallback(Callback) + } - else -> - try { - fallback = false - Application.connectivity.requestNetwork(request, Callback) - } catch (e: RuntimeException) { - fallback = - true // known bug on API 23: https://stackoverflow.com/a/33509180/2245107 - } + else -> try { + fallback = false + Application.connectivity.requestNetwork(request, Callback) + } catch (e: RuntimeException) { + fallback = + true // known bug on API 23: https://stackoverflow.com/a/33509180/2245107 + } } } @@ -226,4 +199,4 @@ object DefaultNetworkListener { Application.connectivity.unregisterNetworkCallback(Callback) } } -} +} \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/DefaultNetworkMonitor.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/DefaultNetworkMonitor.kt index 3c02e04138..9b5c8743c9 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/DefaultNetworkMonitor.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/DefaultNetworkMonitor.kt @@ -4,6 +4,10 @@ import android.net.Network import android.os.Build import io.nekohasekai.libbox.InterfaceUpdateListener import io.nekohasekai.sfa.Application +import io.nekohasekai.sfa.constant.Bugs +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch import java.net.NetworkInterface object DefaultNetworkMonitor { @@ -40,7 +44,9 @@ object DefaultNetworkMonitor { checkDefaultInterfaceUpdate(defaultNetwork) } - private fun checkDefaultInterfaceUpdate(newNetwork: Network?) { + private fun checkDefaultInterfaceUpdate( + newNetwork: Network? + ) { val listener = listener ?: return if (newNetwork != null) { val interfaceName = @@ -53,10 +59,23 @@ object DefaultNetworkMonitor { Thread.sleep(100) continue } - listener.updateDefaultInterface(interfaceName, interfaceIndex, false, false) + if (Bugs.fixAndroidStack) { + GlobalScope.launch(Dispatchers.IO) { + listener.updateDefaultInterface(interfaceName, interfaceIndex, false, false) + } + } else { + listener.updateDefaultInterface(interfaceName, interfaceIndex, false, false) + } } } else { - listener.updateDefaultInterface("", -1, false, false) + if (Bugs.fixAndroidStack) { + GlobalScope.launch(Dispatchers.IO) { + listener.updateDefaultInterface("", -1, false, false) + } + } else { + listener.updateDefaultInterface("", -1, false, false) + } } } -} + +} \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/LocalResolver.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/LocalResolver.kt index 26f0254382..5db814e091 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/LocalResolver.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/LocalResolver.kt @@ -17,9 +17,12 @@ import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine object LocalResolver : LocalDNSTransport { + private const val RCODE_NXDOMAIN = 3 - override fun raw(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q + override fun raw(): Boolean { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q + } @RequiresApi(Build.VERSION_CODES.Q) override fun exchange(ctx: ExchangeContext, message: ByteArray) { @@ -28,11 +31,52 @@ object LocalResolver : LocalDNSTransport { suspendCoroutine { continuation -> val signal = CancellationSignal() ctx.onCancel(signal::cancel) - val callback = - object : DnsResolver.Callback { - override fun onAnswer(answer: ByteArray, rcode: Int) { + val callback = object : DnsResolver.Callback { + override fun onAnswer(answer: ByteArray, rcode: Int) { + if (rcode == 0) { + ctx.rawSuccess(answer) + } else { + ctx.errorCode(rcode) + } + continuation.resume(Unit) + } + + override fun onError(error: DnsResolver.DnsException) { + when (val cause = error.cause) { + is ErrnoException -> { + ctx.errnoCode(cause.errno) + continuation.resume(Unit) + return + } + } + continuation.tryResumeWithException(error) + } + } + DnsResolver.getInstance().rawQuery( + defaultNetwork, + message, + DnsResolver.FLAG_NO_RETRY, + Dispatchers.IO.asExecutor(), + signal, + callback + ) + } + } + } + + override fun lookup(ctx: ExchangeContext, network: String, domain: String) { + return runBlocking { + val defaultNetwork = DefaultNetworkMonitor.require() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + suspendCoroutine { continuation -> + val signal = CancellationSignal() + ctx.onCancel(signal::cancel) + val callback = object : DnsResolver.Callback> { + @Suppress("ThrowableNotThrown") + override fun onAnswer(answer: Collection, rcode: Int) { if (rcode == 0) { - ctx.rawSuccess(answer) + ctx.success((answer as Collection).mapNotNull { it?.hostAddress } + .joinToString("\n")) } else { ctx.errorCode(rcode) } @@ -50,57 +94,11 @@ object LocalResolver : LocalDNSTransport { continuation.tryResumeWithException(error) } } - DnsResolver.getInstance().rawQuery( - defaultNetwork, - message, - DnsResolver.FLAG_NO_RETRY, - Dispatchers.IO.asExecutor(), - signal, - callback, - ) - } - } - } - - override fun lookup(ctx: ExchangeContext, network: String, domain: String) { - return runBlocking { - val defaultNetwork = DefaultNetworkMonitor.require() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - suspendCoroutine { continuation -> - val signal = CancellationSignal() - ctx.onCancel(signal::cancel) - val callback = - object : DnsResolver.Callback> { - @Suppress("ThrowableNotThrown") - override fun onAnswer(answer: Collection, rcode: Int) { - if (rcode == 0) { - ctx.success( - (answer as Collection).mapNotNull { it?.hostAddress } - .joinToString("\n"), - ) - } else { - ctx.errorCode(rcode) - } - continuation.resume(Unit) - } - - override fun onError(error: DnsResolver.DnsException) { - when (val cause = error.cause) { - is ErrnoException -> { - ctx.errnoCode(cause.errno) - continuation.resume(Unit) - return - } - } - continuation.tryResumeWithException(error) - } - } - val type = - when { - network.endsWith("4") -> DnsResolver.TYPE_A - network.endsWith("6") -> DnsResolver.TYPE_AAAA - else -> null - } + val type = when { + network.endsWith("4") -> DnsResolver.TYPE_A + network.endsWith("6") -> DnsResolver.TYPE_AAAA + else -> null + } if (type != null) { DnsResolver.getInstance().query( defaultNetwork, @@ -109,7 +107,7 @@ object LocalResolver : LocalDNSTransport { DnsResolver.FLAG_NO_RETRY, Dispatchers.IO.asExecutor(), signal, - callback, + callback ) } else { DnsResolver.getInstance().query( @@ -118,20 +116,19 @@ object LocalResolver : LocalDNSTransport { DnsResolver.FLAG_NO_RETRY, Dispatchers.IO.asExecutor(), signal, - callback, + callback ) } } } else { - val answer = - try { - defaultNetwork.getAllByName(domain) - } catch (e: UnknownHostException) { - ctx.errorCode(RCODE_NXDOMAIN) - return@runBlocking - } + val answer = try { + defaultNetwork.getAllByName(domain) + } catch (e: UnknownHostException) { + ctx.errorCode(RCODE_NXDOMAIN) + return@runBlocking + } ctx.success(answer.mapNotNull { it.hostAddress }.joinToString("\n")) } } } -} +} \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/LogEntry.java b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/LogEntry.java deleted file mode 100644 index f8b2ed3f74..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/LogEntry.java +++ /dev/null @@ -1,67 +0,0 @@ -package io.nekohasekai.sfa.bg; - -import android.os.Parcel; -import android.os.Parcelable; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -public class LogEntry implements Parcelable { - public static final int LEVEL_DEBUG = 0; - public static final int LEVEL_INFO = 1; - public static final int LEVEL_WARN = 2; - public static final int LEVEL_ERROR = 3; - - public final int level; - public final long timestamp; - @NonNull public final String source; - @NonNull public final String message; - @Nullable public final String stackTrace; - - public LogEntry( - int level, - long timestamp, - @NonNull String source, - @NonNull String message, - @Nullable String stackTrace) { - this.level = level; - this.timestamp = timestamp; - this.source = source; - this.message = message; - this.stackTrace = stackTrace; - } - - protected LogEntry(Parcel in) { - level = in.readInt(); - timestamp = in.readLong(); - source = in.readString(); - message = in.readString(); - stackTrace = in.readString(); - } - - @Override - public void writeToParcel(@NonNull Parcel dest, int flags) { - dest.writeInt(level); - dest.writeLong(timestamp); - dest.writeString(source); - dest.writeString(message); - dest.writeString(stackTrace); - } - - @Override - public int describeContents() { - return 0; - } - - public static final Creator CREATOR = - new Creator<>() { - @Override - public LogEntry createFromParcel(Parcel in) { - return new LogEntry(in); - } - - @Override - public LogEntry[] newArray(int size) { - return new LogEntry[size]; - } - }; -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/PackageEntry.java b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/PackageEntry.java deleted file mode 100644 index c4471937fd..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/PackageEntry.java +++ /dev/null @@ -1,40 +0,0 @@ -package io.nekohasekai.sfa.bg; - -import android.os.Parcel; -import android.os.Parcelable; -import androidx.annotation.NonNull; - -public class PackageEntry implements Parcelable { - @NonNull public final String packageName; - - public PackageEntry(@NonNull String packageName) { - this.packageName = packageName; - } - - protected PackageEntry(Parcel in) { - packageName = in.readString(); - } - - @Override - public void writeToParcel(@NonNull Parcel dest, int flags) { - dest.writeString(packageName); - } - - @Override - public int describeContents() { - return 0; - } - - public static final Creator CREATOR = - new Creator<>() { - @Override - public PackageEntry createFromParcel(Parcel in) { - return new PackageEntry(in); - } - - @Override - public PackageEntry[] newArray(int size) { - return new PackageEntry[size]; - } - }; -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/ParceledListSlice.java b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/ParceledListSlice.java deleted file mode 100644 index 9840067555..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/ParceledListSlice.java +++ /dev/null @@ -1,152 +0,0 @@ -/* - * Copyright (C) 2011 The Android Open Source Project - * - * 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 - * - * http://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 io.nekohasekai.sfa.bg; - -import android.os.Binder; -import android.os.IBinder; -import android.os.Parcel; -import android.os.Parcelable; -import android.os.RemoteException; -import java.util.ArrayList; -import java.util.List; - -public class ParceledListSlice implements Parcelable { - private static final int MAX_IPC_SIZE = 64 * 1024; - - private final List mList; - - public ParceledListSlice(List list) { - mList = list; - } - - private ParceledListSlice(Parcel in, ClassLoader loader) { - final int n = in.readInt(); - mList = new ArrayList<>(n); - if (n <= 0) { - return; - } - - int i = 0; - while (i < n) { - if (in.readInt() == 0) { - break; - } - @SuppressWarnings("unchecked") - T item = (T) in.readParcelable(loader); - mList.add(item); - i++; - } - if (i >= n) { - return; - } - final IBinder retriever = in.readStrongBinder(); - while (i < n) { - Parcel data = Parcel.obtain(); - Parcel reply = Parcel.obtain(); - data.writeInt(i); - try { - retriever.transact(IBinder.FIRST_CALL_TRANSACTION, data, reply, 0); - } catch (RemoteException e) { - reply.recycle(); - data.recycle(); - return; - } - while (i < n && reply.readInt() != 0) { - @SuppressWarnings("unchecked") - T item = (T) reply.readParcelable(loader); - mList.add(item); - i++; - } - reply.recycle(); - data.recycle(); - } - } - - public List getList() { - return mList; - } - - @Override - public int describeContents() { - int contents = 0; - for (int i = 0; i < mList.size(); i++) { - contents |= mList.get(i).describeContents(); - } - return contents; - } - - @Override - public void writeToParcel(Parcel dest, int flags) { - final int n = mList.size(); - dest.writeInt(n); - if (n <= 0) { - return; - } - int i = 0; - while (i < n && dest.dataSize() < MAX_IPC_SIZE) { - dest.writeInt(1); - dest.writeParcelable(mList.get(i), flags); - i++; - } - if (i < n) { - dest.writeInt(0); - final int start = i; - Binder retriever = - new Binder() { - @Override - protected boolean onTransact(int code, Parcel data, Parcel reply, int flags) - throws RemoteException { - if (code != FIRST_CALL_TRANSACTION) { - return super.onTransact(code, data, reply, flags); - } - int i = data.readInt(); - if (i < start || i > n) { - return false; - } - while (i < n && reply.dataSize() < MAX_IPC_SIZE) { - reply.writeInt(1); - reply.writeParcelable(mList.get(i), flags); - i++; - } - if (i < n) { - reply.writeInt(0); - } - return true; - } - }; - dest.writeStrongBinder(retriever); - } - } - - public static final Parcelable.ClassLoaderCreator CREATOR = - new Parcelable.ClassLoaderCreator() { - @Override - public ParceledListSlice createFromParcel(Parcel in) { - return new ParceledListSlice(in, null); - } - - @Override - public ParceledListSlice createFromParcel(Parcel in, ClassLoader loader) { - return new ParceledListSlice(in, loader); - } - - @Override - public ParceledListSlice[] newArray(int size) { - return new ParceledListSlice[size]; - } - }; -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/PlatformInterfaceWrapper.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/PlatformInterfaceWrapper.kt index fa7cea5ba4..068a529c9b 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/PlatformInterfaceWrapper.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/PlatformInterfaceWrapper.kt @@ -1,13 +1,13 @@ package io.nekohasekai.sfa.bg import android.annotation.SuppressLint +import android.content.pm.PackageManager import android.net.NetworkCapabilities import android.os.Build import android.os.Process import android.system.OsConstants import android.util.Log import androidx.annotation.RequiresApi -import io.nekohasekai.libbox.ConnectionOwner import io.nekohasekai.libbox.InterfaceUpdateListener import io.nekohasekai.libbox.Libbox import io.nekohasekai.libbox.LocalDNSTransport @@ -27,7 +27,10 @@ import kotlin.io.encoding.ExperimentalEncodingApi import io.nekohasekai.libbox.NetworkInterface as LibboxNetworkInterface interface PlatformInterfaceWrapper : PlatformInterface { - override fun usePlatformAutoDetectInterfaceControl(): Boolean = true + + override fun usePlatformAutoDetectInterfaceControl(): Boolean { + return true + } override fun autoDetectInterfaceControl(fd: Int) { } @@ -36,7 +39,9 @@ interface PlatformInterfaceWrapper : PlatformInterface { error("invalid argument") } - override fun useProcFS(): Boolean = Build.VERSION.SDK_INT < Build.VERSION_CODES.Q + override fun useProcFS(): Boolean { + return Build.VERSION.SDK_INT < Build.VERSION_CODES.Q + } @RequiresApi(Build.VERSION_CODES.Q) override fun findConnectionOwner( @@ -44,22 +49,16 @@ interface PlatformInterfaceWrapper : PlatformInterface { sourceAddress: String, sourcePort: Int, destinationAddress: String, - destinationPort: Int, - ): ConnectionOwner { + destinationPort: Int + ): Int { try { - val uid = - Application.connectivity.getConnectionOwnerUid( - ipProtocol, - InetSocketAddress(sourceAddress, sourcePort), - InetSocketAddress(destinationAddress, destinationPort), - ) + val uid = Application.connectivity.getConnectionOwnerUid( + ipProtocol, + InetSocketAddress(sourceAddress, sourcePort), + InetSocketAddress(destinationAddress, destinationPort) + ) if (uid == Process.INVALID_UID) error("android: connection owner not found") - val packages = Application.packageManager.getPackagesForUid(uid) - val owner = ConnectionOwner() - owner.userId = uid - owner.userName = packages?.firstOrNull() ?: "" - owner.androidPackageName = packages?.firstOrNull() ?: "" - return owner + return uid } catch (e: Exception) { Log.e("PlatformInterface", "getConnectionOwnerUid", e) e.printStackTrace(System.err) @@ -67,6 +66,29 @@ interface PlatformInterfaceWrapper : PlatformInterface { } } + override fun packageNameByUid(uid: Int): String { + val packages = Application.packageManager.getPackagesForUid(uid) + if (packages.isNullOrEmpty()) error("android: package not found") + return packages[0] + } + + @Suppress("DEPRECATION") + override fun uidByPackageName(packageName: String): Int { + return try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + Application.packageManager.getPackageUid( + packageName, PackageManager.PackageInfoFlags.of(0) + ) + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + Application.packageManager.getPackageUid(packageName, 0) + } else { + Application.packageManager.getApplicationInfo(packageName, 0).uid + } + } catch (e: PackageManager.NameNotFoundException) { + error("android: package not found") + } + } + override fun startDefaultInterfaceMonitor(listener: InterfaceUpdateListener) { DefaultNetworkMonitor.setListener(listener) } @@ -89,28 +111,23 @@ interface PlatformInterfaceWrapper : PlatformInterface { networkInterfaces.find { it.name == boxInterface.name } ?: continue boxInterface.dnsServer = StringArray(linkProperties.dnsServers.mapNotNull { it.hostAddress }.iterator()) - boxInterface.type = - when { - networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> Libbox.InterfaceTypeWIFI - networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> Libbox.InterfaceTypeCellular - networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> Libbox.InterfaceTypeEthernet - else -> Libbox.InterfaceTypeOther - } + boxInterface.type = when { + networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> Libbox.InterfaceTypeWIFI + networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> Libbox.InterfaceTypeCellular + networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> Libbox.InterfaceTypeEthernet + else -> Libbox.InterfaceTypeOther + } boxInterface.index = networkInterface.index runCatching { boxInterface.mtu = networkInterface.mtu }.onFailure { Log.e( - "PlatformInterface", - "failed to get mtu for interface ${boxInterface.name}", - it, + "PlatformInterface", "failed to get mtu for interface ${boxInterface.name}", it ) } boxInterface.addresses = - StringArray( - networkInterface.interfaceAddresses.mapTo(mutableListOf()) { it.toPrefix() } - .iterator(), - ) + StringArray(networkInterface.interfaceAddresses.mapTo(mutableListOf()) { it.toPrefix() } + .iterator()) var dumpFlags = 0 if (networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)) { dumpFlags = OsConstants.IFF_UP or OsConstants.IFF_RUNNING @@ -132,16 +149,19 @@ interface PlatformInterfaceWrapper : PlatformInterface { return InterfaceArray(interfaces.iterator()) } - override fun underNetworkExtension(): Boolean = false + override fun underNetworkExtension(): Boolean { + return false + } - override fun includeAllNetworks(): Boolean = false + override fun includeAllNetworks(): Boolean { + return false + } override fun clearDNSCache() { } override fun readWIFIState(): WIFIState? { - @Suppress("DEPRECATION") - val wifiInfo = + @Suppress("DEPRECATION") val wifiInfo = Application.wifiManager.connectionInfo ?: return null var ssid = wifiInfo.ssid if (ssid == "") { @@ -153,52 +173,67 @@ interface PlatformInterfaceWrapper : PlatformInterface { return WIFIState(ssid, wifiInfo.bssid) } - override fun localDNSTransport(): LocalDNSTransport? = LocalResolver + override fun localDNSTransport(): LocalDNSTransport? { + return LocalResolver + } @OptIn(ExperimentalEncodingApi::class) override fun systemCertificates(): StringIterator { val certificates = mutableListOf() val keyStore = KeyStore.getInstance("AndroidCAStore") if (keyStore != null) { - keyStore.load(null, null) + keyStore.load(null, null); val aliases = keyStore.aliases() while (aliases.hasMoreElements()) { val cert = keyStore.getCertificate(aliases.nextElement()) certificates.add( - "-----BEGIN CERTIFICATE-----\n" + Base64.encode(cert.encoded) + "\n-----END CERTIFICATE-----", + "-----BEGIN CERTIFICATE-----\n" + Base64.encode(cert.encoded) + "\n-----END CERTIFICATE-----" ) } } return StringArray(certificates.iterator()) } - private class InterfaceArray(private val iterator: Iterator) : NetworkInterfaceIterator { - override fun hasNext(): Boolean = iterator.hasNext() + private class InterfaceArray(private val iterator: Iterator) : + NetworkInterfaceIterator { + + override fun hasNext(): Boolean { + return iterator.hasNext() + } + + override fun next(): LibboxNetworkInterface { + return iterator.next() + } - override fun next(): LibboxNetworkInterface = iterator.next() } - class StringArray(private val iterator: Iterator) : StringIterator { + private class StringArray(private val iterator: Iterator) : StringIterator { + override fun len(): Int { // not used by core return 0 } - override fun hasNext(): Boolean = iterator.hasNext() + override fun hasNext(): Boolean { + return iterator.hasNext() + } - override fun next(): String = iterator.next() + override fun next(): String { + return iterator.next() + } } - private fun InterfaceAddress.toPrefix(): String = if (address is Inet6Address) { - "${Inet6Address.getByAddress(address.address).hostAddress}/$networkPrefixLength" - } else { - "${address.hostAddress}/$networkPrefixLength" + private fun InterfaceAddress.toPrefix(): String { + return if (address is Inet6Address) { + "${Inet6Address.getByAddress(address.address).hostAddress}/${networkPrefixLength}" + } else { + "${address.hostAddress}/${networkPrefixLength}" + } } private val NetworkInterface.flags: Int - @SuppressLint("SoonBlockedPrivateApi") - get() { + @SuppressLint("SoonBlockedPrivateApi") get() { val getFlagsMethod = NetworkInterface::class.java.getDeclaredMethod("getFlags") return getFlagsMethod.invoke(this) as Int } -} +} \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/ProxyService.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/ProxyService.kt index 74c04120ec..6087d49e33 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/ProxyService.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/ProxyService.kt @@ -4,16 +4,19 @@ import android.app.Service import android.content.Intent import io.nekohasekai.libbox.Notification -class ProxyService : - Service(), - PlatformInterfaceWrapper { +class ProxyService : Service(), PlatformInterfaceWrapper { + private val service = BoxService(this, this) - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int) = service.onStartCommand() + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int) = + service.onStartCommand() override fun onBind(intent: Intent) = service.onBind() - override fun onDestroy() = service.onDestroy() - override fun sendNotification(notification: Notification) = service.sendNotification(notification) -} + override fun writeLog(message: String) = service.writeLog(message) + + override fun sendNotification(notification: Notification) = + service.sendNotification(notification) + +} \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/RootClient.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/RootClient.kt deleted file mode 100644 index ab33003685..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/RootClient.kt +++ /dev/null @@ -1,106 +0,0 @@ -package io.nekohasekai.sfa.bg - -import android.content.ComponentName -import android.content.Intent -import android.content.ServiceConnection -import android.content.pm.PackageInfo -import android.os.IBinder -import android.os.RemoteException -import com.topjohnwu.superuser.Shell -import com.topjohnwu.superuser.ipc.RootService -import io.nekohasekai.sfa.Application -import io.nekohasekai.sfa.BuildConfig -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withContext -import kotlin.coroutines.resume - -object RootClient { - init { - Shell.enableVerboseLogging = BuildConfig.DEBUG - Shell.setDefaultBuilder( - Shell.Builder.create() - .setFlags(Shell.FLAG_MOUNT_MASTER) - .setTimeout(10), - ) - } - - private val _rootAvailable = MutableStateFlow(null) - val rootAvailable: StateFlow = _rootAvailable - - private val _serviceConnected = MutableStateFlow(false) - val serviceConnected: StateFlow = _serviceConnected - - private var service: IRootService? = null - private var connection: ServiceConnection? = null - private val connectionMutex = Mutex() - - suspend fun checkRootAvailable(): Boolean { - Shell.getCachedShell()?.close() - return suspendCancellableCoroutine { continuation -> - Shell.getShell { shell -> - val available = shell.isRoot - _rootAvailable.value = available - continuation.resume(available) - } - } - } - - suspend fun bindService(): IRootService = connectionMutex.withLock { - service?.let { return it } - - return withContext(Dispatchers.Main) { - suspendCancellableCoroutine { continuation -> - val conn = object : ServiceConnection { - override fun onServiceConnected(name: ComponentName?, binder: IBinder?) { - val svc = IRootService.Stub.asInterface(binder) - service = svc - connection = this - _serviceConnected.value = true - continuation.resume(svc) - } - - override fun onServiceDisconnected(name: ComponentName?) { - service = null - connection = null - _serviceConnected.value = false - } - } - - val intent = Intent(Application.application, RootServer::class.java) - RootService.bind(intent, conn) - - continuation.invokeOnCancellation { - RootService.unbind(conn) - } - } - } - } - - fun unbindService() { - connection?.let { - RootService.unbind(it) - connection = null - service = null - _serviceConnected.value = false - } - } - - suspend fun getInstalledPackages(flags: Int): List { - val userId = android.os.Process.myUserHandle().hashCode() - val svc = bindService() - return try { - val slice = svc.getInstalledPackages(flags, userId) - - @Suppress("UNCHECKED_CAST") - val list = slice.list as List - list - } catch (e: RemoteException) { - throw e.rethrowFromSystemServer() - } - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/RootServer.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/RootServer.kt deleted file mode 100644 index 352d159637..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/RootServer.kt +++ /dev/null @@ -1,37 +0,0 @@ -package io.nekohasekai.sfa.bg - -import android.content.Intent -import android.content.pm.PackageInfo -import android.os.IBinder -import android.os.ParcelFileDescriptor -import com.topjohnwu.superuser.ipc.RootService -import io.nekohasekai.sfa.BuildConfig -import io.nekohasekai.sfa.vendor.PrivilegedServiceUtils -import java.io.IOException - -class RootServer : RootService() { - - private val binder = object : IRootService.Stub() { - override fun destroy() { - stopSelf() - } - - override fun getInstalledPackages(flags: Int, userId: Int): ParceledListSlice { - val allPackages = PrivilegedServiceUtils.getInstalledPackages(flags, userId) - return ParceledListSlice(allPackages) - } - - override fun installPackage(apk: ParcelFileDescriptor?, size: Long, userId: Int) { - if (apk == null) throw IOException("APK file descriptor is null") - PrivilegedServiceUtils.installPackage(apk, size, userId) - } - - override fun exportDebugInfo(outputPath: String?): String = DebugInfoExporter.export( - this@RootServer, - outputPath!!, - BuildConfig.APPLICATION_ID, - ) - } - - override fun onBind(intent: Intent): IBinder = binder -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/ServiceBinder.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/ServiceBinder.kt index c430114441..0f8a605554 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/ServiceBinder.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/ServiceBinder.kt @@ -43,7 +43,9 @@ class ServiceBinder(private val status: MutableLiveData) : IService.Stub } } - override fun getStatus(): Int = (status.value ?: Status.Stopped).ordinal + override fun getStatus(): Int { + return (status.value ?: Status.Stopped).ordinal + } override fun registerCallback(callback: IServiceCallback) { callbacks.register(callback) @@ -56,4 +58,4 @@ class ServiceBinder(private val status: MutableLiveData) : IService.Stub fun close() { callbacks.kill() } -} +} \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/ServiceConnection.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/ServiceConnection.kt index d6c76c914d..c9d31f988e 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/ServiceConnection.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/ServiceConnection.kt @@ -18,7 +18,12 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext -class ServiceConnection(private val context: Context, callback: Callback, private val register: Boolean = true) : ServiceConnection { +class ServiceConnection( + private val context: Context, + callback: Callback, + private val register: Boolean = true, +) : ServiceConnection { + companion object { private const val TAG = "ServiceConnection" } @@ -29,12 +34,11 @@ class ServiceConnection(private val context: Context, callback: Callback, privat val status get() = service?.status?.let { Status.values()[it] } ?: Status.Stopped fun connect() { - val intent = - runBlocking { - withContext(Dispatchers.IO) { - Intent(context, Settings.serviceClass()).setAction(Action.SERVICE) - } + val intent = runBlocking { + withContext(Dispatchers.IO) { + Intent(context, Settings.serviceClass()).setAction(Action.SERVICE) } + } context.bindService(intent, this, AppCompatActivity.BIND_AUTO_CREATE) Log.d(TAG, "request connect") } @@ -52,12 +56,11 @@ class ServiceConnection(private val context: Context, callback: Callback, privat context.unbindService(this) } catch (_: IllegalArgumentException) { } - val intent = - runBlocking { - withContext(Dispatchers.IO) { - Intent(context, Settings.serviceClass()).setAction(Action.SERVICE) - } + val intent = runBlocking { + withContext(Dispatchers.IO) { + Intent(context, Settings.serviceClass()).setAction(Action.SERVICE) } + } context.bindService(intent, this, AppCompatActivity.BIND_AUTO_CREATE) Log.d(TAG, "request reconnect") } @@ -90,9 +93,7 @@ class ServiceConnection(private val context: Context, callback: Callback, privat interface Callback { fun onServiceStatusChanged(status: Status) - - fun onServiceAlert(type: Alert, message: String?) { - } + fun onServiceAlert(type: Alert, message: String?) {} } class ServiceCallback(private val callback: Callback) : IServiceCallback.Stub() { @@ -104,4 +105,4 @@ class ServiceConnection(private val context: Context, callback: Callback, privat callback.onServiceAlert(Alert.values()[type], message) } } -} +} \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/ServiceNotification.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/ServiceNotification.kt index bd1d7fb4ce..7a8b0f065d 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/ServiceNotification.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/ServiceNotification.kt @@ -17,19 +17,19 @@ import io.nekohasekai.libbox.Libbox import io.nekohasekai.libbox.StatusMessage import io.nekohasekai.sfa.Application import io.nekohasekai.sfa.R -import io.nekohasekai.sfa.compose.MainActivity import io.nekohasekai.sfa.constant.Action import io.nekohasekai.sfa.constant.Status import io.nekohasekai.sfa.database.Settings +import io.nekohasekai.sfa.ui.MainActivity import io.nekohasekai.sfa.utils.CommandClient import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.withContext -class ServiceNotification(private val status: MutableLiveData, private val service: Service) : - BroadcastReceiver(), - CommandClient.Handler { +class ServiceNotification( + private val status: MutableLiveData, private val service: Service +) : BroadcastReceiver(), CommandClient.Handler { companion object { private const val notificationId = 1 private const val notificationChannel = "service" @@ -60,23 +60,21 @@ class ServiceNotification(private val status: MutableLiveData, private v 0, Intent( service, - MainActivity::class.java, + MainActivity::class.java ).setFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT), - flags, - ), + flags + ) ) .setPriority(NotificationCompat.PRIORITY_LOW).apply { addAction( NotificationCompat.Action.Builder( - 0, - service.getText(R.string.stop), - PendingIntent.getBroadcast( + 0, service.getText(R.string.stop), PendingIntent.getBroadcast( service, 0, Intent(Action.SERVICE_CLOSE).setPackage(service.packageName), - flags, - ), - ).build(), + flags + ) + ).build() ) } } @@ -85,17 +83,14 @@ class ServiceNotification(private val status: MutableLiveData, private v if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { Application.notification.createNotificationChannel( NotificationChannel( - notificationChannel, - "Service Notifications", - NotificationManager.IMPORTANCE_LOW, - ), + notificationChannel, "Service Notifications", NotificationManager.IMPORTANCE_LOW + ) ) } service.startForeground( - notificationId, - notificationBuilder + notificationId, notificationBuilder .setContentTitle(lastProfileName.takeIf { it.isNotBlank() } ?: "sing-box") - .setContentText(service.getString(contentTextId)).build(), + .setContentText(service.getString(contentTextId)).build() ) } @@ -109,13 +104,10 @@ class ServiceNotification(private val status: MutableLiveData, private v } private fun registerReceiver() { - service.registerReceiver( - this, - IntentFilter().apply { - addAction(Intent.ACTION_SCREEN_ON) - addAction(Intent.ACTION_SCREEN_OFF) - }, - ) + service.registerReceiver(this, IntentFilter().apply { + addAction(Intent.ACTION_SCREEN_ON) + addAction(Intent.ACTION_SCREEN_OFF) + }) receiverRegistered = true } @@ -124,7 +116,7 @@ class ServiceNotification(private val status: MutableLiveData, private v Libbox.formatBytes(status.uplink) + "/s ↑\t" + Libbox.formatBytes(status.downlink) + "/s ↓" Application.notificationManager.notify( notificationId, - notificationBuilder.setContentText(content).build(), + notificationBuilder.setContentText(content).build() ) } @@ -148,4 +140,4 @@ class ServiceNotification(private val status: MutableLiveData, private v receiverRegistered = false } } -} +} \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/TileService.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/TileService.kt index 3b96002cd7..30fa9302d7 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/TileService.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/TileService.kt @@ -8,19 +8,17 @@ import androidx.annotation.RequiresApi import io.nekohasekai.sfa.constant.Status @RequiresApi(24) -class TileService : - TileService(), - ServiceConnection.Callback { +class TileService : TileService(), ServiceConnection.Callback { + private val connection = ServiceConnection(this, this) override fun onServiceStatusChanged(status: Status) { qsTile?.apply { - state = - when (status) { - Status.Started -> Tile.STATE_ACTIVE - Status.Stopped -> Tile.STATE_INACTIVE - else -> Tile.STATE_UNAVAILABLE - } + state = when (status) { + Status.Started -> Tile.STATE_ACTIVE + Status.Stopped -> Tile.STATE_INACTIVE + else -> Tile.STATE_UNAVAILABLE + } updateTile() } } @@ -53,4 +51,5 @@ class TileService : else -> {} } } -} + +} \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/UpdateProfileWork.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/UpdateProfileWork.kt index 8f4969423f..9dee852fdb 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/UpdateProfileWork.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/UpdateProfileWork.kt @@ -19,6 +19,7 @@ import java.util.Date import java.util.concurrent.TimeUnit class UpdateProfileWork { + companion object { private const val WORK_NAME = "UpdateProfile" private const val TAG = "UpdateProfileWork" @@ -32,9 +33,8 @@ class UpdateProfileWork { } private suspend fun reconfigureUpdater0() { - val remoteProfiles = - ProfileManager.list() - .filter { it.typed.type == TypedProfile.Type.Remote && it.typed.autoUpdate } + val remoteProfiles = ProfileManager.list() + .filter { it.typed.type == TypedProfile.Type.Remote && it.typed.autoUpdate } if (remoteProfiles.isEmpty()) { WorkManager.getInstance(Application.application).cancelUniqueWork(WORK_NAME) return @@ -54,17 +54,19 @@ class UpdateProfileWork { if (minInitDelay > 0) setInitialDelay(minInitDelay, TimeUnit.SECONDS) setBackoffCriteria(BackoffPolicy.LINEAR, 15, TimeUnit.MINUTES) } - .build(), + .build() ) } + } - class UpdateTask(appContext: Context, params: WorkerParameters) : CoroutineWorker(appContext, params) { + class UpdateTask( + appContext: Context, params: WorkerParameters + ) : CoroutineWorker(appContext, params) { override suspend fun doWork(): Result { var selectedProfileUpdated = false - val remoteProfiles = - ProfileManager.list() - .filter { it.typed.type == TypedProfile.Type.Remote && it.typed.autoUpdate } + val remoteProfiles = ProfileManager.list() + .filter { it.typed.type == TypedProfile.Type.Remote && it.typed.autoUpdate } if (remoteProfiles.isEmpty()) return Result.success() var success = true val selectedProfile = Settings.selectedProfile @@ -102,5 +104,8 @@ class UpdateProfileWork { Result.retry() } } + } -} + + +} \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/VPNService.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/VPNService.kt index d9581844dd..4973c701c8 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/VPNService.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/VPNService.kt @@ -6,7 +6,6 @@ import android.net.ProxyInfo import android.net.VpnService import android.os.Build import android.os.IBinder -import android.util.Log import io.nekohasekai.libbox.Notification import io.nekohasekai.libbox.TunOptions import io.nekohasekai.sfa.database.Settings @@ -16,16 +15,16 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext -class VPNService : - VpnService(), - PlatformInterfaceWrapper { +class VPNService : VpnService(), PlatformInterfaceWrapper { + companion object { private const val TAG = "VPNService" } private val service = BoxService(this, this) - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int) = service.onStartCommand() + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int) = + service.onStartCommand() override fun onBind(intent: Intent): IBinder { val binder = super.onBind(intent) @@ -57,10 +56,9 @@ class VPNService : override fun openTun(options: TunOptions): Int { if (prepare(this) != null) error("android: missing vpn permission") - val builder = - Builder() - .setSession("sing-box") - .setMtu(options.mtu) + val builder = Builder() + .setSession("sing-box") + .setMtu(options.mtu) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { builder.setMetered(false) @@ -127,28 +125,42 @@ class VPNService : } } - val includePackage = options.includePackage - if (includePackage.hasNext()) { - while (includePackage.hasNext()) { - try { - val nextPackage = includePackage.next() - builder.addAllowedApplication(nextPackage) - Log.d("VPNService", "addAllowedApplication: $nextPackage") - } catch (e: NameNotFoundException) { - Log.e("VPNService", "addAllowedApplication failed", e) + if (Settings.perAppProxyEnabled) { + val appList = Settings.perAppProxyList + if (Settings.perAppProxyMode == Settings.PER_APP_PROXY_INCLUDE) { + appList.forEach { + try { + builder.addAllowedApplication(it) + } catch (_: NameNotFoundException) { + } + } + builder.addAllowedApplication(packageName) + } else { + appList.forEach { + try { + builder.addDisallowedApplication(it) + } catch (_: NameNotFoundException) { + } + } + } + } else { + val includePackage = options.includePackage + if (includePackage.hasNext()) { + while (includePackage.hasNext()) { + try { + builder.addAllowedApplication(includePackage.next()) + } catch (_: NameNotFoundException) { + } } } - } - val excludePackage = options.excludePackage - if (excludePackage.hasNext()) { - while (excludePackage.hasNext()) { - try { - val nextPackage = excludePackage.next() - builder.addDisallowedApplication(nextPackage) - Log.d("VPNService", "addDisallowedApplication: $nextPackage") - } catch (e: NameNotFoundException) { - Log.e("VPNService", "addDisallowedApplication failed", e) + val excludePackage = options.excludePackage + if (excludePackage.hasNext()) { + while (excludePackage.hasNext()) { + try { + builder.addDisallowedApplication(excludePackage.next()) + } catch (_: NameNotFoundException) { + } } } } @@ -157,15 +169,13 @@ class VPNService : if (options.isHTTPProxyEnabled && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { systemProxyAvailable = true systemProxyEnabled = Settings.systemProxyEnabled - if (systemProxyEnabled) { - builder.setHttpProxy( - ProxyInfo.buildDirectProxy( - options.httpProxyServer, - options.httpProxyServerPort, - options.httpProxyBypassDomain.toList(), - ), + if (systemProxyEnabled) builder.setHttpProxy( + ProxyInfo.buildDirectProxy( + options.httpProxyServer, + options.httpProxyServerPort, + options.httpProxyBypassDomain.toList() ) - } + ) } else { systemProxyAvailable = false systemProxyEnabled = false @@ -177,5 +187,9 @@ class VPNService : return pfd.fd } - override fun sendNotification(notification: Notification) = service.sendNotification(notification) -} + override fun writeLog(message: String) = service.writeLog(message) + + override fun sendNotification(notification: Notification) = + service.sendNotification(notification) + +} \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/LineChart.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/LineChart.kt deleted file mode 100644 index 867de45fb2..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/LineChart.kt +++ /dev/null @@ -1,131 +0,0 @@ -package io.nekohasekai.sfa.compose - -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.tween -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.material3.MaterialTheme -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.graphics.Color -import androidx.compose.ui.graphics.Path -import androidx.compose.ui.graphics.PathEffect -import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.graphics.StrokeJoin -import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.unit.dp -import kotlin.math.max - -@Composable -fun LineChart( - data: List, - modifier: Modifier = Modifier, - lineColor: Color = MaterialTheme.colorScheme.primary, - gridColor: Color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), - animate: Boolean = true, -) { - val animationProgress = remember { Animatable(if (animate) 0f else 1f) } - - LaunchedEffect(data) { - if (animate) { - animationProgress.animateTo( - targetValue = 1f, - animationSpec = tween(durationMillis = 300), - ) - } - } - - Canvas( - modifier = - modifier - .fillMaxWidth() - .height(80.dp), - ) { - val width = size.width - val height = size.height - val maxValue = max(data.maxOrNull() ?: 1f, 1f) * 1.2f // Add 20% padding - val pointCount = data.size - - // Draw horizontal grid lines - val gridLineCount = 3 - for (i in 0..gridLineCount) { - val y = height * i / gridLineCount - drawLine( - color = gridColor, - start = Offset(0f, y), - end = Offset(width, y), - strokeWidth = 1.dp.toPx(), - pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 10f), 0f), - ) - } - - if (pointCount > 1) { - val path = Path() - val spacing = width / (pointCount - 1).toFloat() - - // Calculate points - val points = - data.mapIndexed { index, value -> - val x = index * spacing - val normalizedValue = (value / maxValue).coerceIn(0f, 1f) - val y = height * (1 - normalizedValue) - Offset(x, y) - } - - // Build the path - path.moveTo(points[0].x, points[0].y) - for (i in 1 until points.size) { - val progress = if (animate) animationProgress.value else 1f - val pointIndex = (i * progress).toInt().coerceAtMost(points.size - 1) - - if (i <= pointIndex) { - val prev = points[i - 1] - val current = points[i] - - // Simple line connection - path.lineTo(current.x, current.y) - } - } - - // Draw the line - drawPath( - path = path, - color = lineColor, - style = - Stroke( - width = 2.dp.toPx(), - cap = StrokeCap.Round, - join = StrokeJoin.Round, - ), - ) - - // Draw gradient fill under the line - val fillPath = Path() - fillPath.addPath(path) - - // Complete the fill area - if (points.isNotEmpty()) { - val progressIndex = ((points.size - 1) * animationProgress.value).toInt() - val lastPoint = - if (progressIndex >= 0 && progressIndex < points.size) { - points[progressIndex] - } else { - points.last() - } - - fillPath.lineTo(lastPoint.x, height) - fillPath.lineTo(0f, height) - fillPath.lineTo(points[0].x, points[0].y) - - drawPath( - path = fillPath, - color = lineColor.copy(alpha = 0.1f), - ) - } - } - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/MainActivity.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/MainActivity.kt deleted file mode 100644 index 574422ba29..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/MainActivity.kt +++ /dev/null @@ -1,1244 +0,0 @@ -package io.nekohasekai.sfa.compose - -import android.Manifest -import android.annotation.SuppressLint -import android.content.ContentResolver -import android.content.Intent -import android.net.Uri -import android.net.VpnService -import android.os.Build -import android.os.Bundle -import androidx.activity.compose.setContent -import androidx.activity.enableEdgeToEdge -import androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.app.AppCompatActivity -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.scaleIn -import androidx.compose.animation.scaleOut -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.fillMaxHeight -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.material.icons.Icons -import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material.icons.filled.Stop -import androidx.compose.material.icons.filled.UnfoldLess -import androidx.compose.material.icons.filled.UnfoldMore -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Badge -import androidx.compose.material3.BadgedBox -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExtendedFloatingActionButton -import androidx.compose.material3.FloatingActionButton -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.NavigationBar -import androidx.compose.material3.NavigationBarItem -import androidx.compose.material3.NavigationRail -import androidx.compose.material3.NavigationRailItem -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -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.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.core.content.ContextCompat -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.viewmodel.compose.viewModel -import androidx.navigation.NavDestination.Companion.hierarchy -import androidx.navigation.NavGraph.Companion.findStartDestination -import androidx.navigation.compose.currentBackStackEntryAsState -import androidx.navigation.compose.rememberNavController -import dev.jeziellago.compose.markdowntext.MarkdownText -import io.nekohasekai.libbox.Libbox -import io.nekohasekai.sfa.Application -import io.nekohasekai.sfa.BuildConfig -import io.nekohasekai.sfa.R -import io.nekohasekai.sfa.bg.ServiceConnection -import io.nekohasekai.sfa.bg.ServiceNotification -import io.nekohasekai.sfa.compat.WindowSizeClassCompat -import io.nekohasekai.sfa.compat.isWidthAtLeastBreakpointCompat -import io.nekohasekai.sfa.compose.base.GlobalEventBus -import io.nekohasekai.sfa.compose.base.SelectableMessageDialog -import io.nekohasekai.sfa.compose.base.UiEvent -import io.nekohasekai.sfa.compose.component.ServiceStatusBar -import io.nekohasekai.sfa.compose.component.UpdateAvailableDialog -import io.nekohasekai.sfa.compose.component.UptimeText -import io.nekohasekai.sfa.compose.model.Connection -import io.nekohasekai.sfa.compose.navigation.NewProfileArgs -import io.nekohasekai.sfa.compose.navigation.ProfileRoutes -import io.nekohasekai.sfa.compose.navigation.SFANavHost -import io.nekohasekai.sfa.compose.navigation.Screen -import io.nekohasekai.sfa.compose.navigation.bottomNavigationScreens -import io.nekohasekai.sfa.compose.screen.configuration.ProfileImportHandler -import io.nekohasekai.sfa.compose.screen.connections.ConnectionDetailsScreen -import io.nekohasekai.sfa.compose.screen.connections.ConnectionsPage -import io.nekohasekai.sfa.compose.screen.connections.ConnectionsViewModel -import io.nekohasekai.sfa.compose.screen.dashboard.DashboardViewModel -import io.nekohasekai.sfa.compose.screen.dashboard.GroupsCard -import io.nekohasekai.sfa.compose.screen.dashboard.groups.GroupsViewModel -import io.nekohasekai.sfa.compose.screen.log.LogViewModel -import io.nekohasekai.sfa.compose.theme.SFATheme -import io.nekohasekai.sfa.compose.topbar.LocalTopBarController -import io.nekohasekai.sfa.compose.topbar.TopBarController -import io.nekohasekai.sfa.compose.topbar.TopBarEntry -import io.nekohasekai.sfa.constant.Alert -import io.nekohasekai.sfa.constant.ServiceMode -import io.nekohasekai.sfa.constant.Status -import io.nekohasekai.sfa.database.Settings -import io.nekohasekai.sfa.ktx.hasPermission -import io.nekohasekai.sfa.ktx.launchCustomTab -import io.nekohasekai.sfa.update.UpdateState -import io.nekohasekai.sfa.vendor.Vendor -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -class MainActivity : - AppCompatActivity(), - ServiceConnection.Callback { - private val connection = ServiceConnection(this, this) - private lateinit var dashboardViewModel: DashboardViewModel - private var currentServiceStatus by mutableStateOf(Status.Stopped) - private var currentAlert by mutableStateOf?>(null) - private var showLocationPermissionDialog by mutableStateOf(false) - private var showBackgroundLocationDialog by mutableStateOf(false) - private var showImportProfileDialog by mutableStateOf(false) - private var pendingImportProfile by mutableStateOf?>(null) - private var showImportLocalProfileDialog by mutableStateOf(false) - private var pendingImportLocalProfileName by mutableStateOf(null) - private var pendingImportLocalProfileUri by mutableStateOf(null) - private var newProfileArgs by mutableStateOf(NewProfileArgs()) - private var parseImportLocalProfileJob: Job? = null - private var pendingIntentErrorMessage by mutableStateOf(null) - - private val notificationPermissionLauncher = - registerForActivityResult( - ActivityResultContracts.RequestPermission(), - ) { isGranted -> - if (Settings.dynamicNotification && !isGranted) { - onServiceAlert(Alert.RequestNotificationPermission, null) - } else { - startService0() - } - } - - private val locationPermissionLauncher = - registerForActivityResult(ActivityResultContracts.RequestPermission()) { - if (it) { - if (it && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - requestBackgroundLocationPermission() - } else { - startService() - } - } - } - - private val backgroundLocationPermissionLauncher = - registerForActivityResult(ActivityResultContracts.RequestPermission()) { - if (it) { - startService() - } - } - - private val prepareLauncher = - registerForActivityResult( - ActivityResultContracts.StartActivityForResult(), - ) { result -> - if (result.resultCode == RESULT_OK) { - startService0() - } else { - onServiceAlert(Alert.RequestVPNPermission, null) - } - } - private val pendingNavigationRoute = mutableStateOf(null) - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - enableEdgeToEdge() - - connection.reconnect() - - UpdateState.loadFromCache() - if (Settings.checkUpdateEnabled) { - lifecycleScope.launch(Dispatchers.IO) { - try { - val updateInfo = Vendor.checkUpdateAsync() - UpdateState.setUpdate(updateInfo) - } catch (_: Exception) { - UpdateState.setUpdate(null) - } - } - } - - handleIntent(intent) - - setContent { - SFATheme { - SFAApp() - } - } - } - - override fun onNewIntent(intent: Intent) { - super.onNewIntent(intent) - handleIntent(intent) - } - - private fun handleIntent(intent: Intent?) { - if (intent == null) { - return - } - if (intent.categories?.contains("de.robv.android.xposed.category.MODULE_SETTINGS") == true) { - pendingNavigationRoute.value = "settings/privilege" - } - val uri = intent.data ?: return - if (uri.scheme == "sing-box" && uri.host == "import-remote-profile") { - try { - val profile = Libbox.parseRemoteProfileImportLink(uri.toString()) - pendingImportProfile = Triple(profile.name, profile.host, profile.url) - showImportProfileDialog = true - } catch (e: Exception) { - pendingIntentErrorMessage = e.message ?: "Failed to parse profile link" - } - return - } - - if (intent.action == Intent.ACTION_VIEW && - (uri.scheme == ContentResolver.SCHEME_CONTENT || uri.scheme == ContentResolver.SCHEME_FILE) - ) { - parseImportLocalProfileJob?.cancel() - parseImportLocalProfileJob = - lifecycleScope.launch(Dispatchers.IO) { - val importHandler = ProfileImportHandler(this@MainActivity) - when (val result = importHandler.parseUri(uri)) { - is ProfileImportHandler.UriParseResult.Success -> { - withContext(Dispatchers.Main) { - pendingImportLocalProfileName = result.name - pendingImportLocalProfileUri = uri - showImportLocalProfileDialog = true - } - } - - is ProfileImportHandler.UriParseResult.Error -> { - withContext(Dispatchers.Main) { - pendingIntentErrorMessage = result.message - } - } - } - } - } - } - - @SuppressLint("NewApi") - fun startService() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !ServiceNotification.checkPermission()) { - notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) - return - } - startService0() - } - - private fun startService0() { - lifecycleScope.launch(Dispatchers.IO) { - if (Settings.rebuildServiceMode()) { - connection.reconnect() - } - if (Settings.serviceMode == ServiceMode.VPN) { - if (prepare()) { - return@launch - } - } - val intent = Intent(Application.application, Settings.serviceClass()) - withContext(Dispatchers.Main) { - ContextCompat.startForegroundService(this@MainActivity, intent) - } - Settings.startedByUser = true - } - } - - private suspend fun prepare() = withContext(Dispatchers.Main) { - try { - val intent = VpnService.prepare(this@MainActivity) - if (intent != null) { - prepareLauncher.launch(intent) - true - } else { - false - } - } catch (e: Exception) { - onServiceAlert(Alert.RequestVPNPermission, e.message) - true - } - } - - @OptIn(ExperimentalMaterial3Api::class) - @Composable - fun SFAApp() { - val navController = rememberNavController() - val navBackStackEntry by navController.currentBackStackEntryAsState() - val currentDestination = navBackStackEntry?.destination - val currentRoute = currentDestination?.route - val scope = rememberCoroutineScope() - val importHandler = remember { ProfileImportHandler(this@MainActivity) } - - val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass - val useNavigationRail = - windowSizeClass.isWidthAtLeastBreakpointCompat(WindowSizeClassCompat.WIDTH_DP_MEDIUM_LOWER_BOUND) - - // Snackbar state - val snackbarHostState = remember { SnackbarHostState() } - - // Groups Sheet state - var showGroupsSheet by remember { mutableStateOf(false) } - - // Connections Sheet state - var showConnectionsSheet by remember { mutableStateOf(false) } - - // Error dialog state for UiEvent.ShowError - var showErrorDialog by remember { mutableStateOf(false) } - var errorMessage by remember { mutableStateOf("") } - val pendingIntentError = pendingIntentErrorMessage - LaunchedEffect(pendingIntentError) { - if (pendingIntentError != null) { - errorMessage = pendingIntentError - showErrorDialog = true - pendingIntentErrorMessage = null - } - } - val topBarState = remember { mutableStateOf(emptyList()) } - val topBarController = remember { TopBarController(topBarState) } - val topBarOverride = topBarState.value.lastOrNull()?.content - val openNewProfile: (NewProfileArgs) -> Unit = { args -> - newProfileArgs = args - navController.navigate(ProfileRoutes.NewProfile) { - launchSingleTop = true - } - } - - // Handle service alerts - currentAlert?.let { (alertType, message) -> - ServiceAlertDialog( - alertType = alertType, - message = message, - onDismiss = { currentAlert = null }, - ) - } - - // Handle UiEvent.ShowError dialog - if (showErrorDialog) { - SelectableMessageDialog( - title = stringResource(R.string.error_title), - message = errorMessage, - onDismiss = { showErrorDialog = false }, - ) - } - - // Handle location permission dialogs - if (showLocationPermissionDialog) { - LocationPermissionDialog(onConfirm = { - showLocationPermissionDialog = false - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - locationPermissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION) - } - }, onDismiss = { showLocationPermissionDialog = false }) - } - - if (showBackgroundLocationDialog) { - BackgroundLocationPermissionDialog(onConfirm = { - showBackgroundLocationDialog = false - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - backgroundLocationPermissionLauncher.launch(Manifest.permission.ACCESS_BACKGROUND_LOCATION) - } - }, onDismiss = { showBackgroundLocationDialog = false }) - } - - // Handle import remote profile dialog - if (showImportProfileDialog && pendingImportProfile != null) { - val (name, host, url) = pendingImportProfile!! - AlertDialog( - onDismissRequest = { - showImportProfileDialog = false - pendingImportProfile = null - }, - title = { Text(stringResource(R.string.import_remote_profile)) }, - text = { Text(stringResource(R.string.import_remote_profile_message, name, host)) }, - confirmButton = { - TextButton(onClick = { - openNewProfile( - NewProfileArgs( - importName = name, - importUrl = url, - ), - ) - showImportProfileDialog = false - pendingImportProfile = null - }) { - Text(stringResource(R.string.ok)) - } - }, - dismissButton = { - TextButton(onClick = { - showImportProfileDialog = false - pendingImportProfile = null - }) { - Text(stringResource(android.R.string.cancel)) - } - }, - ) - } - - if (showImportLocalProfileDialog && pendingImportLocalProfileUri != null && pendingImportLocalProfileName != null) { - val importName = pendingImportLocalProfileName!! - val importUri = pendingImportLocalProfileUri!! - AlertDialog( - onDismissRequest = { - showImportLocalProfileDialog = false - pendingImportLocalProfileName = null - pendingImportLocalProfileUri = null - }, - title = { Text(stringResource(R.string.import_profile_confirm_title)) }, - text = { Text(stringResource(R.string.import_profile_confirm_message, importName)) }, - confirmButton = { - TextButton(onClick = { - showImportLocalProfileDialog = false - pendingImportLocalProfileName = null - pendingImportLocalProfileUri = null - scope.launch { - when (val result = importHandler.importFromUri(importUri)) { - is ProfileImportHandler.ImportResult.Success -> { - navController.navigate(ProfileRoutes.editProfile(result.profile.id)) { - launchSingleTop = true - } - } - is ProfileImportHandler.ImportResult.Error -> { - errorMessage = result.message - showErrorDialog = true - } - } - } - }) { - Text(stringResource(R.string.import_action)) - } - }, - dismissButton = { - TextButton(onClick = { - showImportLocalProfileDialog = false - pendingImportLocalProfileName = null - pendingImportLocalProfileUri = null - }) { - Text(stringResource(R.string.cancel)) - } - }, - ) - } - - // Handle update check prompt dialog (shown only once on first launch) - var showUpdateCheckPrompt by remember { mutableStateOf(!Settings.updateCheckPrompted) } - if (showUpdateCheckPrompt) { - AlertDialog( - onDismissRequest = { - Settings.updateCheckPrompted = true - showUpdateCheckPrompt = false - }, - title = { Text(stringResource(R.string.check_update)) }, - text = { - MarkdownText( - markdown = stringResource( - if (BuildConfig.FLAVOR == "play") { - R.string.check_update_prompt_play - } else { - R.string.check_update_prompt_github - }, - ), - style = MaterialTheme.typography.bodyMedium, - ) - }, - confirmButton = { - TextButton(onClick = { - Settings.updateCheckPrompted = true - Settings.checkUpdateEnabled = true - showUpdateCheckPrompt = false - scope.launch(Dispatchers.IO) { - try { - val result = Vendor.checkUpdateAsync() - UpdateState.setUpdate(result) - } catch (_: Exception) { - UpdateState.setUpdate(null) - } - } - }) { - Text(stringResource(R.string.ok)) - } - }, - dismissButton = { - TextButton(onClick = { - Settings.updateCheckPrompted = true - showUpdateCheckPrompt = false - }) { - Text(stringResource(R.string.no_thanks)) - } - }, - ) - } - - // Handle update available dialog - val updateInfo by UpdateState.updateInfo - val shouldShowUpdateDialog = updateInfo != null && - updateInfo!!.versionCode > Settings.lastShownUpdateVersion - var showUpdateDialog by remember { mutableStateOf(true) } - - // Download dialog state - var showDownloadDialog by remember { mutableStateOf(false) } - var downloadJob by remember { mutableStateOf(null) } - var downloadError by remember { mutableStateOf(null) } - - if (showUpdateDialog && shouldShowUpdateDialog) { - UpdateAvailableDialog( - updateInfo = updateInfo!!, - onDismiss = { - Settings.lastShownUpdateVersion = updateInfo!!.versionCode - showUpdateDialog = false - }, - onUpdate = { - showDownloadDialog = true - downloadError = null - downloadJob = scope.launch { - try { - withContext(Dispatchers.IO) { - Vendor.downloadAndInstall( - this@MainActivity, - updateInfo!!.downloadUrl, - ) - } - showDownloadDialog = false - } catch (e: Exception) { - downloadError = e.message - } - } - }, - ) - } - - // Download progress dialog - if (showDownloadDialog) { - AlertDialog( - onDismissRequest = {}, - title = { Text(stringResource(R.string.update)) }, - text = { - Column { - if (downloadError != null) { - Text( - downloadError!!, - color = MaterialTheme.colorScheme.error, - ) - } else { - Row(verticalAlignment = Alignment.CenterVertically) { - CircularProgressIndicator(modifier = Modifier.size(24.dp)) - Spacer(modifier = Modifier.width(12.dp)) - Text(stringResource(R.string.downloading)) - } - } - } - }, - confirmButton = { - TextButton( - onClick = { - downloadJob?.cancel() - downloadJob = null - showDownloadDialog = false - downloadError = null - }, - ) { - Text(stringResource(if (downloadError != null) R.string.ok else android.R.string.cancel)) - } - }, - ) - } - - // Initialize the dashboard view model and store reference - val dashboardViewModel: DashboardViewModel = viewModel() - if (!::dashboardViewModel.isInitialized) { - this.dashboardViewModel = dashboardViewModel - } - val dashboardUiState by dashboardViewModel.uiState.collectAsState() - - val isSettingsSubScreen = currentRoute?.startsWith("settings/") == true - val isConnectionsDetail = currentRoute?.startsWith("connections/detail") == true - val isProfileRoute = currentRoute?.startsWith("profile/") == true - val currentRootRoute = - when { - isSettingsSubScreen -> Screen.Settings.route - currentRoute?.startsWith(Screen.Connections.route) == true -> Screen.Connections.route - currentRoute?.startsWith(Screen.Log.route) == true -> Screen.Log.route - isProfileRoute -> Screen.Dashboard.route - else -> currentRoute - } - val isConnectionsRoute = currentRootRoute == Screen.Connections.route - val isGroupsRoute = currentRootRoute == Screen.Groups.route - val isLogRoute = currentRootRoute == Screen.Log.route - - val isSubScreen = isSettingsSubScreen || isConnectionsDetail || isProfileRoute - // Get LogViewModel instance if we're on the Log screen - val logViewModel: LogViewModel? = - if (isLogRoute) { - viewModel() - } else { - null - } - - val groupsViewModel: GroupsViewModel? = - if (isGroupsRoute) { - viewModel( - factory = object : ViewModelProvider.Factory { - override fun create(modelClass: Class): T { - @Suppress("UNCHECKED_CAST") - return GroupsViewModel(dashboardViewModel.commandClient) as T - } - }, - ) - } else { - null - } - - val connectionsViewModel: ConnectionsViewModel? = - if (isConnectionsRoute) { - viewModel() - } else { - null - } - - val showGroupsInNav = dashboardUiState.hasGroups - val showConnectionsInNav = - currentServiceStatus == Status.Started || currentServiceStatus == Status.Starting - - val railScreens = - buildList { - add(Screen.Dashboard) - if (showGroupsInNav) { - add(Screen.Groups) - } - if (showConnectionsInNav) { - add(Screen.Connections) - } - add(Screen.Log) - add(Screen.Settings) - } - - val allowedRoutes = - buildSet { - add(Screen.Dashboard.route) - add(Screen.Log.route) - add(Screen.Settings.route) - if (useNavigationRail && showGroupsInNav) { - add(Screen.Groups.route) - } - if (useNavigationRail && showConnectionsInNav) { - add(Screen.Connections.route) - } - } - - val pendingRoute = pendingNavigationRoute.value - LaunchedEffect(pendingRoute) { - if (pendingRoute != null) { - navController.navigate(pendingRoute) { - launchSingleTop = true - } - pendingNavigationRoute.value = null - } - } - - LaunchedEffect(allowedRoutes, currentRootRoute, useNavigationRail) { - if (currentRootRoute != null && !allowedRoutes.contains(currentRootRoute)) { - navController.navigate(Screen.Dashboard.route) { - popUpTo(navController.graph.findStartDestination().id) { - saveState = true - } - launchSingleTop = true - restoreState = true - } - } - } - - // Collect all UI events from GlobalEventBus - LaunchedEffect(Unit) { - GlobalEventBus.events.collect { event -> - when (event) { - is UiEvent.ErrorMessage -> { - errorMessage = event.message - showErrorDialog = true - } - - is UiEvent.OpenUrl -> { - this@MainActivity.launchCustomTab(event.url) - } - - is UiEvent.RequestStartService -> { - startService() - } - - is UiEvent.RequestReconnectService -> { - connection.reconnect() - } - - is UiEvent.EditProfile -> { - navController.navigate(ProfileRoutes.editProfile(event.profileId)) { - launchSingleTop = true - } - } - - is UiEvent.RestartToTakeEffect -> { - if (currentServiceStatus == Status.Started) { - scope.launch { - snackbarHostState.currentSnackbarData?.dismiss() - val result = - snackbarHostState.showSnackbar( - message = "Restart to take effect", - actionLabel = "Restart", - duration = androidx.compose.material3.SnackbarDuration.Short, - ) - if (result == androidx.compose.material3.SnackbarResult.ActionPerformed) { - withContext(Dispatchers.IO) { - Libbox.newStandaloneCommandClient().serviceReload() - } - } - } - } - } - } - } - } - - val topBarContent: @Composable () -> Unit = { - topBarOverride?.invoke() - } - - val scaffoldContent: @Composable (PaddingValues) -> Unit = { paddingValues -> - Box( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues), - ) { - // Service Status Bar (shown when service is running or stopping) - val serviceRunning = - currentServiceStatus == Status.Started || currentServiceStatus == Status.Starting - val showStatusBar = serviceRunning || currentServiceStatus == Status.Stopping - val showStartFab = !serviceRunning && dashboardUiState.selectedProfileId != -1L - - SFANavHost( - navController = navController, - serviceStatus = currentServiceStatus, - showStartFab = showStartFab, - showStatusBar = showStatusBar, - newProfileArgs = newProfileArgs, - onClearNewProfileArgs = { newProfileArgs = NewProfileArgs() }, - onOpenNewProfile = openNewProfile, - dashboardViewModel = dashboardViewModel, - logViewModel = logViewModel, - groupsViewModel = groupsViewModel, - connectionsViewModel = connectionsViewModel, - modifier = Modifier.fillMaxSize(), - ) - if (!useNavigationRail) { - ServiceStatusBar( - visible = showStatusBar && !isSubScreen, - serviceStatus = currentServiceStatus, - startTime = dashboardUiState.serviceStartTime, - groupsCount = dashboardUiState.groupsCount, - hasGroups = dashboardUiState.hasGroups, - onGroupsClick = { showGroupsSheet = true }, - connectionsCount = dashboardUiState.connectionsCount, - onConnectionsClick = { showConnectionsSheet = true }, - onStopClick = { dashboardViewModel.toggleService() }, - modifier = Modifier.align(Alignment.BottomCenter), - ) - } - - val showPadFab = useNavigationRail && !isSubScreen && (showStartFab || showStatusBar) - if (useNavigationRail) { - androidx.compose.animation.AnimatedVisibility( - visible = showPadFab, - enter = scaleIn(), - exit = scaleOut(), - modifier = Modifier - .align(Alignment.BottomEnd) - .padding(20.dp), - ) { - val isRunning = - currentServiceStatus == Status.Started || currentServiceStatus == Status.Starting - val isStopping = currentServiceStatus == Status.Stopping - if (currentServiceStatus == Status.Stopped) { - FloatingActionButton( - onClick = { startService() }, - containerColor = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onPrimaryContainer, - ) { - Icon( - imageVector = Icons.Default.PlayArrow, - contentDescription = stringResource(R.string.action_start), - ) - } - } else { - ExtendedFloatingActionButton( - onClick = { - if (isRunning || isStopping) { - dashboardViewModel.toggleService() - } else { - startService() - } - }, - icon = { - Icon( - imageVector = - if (isRunning || isStopping) { - Icons.Default.Stop - } else { - Icons.Default.PlayArrow - }, - contentDescription = - if (isRunning || isStopping) { - stringResource(R.string.stop) - } else { - stringResource(R.string.action_start) - }, - ) - }, - text = { - when { - isRunning && dashboardUiState.serviceStartTime != null -> { - UptimeText(startTime = dashboardUiState.serviceStartTime!!) - } - currentServiceStatus == Status.Started -> { - Text( - text = stringResource(R.string.status_started), - style = MaterialTheme.typography.labelLarge, - ) - } - currentServiceStatus == Status.Starting -> { - Text( - text = stringResource(R.string.status_starting), - style = MaterialTheme.typography.labelLarge, - ) - } - currentServiceStatus == Status.Stopping -> { - Text( - text = stringResource(R.string.status_stopping), - style = MaterialTheme.typography.labelLarge, - ) - } - else -> { - Text( - text = stringResource(R.string.action_start), - style = MaterialTheme.typography.labelLarge, - ) - } - } - }, - containerColor = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onPrimaryContainer, - modifier = Modifier.height(64.dp), - ) - } - } - } else { - // Start FAB (shown when service is stopped and a profile is selected) - androidx.compose.animation.AnimatedVisibility( - visible = currentServiceStatus == Status.Stopped && - dashboardUiState.selectedProfileId != -1L && - !isSubScreen, - enter = scaleIn(), - exit = scaleOut(), - modifier = Modifier - .align(Alignment.BottomEnd) - .padding(16.dp), - ) { - FloatingActionButton( - onClick = { startService() }, - containerColor = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onPrimaryContainer, - ) { - Icon( - imageVector = Icons.Default.PlayArrow, - contentDescription = stringResource(R.string.action_start), - ) - } - } - } - } - } - - CompositionLocalProvider(LocalTopBarController provides topBarController) { - if (useNavigationRail) { - Row(modifier = Modifier.fillMaxSize()) { - Surface(tonalElevation = 1.dp) { - NavigationRail( - modifier = Modifier.fillMaxHeight(), - ) { - val hasUpdate by UpdateState.hasUpdate - railScreens.forEach { screen -> - val selected = currentRootRoute == screen.route - - NavigationRailItem( - icon = { - if (screen == Screen.Settings && hasUpdate) { - BadgedBox(badge = { Badge(containerColor = MaterialTheme.colorScheme.primary) }) { - Icon(screen.icon, contentDescription = null) - } - } else { - Icon(screen.icon, contentDescription = null) - } - }, - label = { Text(stringResource(screen.titleRes)) }, - selected = selected, - onClick = { - navController.navigate(screen.route) { - popUpTo(navController.graph.findStartDestination().id) { - saveState = true - } - launchSingleTop = true - restoreState = true - } - }, - ) - } - } - } - - Scaffold( - modifier = Modifier.weight(1f), - snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, - topBar = topBarContent, - ) { paddingValues -> - scaffoldContent(paddingValues) - } - } - } else { - Scaffold( - modifier = Modifier.fillMaxSize(), - snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, - topBar = topBarContent, - bottomBar = { - if (!isSubScreen) { - val hasUpdate by UpdateState.hasUpdate - NavigationBar { - bottomNavigationScreens.forEach { screen -> - NavigationBarItem( - icon = { - if (screen == Screen.Settings && hasUpdate) { - BadgedBox(badge = { Badge(containerColor = MaterialTheme.colorScheme.primary) }) { - Icon(screen.icon, contentDescription = null) - } - } else { - Icon(screen.icon, contentDescription = null) - } - }, - label = { Text(stringResource(screen.titleRes)) }, - selected = - currentDestination?.hierarchy?.any { - it.route == screen.route - } == true, - onClick = { - navController.navigate(screen.route) { - // Pop up to the start destination of the graph to - // avoid building up a large stack of destinations - popUpTo(navController.graph.findStartDestination().id) { - saveState = true - } - // Avoid multiple copies of the same destination - launchSingleTop = true - // Restore state when reselecting a previously selected item - restoreState = true - } - }, - ) - } - } - } - }, - ) { paddingValues -> - scaffoldContent(paddingValues) - } - } - } - - // Groups ModalBottomSheet - if (showGroupsSheet && !useNavigationRail) { - val groupsSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - val groupsViewModel: GroupsViewModel = viewModel( - factory = object : ViewModelProvider.Factory { - override fun create(modelClass: Class): T { - @Suppress("UNCHECKED_CAST") - return GroupsViewModel(dashboardViewModel.commandClient) as T - } - }, - ) - val groupsUiState by groupsViewModel.uiState.collectAsState() - val allCollapsed = groupsUiState.expandedGroups.isEmpty() - - ModalBottomSheet( - onDismissRequest = { showGroupsSheet = false }, - sheetState = groupsSheetState, - containerColor = MaterialTheme.colorScheme.surface, - contentColor = MaterialTheme.colorScheme.onSurface, - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .fillMaxHeight(), - ) { - // Groups content - GroupsCard( - serviceStatus = currentServiceStatus, - commandClient = dashboardViewModel.commandClient, - viewModel = groupsViewModel, - listHeaderContent = { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringResource(R.string.title_groups), - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onSurface, - ) - if (groupsUiState.groups.isNotEmpty()) { - IconButton(onClick = { groupsViewModel.toggleAllGroups() }) { - Icon( - imageVector = if (allCollapsed) { - Icons.Default.UnfoldMore - } else { - Icons.Default.UnfoldLess - }, - contentDescription = if (allCollapsed) { - stringResource(R.string.expand_all) - } else { - stringResource(R.string.collapse_all) - }, - ) - } - } - } - }, - asSheet = true, - modifier = Modifier.fillMaxSize(), - ) - } - } - } - - // Connections ModalBottomSheet - if (showConnectionsSheet && !useNavigationRail) { - val connectionsSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - val connectionsViewModel: ConnectionsViewModel = viewModel() - val connectionsUiState by connectionsViewModel.uiState.collectAsState() - var selectedConnectionId by remember { mutableStateOf(null) } - val selectedConnection = connectionsUiState.allConnections.find { it.id == selectedConnectionId } - var cachedConnection by remember { mutableStateOf(null) } - if (selectedConnection != null) { - cachedConnection = selectedConnection - } else if (selectedConnectionId != null && cachedConnection?.isActive == true) { - cachedConnection = cachedConnection?.copy(closedAt = System.currentTimeMillis()) - } - val displayConnection = if (selectedConnectionId != null) cachedConnection else null - - LaunchedEffect(Unit) { - connectionsViewModel.setVisible(true) - } - - DisposableEffect(Unit) { - onDispose { - connectionsViewModel.setVisible(false) - } - } - - ModalBottomSheet( - onDismissRequest = { - showConnectionsSheet = false - selectedConnectionId = null - }, - sheetState = connectionsSheetState, - containerColor = MaterialTheme.colorScheme.surface, - contentColor = MaterialTheme.colorScheme.onSurface, - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .fillMaxHeight(), - ) { - if (displayConnection != null) { - ConnectionDetailsScreen( - connection = displayConnection, - onBack = { selectedConnectionId = null }, - onClose = { - selectedConnectionId?.let { connectionsViewModel.closeConnection(it) } - }, - asSheet = true, - ) - } else { - ConnectionsPage( - serviceStatus = currentServiceStatus, - viewModel = connectionsViewModel, - asSheet = true, - showTitle = true, - onConnectionClick = { selectedConnectionId = it }, - modifier = Modifier.fillMaxSize(), - ) - } - } - } - } - } - - override fun onServiceStatusChanged(status: Status) { - currentServiceStatus = status - // Update service status in ViewModels - if (::dashboardViewModel.isInitialized) { - dashboardViewModel.updateServiceStatus(status) - } - } - - fun reconnect() { - connection.reconnect() - } - - override fun onServiceAlert(type: Alert, message: String?) { - when (type) { - Alert.RequestLocationPermission -> { - return requestLocationPermission() - } - - else -> { - currentAlert = Pair(type, message) - } - } - } - - private fun requestLocationPermission() { - if (!hasPermission(Manifest.permission.ACCESS_FINE_LOCATION)) { - requestFineLocationPermission() - } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - requestBackgroundLocationPermission() - } - } - - private fun requestFineLocationPermission() { - // Show location permission dialog in Compose UI - showLocationPermissionDialog = true - } - - private fun requestBackgroundLocationPermission() { - // Show background location permission dialog in Compose UI - showBackgroundLocationDialog = true - } - - override fun onDestroy() { - connection.disconnect() - super.onDestroy() - } - - @Composable - private fun ServiceAlertDialog(alertType: Alert, message: String?, onDismiss: () -> Unit) { - val title = - when (alertType) { - Alert.RequestNotificationPermission -> stringResource(R.string.notification_permission_title) - Alert.StartCommandServer -> stringResource(R.string.error_start_command_server) - Alert.CreateService -> stringResource(R.string.error_create_service) - Alert.StartService -> stringResource(R.string.error_start_service) - else -> null - } - - val dialogMessage = - when (alertType) { - Alert.RequestVPNPermission -> stringResource(R.string.error_missing_vpn_permission) - Alert.RequestNotificationPermission -> stringResource(R.string.notification_permission_required_description) - Alert.EmptyConfiguration -> stringResource(R.string.error_empty_configuration) - else -> message - } - - AlertDialog( - onDismissRequest = onDismiss, - title = title?.let { { Text(text = it) } }, - text = dialogMessage?.let { { Text(text = it) } }, - confirmButton = { - TextButton(onClick = onDismiss) { - Text(stringResource(R.string.ok)) - } - }, - ) - } - - @Composable - private fun LocationPermissionDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) { - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(stringResource(R.string.location_permission_title)) }, - text = { Text(stringResource(R.string.location_permission_description)) }, - confirmButton = { - TextButton(onClick = onConfirm) { - Text(stringResource(R.string.ok)) - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text(stringResource(R.string.no_thanks)) - } - }, - ) - } - - @Composable - private fun BackgroundLocationPermissionDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) { - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(stringResource(R.string.location_permission_title)) }, - text = { Text(stringResource(R.string.location_permission_background_description)) }, - confirmButton = { - TextButton(onClick = onConfirm) { - Text(stringResource(R.string.ok)) - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text(stringResource(R.string.no_thanks)) - } - }, - ) - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/base/BaseViewModel.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/base/BaseViewModel.kt deleted file mode 100644 index b78d059888..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/base/BaseViewModel.kt +++ /dev/null @@ -1,74 +0,0 @@ -package io.nekohasekai.sfa.compose.base - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.CoroutineExceptionHandler -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch - -abstract class BaseViewModel : ViewModel() { - private val _uiState: MutableStateFlow by lazy { MutableStateFlow(createInitialState()) } - val uiState: StateFlow = _uiState.asStateFlow() - - private val _events = MutableSharedFlow() - val events: SharedFlow = _events.asSharedFlow() - - abstract fun createInitialState(): State - - protected val currentState: State - get() = _uiState.value - - protected fun updateState(reducer: State.() -> State) { - _uiState.value = _uiState.value.reducer() - } - - /** - * Send an event that will be handled locally by the screen. - * For global events, use sendGlobalEvent() instead. - */ - protected fun sendEvent(event: Event) { - viewModelScope.launch { - _events.emit(event) - } - } - - /** - * Send a global UI event that will be handled by ComposeActivity. - * This is a convenience method for sending UiEvents to the global bus. - */ - fun sendGlobalEvent(event: UiEvent) { - viewModelScope.launch { - GlobalEventBus.emit(event) - } - } - - /** - * Send an error event to be displayed as a dialog. - * This is a convenience method for the common error handling case. - */ - protected fun sendErrorMessage(message: String) { - sendGlobalEvent(UiEvent.ErrorMessage(message)) - } - - protected fun launch(onError: ((Throwable) -> Unit)? = null, block: suspend CoroutineScope.() -> Unit) { - val errorHandler = - CoroutineExceptionHandler { _, throwable -> - onError?.invoke(throwable) ?: sendError(throwable) - } - - viewModelScope.launch(errorHandler, block = block) - } - - /** - * Convenience method to handle exceptions with a custom fallback message - */ - protected fun sendError(throwable: Throwable) { - sendErrorMessage(throwable.message ?: "An unknown error occurred") - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/base/GlobalEventBus.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/base/GlobalEventBus.kt deleted file mode 100644 index b31d900b65..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/base/GlobalEventBus.kt +++ /dev/null @@ -1,33 +0,0 @@ -package io.nekohasekai.sfa.compose.base - -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.asSharedFlow - -/** - * Global event bus that aggregates events from all ViewModels. - * This allows ComposeActivity to handle all events in a centralized manner. - */ -object GlobalEventBus { - private val _events = - MutableSharedFlow( - replay = 0, - extraBufferCapacity = 10, - ) - - val events: SharedFlow = _events.asSharedFlow() - - /** - * Emit an event to the global event bus. - * This should be called by ViewModels to send events that need global handling. - */ - suspend fun emit(event: UiEvent) { - _events.emit(event) - } - - /** - * Try to emit an event without suspending. - * Returns true if the event was emitted successfully. - */ - fun tryEmit(event: UiEvent): Boolean = _events.tryEmit(event) -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/base/SelectableMessageDialog.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/base/SelectableMessageDialog.kt deleted file mode 100644 index c5742a67f7..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/base/SelectableMessageDialog.kt +++ /dev/null @@ -1,56 +0,0 @@ -package io.nekohasekai.sfa.compose.base - -import android.widget.Toast -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.selection.SelectionContainer -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalClipboardManager -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.unit.dp -import io.nekohasekai.sfa.R - -@Composable -fun SelectableMessageDialog(title: String, message: String, onDismiss: () -> Unit) { - val clipboard = LocalClipboardManager.current - val context = LocalContext.current - val scrollState = rememberScrollState() - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(title) }, - text = { - Box( - modifier = Modifier - .heightIn(max = 320.dp) - .verticalScroll(scrollState), - ) { - SelectionContainer { - Text(message) - } - } - }, - dismissButton = { - TextButton( - onClick = { - clipboard.setText(AnnotatedString(message)) - Toast.makeText(context, context.getString(R.string.copied_to_clipboard), Toast.LENGTH_SHORT).show() - }, - ) { - Text(stringResource(R.string.per_app_proxy_action_copy)) - } - }, - confirmButton = { - TextButton(onClick = onDismiss) { - Text(stringResource(R.string.ok)) - } - }, - ) -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/base/UiEvent.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/base/UiEvent.kt deleted file mode 100644 index 6b7467a325..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/base/UiEvent.kt +++ /dev/null @@ -1,43 +0,0 @@ -package io.nekohasekai.sfa.compose.base - -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.asSharedFlow - -/** - * Base sealed class for all UI events in the application. - * These are one-time events that should trigger UI actions. - */ -sealed class UiEvent { - data class ErrorMessage(val message: String) : UiEvent() - - data class OpenUrl(val url: String) : UiEvent() - - data class EditProfile(val profileId: Long) : UiEvent() - - object RequestStartService : UiEvent() - - object RequestReconnectService : UiEvent() - - object RestartToTakeEffect : UiEvent() -} - -/** - * Interface for screen-specific events that don't need global handling - */ -interface ScreenEvent - -interface EventHandler { - val events: SharedFlow - - suspend fun sendEvent(event: T) -} - -class UiEventHandler : EventHandler { - private val _events = MutableSharedFlow() - override val events: SharedFlow = _events.asSharedFlow() - - override suspend fun sendEvent(event: T) { - _events.emit(event) - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/base/UiState.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/base/UiState.kt deleted file mode 100644 index ed9c3570af..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/base/UiState.kt +++ /dev/null @@ -1,11 +0,0 @@ -package io.nekohasekai.sfa.compose.base - -sealed class UiState { - object Loading : UiState() - - data class Success(val data: T) : UiState() - - data class Error(val exception: Throwable, val message: String? = null) : UiState() -} - -data class BaseUiState(val isLoading: Boolean = false, val data: T? = null, val error: String? = null) diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/component/ServiceStatusBar.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/component/ServiceStatusBar.kt deleted file mode 100644 index 225942c155..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/component/ServiceStatusBar.kt +++ /dev/null @@ -1,207 +0,0 @@ -package io.nekohasekai.sfa.compose.component - -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.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -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.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Folder -import androidx.compose.material.icons.filled.Stop -import androidx.compose.material.icons.outlined.Cable -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.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableLongStateOf -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.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import io.nekohasekai.sfa.R -import io.nekohasekai.sfa.constant.Status -import kotlinx.coroutines.delay - -@Composable -fun ServiceStatusBar( - visible: Boolean, - serviceStatus: Status, - startTime: Long?, - groupsCount: Int, - hasGroups: Boolean, - onGroupsClick: () -> Unit, - connectionsCount: Int, - onConnectionsClick: () -> Unit, - onStopClick: () -> Unit, - modifier: Modifier = Modifier, -) { - AnimatedVisibility( - visible = visible, - enter = slideInVertically { it } + fadeIn(), - exit = slideOutVertically { it } + fadeOut(), - modifier = modifier, - ) { - Surface( - modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.surfaceContainer, - tonalElevation = 3.dp, - ) { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - // Status text - StatusItem( - text = when (serviceStatus) { - Status.Starting -> stringResource(R.string.status_starting) - Status.Started -> stringResource(R.string.status_started) - Status.Stopping -> stringResource(R.string.status_stopping) - else -> "" - }, - modifier = Modifier.weight(1f), - ) - - // Connections button - Row( - modifier = - Modifier - .clip(RoundedCornerShape(8.dp)) - .background(MaterialTheme.colorScheme.secondaryContainer) - .clickable(onClick = onConnectionsClick) - .padding(horizontal = 12.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - ) { - Text( - text = connectionsCount.toString(), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onSecondaryContainer, - ) - Spacer(modifier = Modifier.width(4.dp)) - Icon( - imageVector = Icons.Outlined.Cable, - contentDescription = stringResource(R.string.title_connections), - modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme.onSecondaryContainer, - ) - } - - // Groups button (only show if hasGroups) - if (hasGroups) { - Row( - modifier = - Modifier - .clip(RoundedCornerShape(8.dp)) - .background(MaterialTheme.colorScheme.secondaryContainer) - .clickable(onClick = onGroupsClick) - .padding(horizontal = 12.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - ) { - Text( - text = groupsCount.toString(), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onSecondaryContainer, - ) - Spacer(modifier = Modifier.width(4.dp)) - Icon( - imageVector = Icons.Default.Folder, - contentDescription = stringResource(R.string.title_groups), - modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme.onSecondaryContainer, - ) - } - } - - // Stop button - Row( - modifier = - Modifier - .clip(RoundedCornerShape(8.dp)) - .background(MaterialTheme.colorScheme.primaryContainer) - .clickable(onClick = onStopClick) - .padding(horizontal = 12.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - ) { - if (startTime != null) { - UptimeText(startTime = startTime) - Spacer(modifier = Modifier.width(4.dp)) - } - Icon( - imageVector = Icons.Default.Stop, - contentDescription = stringResource(R.string.stop), - modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme.onPrimaryContainer, - ) - } - } - } - } -} - -@Composable -private fun StatusItem(text: String, modifier: Modifier = Modifier) { - Text( - text = text, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onSurface, - modifier = modifier, - ) -} - -@Composable -fun UptimeText(startTime: Long, modifier: Modifier = Modifier) { - var currentTime by remember { mutableLongStateOf(System.currentTimeMillis()) } - - LaunchedEffect(startTime) { - while (true) { - delay(1000) - currentTime = System.currentTimeMillis() - } - } - - val elapsedSeconds = ((currentTime - startTime) / 1000).coerceAtLeast(0) - val hours = elapsedSeconds / 3600 - val minutes = (elapsedSeconds % 3600) / 60 - val seconds = elapsedSeconds % 60 - - val formattedTime = - if (hours > 0) { - String.format("%d:%02d:%02d", hours, minutes, seconds) - } else { - String.format("%d:%02d", minutes, seconds) - } - - Text( - text = formattedTime, - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onPrimaryContainer, - modifier = modifier, - ) -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/component/UpdateDialog.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/component/UpdateDialog.kt deleted file mode 100644 index ff18d53970..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/component/UpdateDialog.kt +++ /dev/null @@ -1,86 +0,0 @@ -package io.nekohasekai.sfa.compose.component - -import android.content.Intent -import android.net.Uri -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import dev.jeziellago.compose.markdowntext.MarkdownText -import io.nekohasekai.sfa.R -import io.nekohasekai.sfa.update.UpdateInfo -import org.kodein.emoji.Emoji -import org.kodein.emoji.EmojiTemplateCatalog -import org.kodein.emoji.all - -@Composable -fun UpdateAvailableDialog(updateInfo: UpdateInfo, onDismiss: () -> Unit, onUpdate: () -> Unit) { - val context = LocalContext.current - val emojiCatalog = remember { EmojiTemplateCatalog(Emoji.all()) } - - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(stringResource(R.string.check_update)) }, - text = { - Column( - modifier = Modifier.verticalScroll(rememberScrollState()), - ) { - Text( - text = stringResource(R.string.new_version_available, updateInfo.versionName), - style = MaterialTheme.typography.bodyMedium, - ) - - if (!updateInfo.releaseNotes.isNullOrBlank()) { - val processedNotes = remember(updateInfo.releaseNotes) { - emojiCatalog.replaceShortcodes(updateInfo.releaseNotes) - } - Spacer(modifier = Modifier.height(12.dp)) - MarkdownText( - markdown = processedNotes, - style = MaterialTheme.typography.bodySmall.copy( - color = MaterialTheme.colorScheme.onSurfaceVariant, - ), - ) - } - } - }, - confirmButton = { - TextButton( - onClick = { - onDismiss() - onUpdate() - }, - ) { - Text(stringResource(R.string.update)) - } - }, - dismissButton = { - Row { - TextButton(onClick = { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse(updateInfo.releaseUrl)) - context.startActivity(intent) - onDismiss() - }) { - Text(stringResource(R.string.view_release)) - } - Spacer(modifier = Modifier.width(8.dp)) - TextButton(onClick = onDismiss) { - Text(stringResource(android.R.string.cancel)) - } - } - }, - ) -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/component/qr/QRCodeDialog.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/component/qr/QRCodeDialog.kt deleted file mode 100644 index 535b87c400..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/component/qr/QRCodeDialog.kt +++ /dev/null @@ -1,65 +0,0 @@ -package io.nekohasekai.sfa.compose.component.qr - -import android.graphics.Bitmap -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -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.ui.graphics.asImageBitmap -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties - -@Composable -fun QRCodeDialog(bitmap: Bitmap, onDismiss: () -> Unit) { - Dialog( - onDismissRequest = onDismiss, - properties = DialogProperties(usePlatformDefaultWidth = false), - ) { - Card( - modifier = - Modifier - .fillMaxWidth(0.9f) - .wrapContentHeight(), - shape = RoundedCornerShape(16.dp), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surface, - ), - ) { - Surface( - modifier = Modifier - .fillMaxWidth() - .aspectRatio(1f), - shape = RoundedCornerShape(0.dp), - color = MaterialTheme.colorScheme.surfaceVariant, - ) { - Box( - modifier = - Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.surface), - contentAlignment = Alignment.Center, - ) { - Image( - bitmap = bitmap.asImageBitmap(), - contentDescription = stringResource(io.nekohasekai.sfa.R.string.content_description_qr_code), - modifier = Modifier.fillMaxSize(), - ) - } - } - } - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/component/qr/QRSBitmapState.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/component/qr/QRSBitmapState.kt deleted file mode 100644 index 3a36f52a53..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/component/qr/QRSBitmapState.kt +++ /dev/null @@ -1,108 +0,0 @@ -package io.nekohasekai.sfa.compose.component.qr - -import android.graphics.Bitmap -import io.nekohasekai.sfa.compose.util.QRCodeGenerator -import io.nekohasekai.sfa.qrs.QRSEncoder -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import kotlinx.coroutines.yield - -data class QRSGenerationState( - val currentBitmap: Bitmap? = null, - val currentFrameIndex: Int = 0, - val generatedCount: Int = 0, - val totalFrames: Int = 0, - val isGenerating: Boolean = true, -) - -class QRSBitmapGenerator( - private val scope: CoroutineScope, - private val frames: List, - private val foregroundColor: Int, - private val backgroundColor: Int, - bufferSize: Int = 30, -) { - private val _state = MutableStateFlow(QRSGenerationState(totalFrames = frames.size)) - val state: StateFlow = _state - - private val actualBufferSize = bufferSize.coerceAtMost(frames.size) - private val bitmapBuffer = arrayOfNulls(actualBufferSize) - private var generationJob: Job? = null - - @Volatile - private var currentFrameIndex = 0 - private var generatedUpTo = -1 - - fun start() { - if (frames.isEmpty()) { - _state.value = _state.value.copy(isGenerating = false) - return - } - - generationJob = scope.launch { - val firstBitmap = withContext(Dispatchers.Default) { - QRCodeGenerator.generate( - content = frames[0].content, - foregroundColor = foregroundColor, - backgroundColor = backgroundColor, - ) - } - bitmapBuffer[0] = firstBitmap - generatedUpTo = 0 - _state.value = _state.value.copy( - currentBitmap = firstBitmap, - generatedCount = 1, - isGenerating = frames.size > 1, - ) - - for (i in 1 until frames.size) { - yield() - val bitmap = withContext(Dispatchers.Default) { - QRCodeGenerator.generate( - content = frames[i].content, - foregroundColor = foregroundColor, - backgroundColor = backgroundColor, - ) - } - - val bufferIndex = i % actualBufferSize - val currentDisplayBufferIndex = currentFrameIndex % actualBufferSize - if (bufferIndex != currentDisplayBufferIndex) { - bitmapBuffer[bufferIndex]?.recycle() - } - bitmapBuffer[bufferIndex] = bitmap - generatedUpTo = i - - _state.value = _state.value.copy( - generatedCount = i + 1, - isGenerating = i < frames.size - 1, - ) - } - } - } - - fun advanceFrame() { - if (generatedUpTo < 0) return - - val nextIndex = (currentFrameIndex + 1) % frames.size - if (nextIndex <= generatedUpTo || generatedUpTo == frames.size - 1) { - currentFrameIndex = nextIndex - } - - val bufferIndex = currentFrameIndex % actualBufferSize - val bitmap = bitmapBuffer[bufferIndex] - _state.value = _state.value.copy( - currentBitmap = bitmap, - currentFrameIndex = currentFrameIndex, - ) - } - - fun cancel() { - generationJob?.cancel() - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/component/qr/QRSDialog.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/component/qr/QRSDialog.kt deleted file mode 100644 index e1300403d1..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/component/qr/QRSDialog.kt +++ /dev/null @@ -1,295 +0,0 @@ -package io.nekohasekai.sfa.compose.component.qr - -import android.content.Intent -import android.graphics.Color -import android.net.Uri -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.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.layout.sizeIn -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.outlined.Info -import androidx.compose.material3.Button -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Slider -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo -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.mutableIntStateOf -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.asImageBitmap -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext -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 io.nekohasekai.sfa.R -import io.nekohasekai.sfa.compat.WindowSizeClassCompat -import io.nekohasekai.sfa.compat.isWidthAtLeastBreakpointCompat -import io.nekohasekai.sfa.qrs.QRSConstants -import io.nekohasekai.sfa.qrs.QRSEncoder -import kotlinx.coroutines.delay - -@Composable -fun QRSDialog(profileData: ByteArray, profileName: String, onDismiss: () -> Unit) { - val context = LocalContext.current - val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass - val isTablet = windowSizeClass.isWidthAtLeastBreakpointCompat(WindowSizeClassCompat.WIDTH_DP_MEDIUM_LOWER_BOUND) - val coroutineScope = rememberCoroutineScope() - var fps by remember { mutableIntStateOf(QRSConstants.DEFAULT_FPS) } - var sliceSize by remember { mutableIntStateOf(QRSConstants.DEFAULT_SLICE_SIZE) } - - val encoder = remember(sliceSize) { QRSEncoder(sliceSize) } - val dataWithMeta = remember(profileData, profileName) { - QRSEncoder.appendFileHeaderMeta( - data = profileData, - filename = "$profileName.bpf", - contentType = "application/octet-stream", - ) - } - val requiredFrames = remember(dataWithMeta, sliceSize) { - QRSConstants.calculateRequiredFrames(dataWithMeta.size, sliceSize) - } - val frames = remember(dataWithMeta, sliceSize, requiredFrames) { - encoder.encode(dataWithMeta, QRSConstants.OFFICIAL_URL_PREFIX) - .take(requiredFrames) - .toList() - } - - val frameInterval = remember(fps) { 1000L / fps } - - val generator = remember(frames) { - QRSBitmapGenerator( - scope = coroutineScope, - frames = frames, - foregroundColor = Color.BLACK, - backgroundColor = Color.WHITE, - bufferSize = QRSConstants.BITMAP_BUFFER_SIZE, - ) - } - - val generationState by generator.state.collectAsState() - - LaunchedEffect(generator) { - generator.start() - } - - DisposableEffect(generator) { - onDispose { - generator.cancel() - } - } - - LaunchedEffect(frameInterval, generationState.generatedCount) { - if (generationState.generatedCount > 0) { - while (true) { - delay(frameInterval) - generator.advanceFrame() - } - } - } - - Dialog( - onDismissRequest = onDismiss, - properties = DialogProperties(usePlatformDefaultWidth = false), - ) { - Card( - modifier = - if (isTablet) { - Modifier - .fillMaxWidth(0.85f) - .sizeIn(maxWidth = 960.dp) - .wrapContentHeight() - } else { - Modifier - .fillMaxWidth(0.9f) - .wrapContentHeight() - }, - shape = RoundedCornerShape(16.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surface, - ), - ) { - val qrSurface: @Composable () -> Unit = { - Surface( - modifier = Modifier - .sizeIn(maxWidth = if (isTablet) 420.dp else 360.dp, maxHeight = if (isTablet) 420.dp else 360.dp) - .fillMaxWidth() - .aspectRatio(1f), - shape = RoundedCornerShape(0.dp), - color = MaterialTheme.colorScheme.surfaceVariant, - ) { - Box( - modifier = Modifier - .fillMaxSize() - .background(androidx.compose.ui.graphics.Color.White), - contentAlignment = Alignment.Center, - ) { - generationState.currentBitmap?.let { bitmap -> - Image( - bitmap = bitmap.asImageBitmap(), - contentDescription = stringResource(R.string.content_description_qr_code), - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Fit, - ) - } - } - } - } - - val controlsContent: @Composable () -> Unit = { - Column( - modifier = Modifier.fillMaxWidth(), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringResource(R.string.qrs_fps), - style = MaterialTheme.typography.bodyMedium, - ) - Text( - text = "$fps Hz", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - Slider( - value = fps.toFloat(), - onValueChange = { fps = it.toInt() }, - valueRange = QRSConstants.MIN_FPS.toFloat()..QRSConstants.MAX_FPS.toFloat(), - modifier = Modifier.fillMaxWidth(), - ) - - Spacer(modifier = Modifier.height(8.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringResource(R.string.qrs_slice_size), - style = MaterialTheme.typography.bodyMedium, - ) - Text( - text = "$sliceSize", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - Slider( - value = sliceSize.toFloat(), - onValueChange = { sliceSize = it.toInt() }, - valueRange = QRSConstants.MIN_SLICE_SIZE.toFloat()..QRSConstants.MAX_SLICE_SIZE.toFloat(), - modifier = Modifier.fillMaxWidth(), - ) - } - - Spacer(modifier = Modifier.height(16.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - OutlinedButton( - onClick = { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/qifi-dev/qrs")) - context.startActivity(intent) - }, - modifier = Modifier.weight(1f), - ) { - Icon( - imageVector = Icons.Outlined.Info, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(4.dp)) - Text(stringResource(R.string.qrs_what_is_qrs)) - } - - Button( - onClick = onDismiss, - modifier = Modifier.weight(1f), - ) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(4.dp)) - Text(stringResource(R.string.close)) - } - } - } - - if (isTablet) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(24.dp), - horizontalArrangement = Arrangement.spacedBy(24.dp), - verticalAlignment = Alignment.Top, - ) { - Column( - modifier = Modifier.weight(1f), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - qrSurface() - } - - Column( - modifier = Modifier.weight(1f), - ) { - controlsContent() - } - } - } else { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - qrSurface() - - Spacer(modifier = Modifier.height(16.dp)) - - controlsContent() - } - } - } - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/component/qr/QRScanSheet.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/component/qr/QRScanSheet.kt deleted file mode 100644 index ea0677e9b6..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/component/qr/QRScanSheet.kt +++ /dev/null @@ -1,362 +0,0 @@ -package io.nekohasekai.sfa.compose.component.qr - -import android.Manifest -import android.content.pm.PackageManager -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.camera.view.PreviewView -import androidx.compose.foundation.Canvas -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.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -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.material.icons.Icons -import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.rememberModalBottomSheetState -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.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Rect -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalLifecycleOwner -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.AndroidView -import androidx.core.content.ContextCompat -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.viewmodel.compose.viewModel -import io.nekohasekai.sfa.R -import io.nekohasekai.sfa.compose.screen.qrscan.QRCodeCropArea -import io.nekohasekai.sfa.compose.screen.qrscan.QRScanResult -import io.nekohasekai.sfa.compose.screen.qrscan.QRScanViewModel -import kotlin.math.max - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun QRScanSheet(onDismiss: () -> Unit, onScanResult: (QRScanResult) -> Unit, viewModel: QRScanViewModel = viewModel()) { - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - val context = LocalContext.current - val lifecycleOwner = LocalLifecycleOwner.current - val uiState by viewModel.uiState.collectAsState() - - var hasPermission by remember { - mutableStateOf( - ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == - PackageManager.PERMISSION_GRANTED, - ) - } - - val permissionLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.RequestPermission(), - ) { isGranted -> - if (isGranted) { - hasPermission = true - } else { - onDismiss() - } - } - - LaunchedEffect(Unit) { - viewModel.resetQRSState() - if (!hasPermission) { - permissionLauncher.launch(Manifest.permission.CAMERA) - } - } - - LaunchedEffect(uiState.result) { - uiState.result?.let { result -> - viewModel.clearResult() - onScanResult(result) - } - } - - var showMenu by remember { mutableStateOf(false) } - - ModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = sheetState, - containerColor = MaterialTheme.colorScheme.surface, - contentColor = MaterialTheme.colorScheme.onSurface, - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .fillMaxHeight(0.9f), - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp) - .padding(bottom = 16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringResource(R.string.profile_add_scan_qr_code), - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Medium, - ) - - Box { - IconButton(onClick = { showMenu = true }) { - Icon( - imageVector = Icons.Default.MoreVert, - contentDescription = stringResource(R.string.more_options), - ) - } - DropdownMenu( - expanded = showMenu, - onDismissRequest = { showMenu = false }, - ) { - DropdownMenuItem( - text = { - Text( - (if (uiState.useFrontCamera) "✓ " else " ") + - stringResource(R.string.profile_add_scan_use_front_camera), - ) - }, - onClick = { - viewModel.toggleFrontCamera(lifecycleOwner) - showMenu = false - }, - ) - DropdownMenuItem( - text = { - Text( - (if (uiState.torchEnabled) "✓ " else " ") + - stringResource(R.string.profile_add_scan_enable_torch), - ) - }, - onClick = { - viewModel.toggleTorch() - showMenu = false - }, - ) - if (uiState.vendorAnalyzerAvailable) { - DropdownMenuItem( - text = { - Text( - (if (uiState.useVendorAnalyzer) "✓ " else " ") + - stringResource(R.string.profile_add_scan_use_vendor_analyzer), - ) - }, - onClick = { - viewModel.toggleVendorAnalyzer() - showMenu = false - }, - ) - } - } - } - } - - Box( - modifier = Modifier - .fillMaxWidth() - .weight(1f), - ) { - if (hasPermission) { - CameraPreview( - modifier = Modifier.fillMaxSize(), - viewModel = viewModel, - lifecycleOwner = lifecycleOwner, - cropArea = uiState.cropArea, - ) - } - - if (uiState.isLoading) { - Box( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.surface), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator() - } - } - - if (uiState.qrsMode && uiState.qrsProgress != null) { - val (decoded, total) = uiState.qrsProgress!! - val progress = if (total > 0) decoded.toFloat() / total.toFloat() / 1.2f else 0f - Box( - modifier = Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = 0.5f)), - contentAlignment = Alignment.Center, - ) { - Box(contentAlignment = Alignment.Center) { - CircularProgressIndicator( - progress = { progress.coerceIn(0f, 1f) }, - modifier = Modifier.size(96.dp), - color = Color.White, - strokeWidth = 8.dp, - trackColor = Color.White.copy(alpha = 0.3f), - ) - if (total > 0) { - Text( - text = "${minOf(99, (progress * 100).toInt())}%", - style = MaterialTheme.typography.titleLarge.copy( - fontWeight = FontWeight.SemiBold, - ), - color = Color.White, - ) - } - Text( - text = "QRS", - style = MaterialTheme.typography.headlineLarge.copy( - fontWeight = FontWeight.Bold, - ), - color = Color.White, - modifier = Modifier.offset(y = (-88).dp), - ) - } - } - } - } - } - } - - if (uiState.errorMessage != null) { - AlertDialog( - onDismissRequest = { viewModel.dismissError() }, - title = { Text(stringResource(android.R.string.dialog_alert_title)) }, - text = { Text(uiState.errorMessage ?: "") }, - confirmButton = { - TextButton(onClick = { viewModel.dismissError() }) { - Text(stringResource(android.R.string.ok)) - } - }, - ) - } -} - -@Composable -private fun CameraPreview( - modifier: Modifier = Modifier, - viewModel: QRScanViewModel, - lifecycleOwner: LifecycleOwner, - cropArea: QRCodeCropArea?, -) { - var previewView by remember { mutableStateOf(null) } - - DisposableEffect(previewView) { - previewView?.let { view -> - view.implementationMode = PreviewView.ImplementationMode.COMPATIBLE - viewModel.startCamera(lifecycleOwner, view) - } - onDispose { } - } - - Box(modifier = modifier) { - AndroidView( - modifier = Modifier.fillMaxSize(), - factory = { ctx -> - PreviewView(ctx).apply { - implementationMode = PreviewView.ImplementationMode.COMPATIBLE - previewView = this - - previewStreamState.observe(lifecycleOwner) { state -> - if (state == PreviewView.StreamState.STREAMING) { - viewModel.onPreviewStreamStateChanged(true) - implementationMode = PreviewView.ImplementationMode.PERFORMANCE - } - } - } - }, - ) - - Canvas(modifier = Modifier.fillMaxSize()) { - val rect = cropArea?.let { mapCropAreaToPreview(it, size.width, size.height) } ?: return@Canvas - drawRect( - color = Color.White.copy(alpha = 0.85f), - topLeft = rect.topLeft, - size = rect.size, - style = Stroke(width = 2.dp.toPx()), - ) - } - } -} - -private fun mapCropAreaToPreview(area: QRCodeCropArea, viewWidth: Float, viewHeight: Float): Rect? { - if (viewWidth <= 0f || viewHeight <= 0f) return null - - val rotation = ((area.rotationDegrees % 360) + 360) % 360 - var rotLeft = area.left.toFloat() - var rotTop = area.top.toFloat() - var rotRight = area.right.toFloat() - var rotBottom = area.bottom.toFloat() - var imageWidth = area.imageWidth.toFloat() - var imageHeight = area.imageHeight.toFloat() - when (rotation) { - 90 -> { - rotLeft = (area.imageHeight - area.bottom).toFloat() - rotTop = area.left.toFloat() - rotRight = (area.imageHeight - area.top).toFloat() - rotBottom = area.right.toFloat() - imageWidth = area.imageHeight.toFloat() - imageHeight = area.imageWidth.toFloat() - } - 180 -> { - rotLeft = (area.imageWidth - area.right).toFloat() - rotTop = (area.imageHeight - area.bottom).toFloat() - rotRight = (area.imageWidth - area.left).toFloat() - rotBottom = (area.imageHeight - area.top).toFloat() - } - 270 -> { - rotLeft = area.top.toFloat() - rotTop = (area.imageWidth - area.right).toFloat() - rotRight = area.bottom.toFloat() - rotBottom = (area.imageWidth - area.left).toFloat() - imageWidth = area.imageHeight.toFloat() - imageHeight = area.imageWidth.toFloat() - } - } - - if (imageWidth <= 0f || imageHeight <= 0f) return null - - val scale = max(viewWidth / imageWidth, viewHeight / imageHeight) - val dx = (viewWidth - imageWidth * scale) / 2f - val dy = (viewHeight - imageHeight * scale) / 2f - - val left = rotLeft * scale + dx - val top = rotTop * scale + dy - val right = rotRight * scale + dx - val bottom = rotBottom * scale + dy - - val clampedLeft = left.coerceIn(0f, viewWidth) - val clampedTop = top.coerceIn(0f, viewHeight) - val clampedRight = right.coerceIn(0f, viewWidth) - val clampedBottom = bottom.coerceIn(0f, viewHeight) - - if (clampedRight - clampedLeft < 4f || clampedBottom - clampedTop < 4f) return null - - return Rect(clampedLeft, clampedTop, clampedRight, clampedBottom) -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/model/Connection.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/model/Connection.kt deleted file mode 100644 index c84759efbd..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/model/Connection.kt +++ /dev/null @@ -1,114 +0,0 @@ -package io.nekohasekai.sfa.compose.model - -import androidx.compose.runtime.Immutable -import io.nekohasekai.sfa.ktx.toList -import io.nekohasekai.libbox.Connection as LibboxConnection -import io.nekohasekai.libbox.ProcessInfo as LibboxProcessInfo - -@Immutable -data class ProcessInfo(val processId: Long, val userId: Int, val userName: String, val processPath: String, val packageName: String) { - companion object { - fun from(processInfo: LibboxProcessInfo?): ProcessInfo? { - if (processInfo == null) return null - return ProcessInfo( - processId = processInfo.processID, - userId = processInfo.userID, - userName = processInfo.userName ?: "", - processPath = processInfo.processPath ?: "", - packageName = processInfo.packageName ?: "", - ) - } - } -} - -@Immutable -data class Connection( - val id: String, - val inbound: String, - val inboundType: String, - val ipVersion: Int, - val network: String, - val source: String, - val destination: String, - val domain: String, - val displayDestination: String, - val protocolName: String, - val user: String, - val fromOutbound: String, - val createdAt: Long, - val closedAt: Long?, - val upload: Long, - val download: Long, - val uploadTotal: Long, - val downloadTotal: Long, - val rule: String, - val outbound: String, - val outboundType: String, - val chain: List, - val processInfo: ProcessInfo?, -) { - val isActive: Boolean get() = closedAt == null || closedAt == 0L - - fun performSearch(content: String): Boolean { - if (content.isBlank()) return true - for (item in content.trim().split(" ").filter { it.isNotEmpty() }) { - val parts = item.split(":", limit = 2) - if (parts.size == 2) { - if (!performSearchType(parts[0], parts[1])) return false - } else { - if (!performSearchPlain(item)) return false - } - } - return true - } - - private fun performSearchPlain(content: String): Boolean = destination.contains(content, ignoreCase = true) || - domain.contains(content, ignoreCase = true) || - outbound.contains(content, ignoreCase = true) || - rule.contains(content, ignoreCase = true) || - processInfo?.packageName?.contains(content, ignoreCase = true) == true - - private fun performSearchType(type: String, value: String): Boolean = when (type) { - "network" -> network.equals(value, ignoreCase = true) - "inbound" -> inbound.contains(value, ignoreCase = true) - "inbound.type" -> inboundType.equals(value, ignoreCase = true) - "source" -> source.contains(value, ignoreCase = true) - "destination" -> destination.contains(value, ignoreCase = true) - "outbound" -> outbound.contains(value, ignoreCase = true) - "outbound.type" -> outboundType.equals(value, ignoreCase = true) - "rule" -> rule.contains(value, ignoreCase = true) - "protocol" -> protocolName.equals(value, ignoreCase = true) - "user" -> user.contains(value, ignoreCase = true) - "package" -> processInfo?.packageName?.contains(value, ignoreCase = true) == true - "chain" -> chain.any { it.contains(value, ignoreCase = true) } - else -> false - } - - companion object { - fun from(connection: LibboxConnection): Connection = Connection( - id = connection.id, - inbound = connection.inbound, - inboundType = connection.inboundType, - ipVersion = connection.ipVersion, - network = connection.network, - source = connection.source, - destination = connection.destination, - domain = connection.domain, - displayDestination = connection.displayDestination(), - protocolName = connection.protocol, - user = connection.user, - fromOutbound = connection.fromOutbound, - createdAt = connection.createdAt, - closedAt = if (connection.closedAt > 0) connection.closedAt else null, - upload = connection.uplink, - download = connection.downlink, - uploadTotal = connection.uplinkTotal, - downloadTotal = connection.downlinkTotal, - rule = connection.rule, - outbound = connection.outbound, - outboundType = connection.outboundType, - chain = connection.chain().toList(), - processInfo = ProcessInfo.from(connection.processInfo), - ) - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/model/ConnectionFilters.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/model/ConnectionFilters.kt deleted file mode 100644 index 3a735702e0..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/model/ConnectionFilters.kt +++ /dev/null @@ -1,15 +0,0 @@ -package io.nekohasekai.sfa.compose.model - -import io.nekohasekai.libbox.Libbox - -enum class ConnectionStateFilter(val libboxValue: Int) { - All(Libbox.ConnectionStateAll.toInt()), - Active(Libbox.ConnectionStateActive.toInt()), - Closed(Libbox.ConnectionStateClosed.toInt()), -} - -enum class ConnectionSort { - ByDate, - ByTraffic, - ByTrafficTotal, -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/navigation/NavigationDestinations.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/navigation/NavigationDestinations.kt deleted file mode 100644 index 27456b99cf..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/navigation/NavigationDestinations.kt +++ /dev/null @@ -1,50 +0,0 @@ -package io.nekohasekai.sfa.compose.navigation - -import androidx.annotation.StringRes -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.TextSnippet -import androidx.compose.material.icons.filled.Dashboard -import androidx.compose.material.icons.filled.Folder -import androidx.compose.material.icons.filled.Settings -import androidx.compose.material.icons.filled.SwapVert -import androidx.compose.ui.graphics.vector.ImageVector -import io.nekohasekai.sfa.R - -sealed class Screen(val route: String, @StringRes val titleRes: Int, val icon: ImageVector) { - object Dashboard : Screen( - route = "dashboard", - titleRes = R.string.title_dashboard, - icon = Icons.Default.Dashboard, - ) - - object Log : Screen( - route = "log", - titleRes = R.string.title_log, - icon = Icons.AutoMirrored.Default.TextSnippet, - ) - - object Groups : Screen( - route = "groups", - titleRes = R.string.title_groups, - icon = Icons.Default.Folder, - ) - - object Connections : Screen( - route = "connections", - titleRes = R.string.title_connections, - icon = Icons.Default.SwapVert, - ) - - object Settings : Screen( - route = "settings", - titleRes = R.string.title_settings, - icon = Icons.Default.Settings, - ) -} - -val bottomNavigationScreens = - listOf( - Screen.Dashboard, - Screen.Log, - Screen.Settings, - ) diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/navigation/ProfileRoutes.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/navigation/ProfileRoutes.kt deleted file mode 100644 index 54d2dcddd1..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/navigation/ProfileRoutes.kt +++ /dev/null @@ -1,11 +0,0 @@ -package io.nekohasekai.sfa.compose.navigation - -data class NewProfileArgs(val importName: String? = null, val importUrl: String? = null, val qrsData: ByteArray? = null) - -object ProfileRoutes { - const val NewProfile = "profile/new" - const val EditProfile = "profile/edit/{profileId}" - const val EditProfileBase = "profile/edit" - - fun editProfile(profileId: Long): String = "$EditProfileBase/$profileId" -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/navigation/SFANavigation.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/navigation/SFANavigation.kt deleted file mode 100644 index 2f46d1774d..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/navigation/SFANavigation.kt +++ /dev/null @@ -1,297 +0,0 @@ -package io.nekohasekai.sfa.compose.navigation - -import android.net.Uri -import androidx.compose.animation.AnimatedContentTransitionScope -import androidx.compose.animation.core.tween -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.ui.Modifier -import androidx.navigation.NavHostController -import androidx.navigation.NavType -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.composable -import androidx.navigation.navArgument -import io.nekohasekai.sfa.compose.screen.configuration.NewProfileScreen -import io.nekohasekai.sfa.compose.screen.connections.ConnectionDetailsRoute -import io.nekohasekai.sfa.compose.screen.connections.ConnectionsPage -import io.nekohasekai.sfa.compose.screen.connections.ConnectionsViewModel -import io.nekohasekai.sfa.compose.screen.dashboard.DashboardScreen -import io.nekohasekai.sfa.compose.screen.dashboard.DashboardViewModel -import io.nekohasekai.sfa.compose.screen.dashboard.GroupsCard -import io.nekohasekai.sfa.compose.screen.dashboard.groups.GroupsViewModel -import io.nekohasekai.sfa.compose.screen.log.HookLogScreen -import io.nekohasekai.sfa.compose.screen.log.LogScreen -import io.nekohasekai.sfa.compose.screen.log.LogViewModel -import io.nekohasekai.sfa.compose.screen.privilegesettings.PrivilegeSettingsManageScreen -import io.nekohasekai.sfa.compose.screen.profile.EditProfileRoute -import io.nekohasekai.sfa.compose.screen.profileoverride.PerAppProxyScreen -import io.nekohasekai.sfa.compose.screen.settings.AppSettingsScreen -import io.nekohasekai.sfa.compose.screen.settings.CoreSettingsScreen -import io.nekohasekai.sfa.compose.screen.settings.PrivilegeSettingsScreen -import io.nekohasekai.sfa.compose.screen.settings.ProfileOverrideScreen -import io.nekohasekai.sfa.compose.screen.settings.ServiceSettingsScreen -import io.nekohasekai.sfa.compose.screen.settings.SettingsScreen -import io.nekohasekai.sfa.constant.Status - -private val slideInFromRight: AnimatedContentTransitionScope<*>.() -> androidx.compose.animation.EnterTransition = { - slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Left, animationSpec = tween(300)) -} - -private val slideOutToRight: AnimatedContentTransitionScope<*>.() -> androidx.compose.animation.ExitTransition = { - slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Right, animationSpec = tween(300)) -} - -private val slideInFromLeft: AnimatedContentTransitionScope<*>.() -> androidx.compose.animation.EnterTransition = { - slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Right, animationSpec = tween(300)) -} - -private val slideOutToLeft: AnimatedContentTransitionScope<*>.() -> androidx.compose.animation.ExitTransition = { - slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Left, animationSpec = tween(300)) -} - -@Composable -fun SFANavHost( - navController: NavHostController, - serviceStatus: Status = Status.Stopped, - showStartFab: Boolean = false, - showStatusBar: Boolean = false, - newProfileArgs: NewProfileArgs = NewProfileArgs(), - onClearNewProfileArgs: () -> Unit = {}, - onOpenNewProfile: (NewProfileArgs) -> Unit = {}, - dashboardViewModel: DashboardViewModel? = null, - logViewModel: LogViewModel? = null, - groupsViewModel: GroupsViewModel? = null, - connectionsViewModel: ConnectionsViewModel? = null, - modifier: Modifier = Modifier, -) { - NavHost( - navController = navController, - startDestination = Screen.Dashboard.route, - modifier = modifier, - ) { - composable(Screen.Dashboard.route) { - if (dashboardViewModel != null) { - DashboardScreen( - serviceStatus = serviceStatus, - showStartFab = showStartFab, - showStatusBar = showStatusBar, - onOpenNewProfile = onOpenNewProfile, - viewModel = dashboardViewModel, - ) - } else { - DashboardScreen( - serviceStatus = serviceStatus, - showStartFab = showStartFab, - showStatusBar = showStatusBar, - onOpenNewProfile = onOpenNewProfile, - ) - } - } - - composable(Screen.Log.route) { - if (logViewModel != null) { - LogScreen( - serviceStatus = serviceStatus, - showStartFab = showStartFab, - showStatusBar = showStatusBar, - viewModel = logViewModel, - ) - } else { - LogScreen( - serviceStatus = serviceStatus, - showStartFab = showStartFab, - showStatusBar = showStatusBar, - ) - } - } - - composable(Screen.Groups.route) { - if (groupsViewModel != null) { - GroupsCard( - serviceStatus = serviceStatus, - viewModel = groupsViewModel, - showTopBar = true, - modifier = Modifier.fillMaxSize(), - ) - } else { - GroupsCard( - serviceStatus = serviceStatus, - showTopBar = true, - modifier = Modifier.fillMaxSize(), - ) - } - } - - composable(Screen.Connections.route) { - if (connectionsViewModel != null) { - ConnectionsPage( - serviceStatus = serviceStatus, - viewModel = connectionsViewModel, - showTitle = false, - showTopBar = true, - onConnectionClick = { connectionId -> - navController.navigate("connections/detail/${Uri.encode(connectionId)}") - }, - modifier = Modifier.fillMaxSize(), - ) - } else { - ConnectionsPage( - serviceStatus = serviceStatus, - showTitle = false, - showTopBar = true, - onConnectionClick = { connectionId -> - navController.navigate("connections/detail/${Uri.encode(connectionId)}") - }, - modifier = Modifier.fillMaxSize(), - ) - } - } - - composable(ProfileRoutes.NewProfile) { - DisposableEffect(Unit) { - onDispose { onClearNewProfileArgs() } - } - NewProfileScreen( - importName = newProfileArgs.importName, - importUrl = newProfileArgs.importUrl, - qrsData = newProfileArgs.qrsData, - onNavigateBack = { - onClearNewProfileArgs() - navController.navigateUp() - }, - onProfileCreated = { profileId -> - onClearNewProfileArgs() - navController.navigate(ProfileRoutes.editProfile(profileId)) { - popUpTo(ProfileRoutes.NewProfile) { - inclusive = true - } - } - }, - ) - } - - composable( - route = ProfileRoutes.EditProfile, - arguments = listOf( - navArgument("profileId") { - type = NavType.LongType - }, - ), - ) { backStackEntry -> - val profileId = backStackEntry.arguments?.getLong("profileId") ?: -1L - EditProfileRoute( - profileId = profileId, - onNavigateBack = { navController.navigateUp() }, - modifier = Modifier.fillMaxSize(), - ) - } - - composable("connections/detail/{connectionId}") { backStackEntry -> - val connectionId = backStackEntry.arguments?.getString("connectionId") - if (connectionId != null) { - if (connectionsViewModel != null) { - ConnectionDetailsRoute( - connectionId = connectionId, - serviceStatus = serviceStatus, - viewModel = connectionsViewModel, - onBack = { navController.navigateUp() }, - modifier = Modifier.fillMaxSize(), - ) - } else { - ConnectionDetailsRoute( - connectionId = connectionId, - serviceStatus = serviceStatus, - onBack = { navController.navigateUp() }, - modifier = Modifier.fillMaxSize(), - ) - } - } - } - - composable(Screen.Settings.route) { - SettingsScreen(navController = navController) - } - - // Settings subscreens with slide animations - composable( - route = "settings/app", - enterTransition = slideInFromRight, - exitTransition = slideOutToLeft, - popEnterTransition = slideInFromLeft, - popExitTransition = slideOutToRight, - ) { - AppSettingsScreen(navController = navController) - } - - composable( - route = "settings/core", - enterTransition = slideInFromRight, - exitTransition = slideOutToRight, - popEnterTransition = slideInFromRight, - popExitTransition = slideOutToRight, - ) { - CoreSettingsScreen(navController = navController) - } - - composable( - route = "settings/service", - enterTransition = slideInFromRight, - exitTransition = slideOutToLeft, - popEnterTransition = slideInFromLeft, - popExitTransition = slideOutToRight, - ) { - ServiceSettingsScreen(navController = navController) - } - - composable( - route = "settings/profile_override", - enterTransition = slideInFromRight, - exitTransition = slideOutToLeft, - popEnterTransition = slideInFromLeft, - popExitTransition = slideOutToRight, - ) { - ProfileOverrideScreen(navController = navController) - } - - composable( - route = "settings/profile_override/manage", - enterTransition = slideInFromRight, - exitTransition = slideOutToLeft, - popEnterTransition = slideInFromLeft, - popExitTransition = slideOutToRight, - ) { - PerAppProxyScreen(onBack = { navController.navigateUp() }) - } - - composable( - route = "settings/privilege", - enterTransition = slideInFromRight, - exitTransition = slideOutToLeft, - popEnterTransition = slideInFromLeft, - popExitTransition = slideOutToRight, - ) { - PrivilegeSettingsScreen(navController = navController, serviceStatus = serviceStatus) - } - - composable( - route = "settings/privilege/manage", - enterTransition = slideInFromRight, - exitTransition = slideOutToLeft, - popEnterTransition = slideInFromLeft, - popExitTransition = slideOutToRight, - ) { - PrivilegeSettingsManageScreen(onBack = { navController.navigateUp() }) - } - - composable( - route = "settings/privilege/logs", - enterTransition = slideInFromRight, - exitTransition = slideOutToLeft, - popEnterTransition = slideInFromLeft, - popExitTransition = slideOutToRight, - ) { - HookLogScreen(onBack = { navController.navigateUp() }) - } - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/configuration/NewProfileScreen.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/configuration/NewProfileScreen.kt deleted file mode 100644 index 72559fd1a0..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/configuration/NewProfileScreen.kt +++ /dev/null @@ -1,590 +0,0 @@ -package io.nekohasekai.sfa.compose.screen.configuration - -import android.net.Uri -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.expandVertically -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -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.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.navigationBars -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.CloudDownload -import androidx.compose.material.icons.filled.CreateNewFolder -import androidx.compose.material.icons.filled.FileUpload -import androidx.compose.material.icons.filled.Save -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -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.OutlinedButton -import androidx.compose.material3.OutlinedCard -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Surface -import androidx.compose.material3.Switch -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -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.platform.LocalContext -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewmodel.compose.viewModel -import io.nekohasekai.sfa.R -import io.nekohasekai.sfa.compose.base.SelectableMessageDialog -import io.nekohasekai.sfa.compose.topbar.OverrideTopBar - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun NewProfileScreen( - importName: String? = null, - importUrl: String? = null, - qrsData: ByteArray? = null, - onNavigateBack: () -> Unit, - onProfileCreated: (profileId: Long) -> Unit, - viewModel: NewProfileViewModel = viewModel(), -) { - val uiState by viewModel.uiState.collectAsStateWithLifecycle() - val context = LocalContext.current - - LaunchedEffect(importName, importUrl, qrsData) { - if (qrsData != null) { - viewModel.initializeFromQRSImport(importName, qrsData) - } else { - viewModel.initializeFromQRImport(importName, importUrl) - } - } - - // File picker launcher - val filePickerLauncher = - rememberLauncherForActivityResult( - contract = ActivityResultContracts.GetContent(), - ) { uri: Uri? -> - uri?.let { - val fileName = - context.contentResolver.query(it, null, null, null, null)?.use { cursor -> - val nameIndex = cursor.getColumnIndexOrThrow("_display_name") - cursor.moveToFirst() - cursor.getString(nameIndex) - } - viewModel.setImportUri(it, fileName) - } - } - - // Error dialog state - var showErrorDialog by remember { mutableStateOf(false) } - - // Handle success - LaunchedEffect(uiState.isSuccess, uiState.createdProfile) { - if (uiState.isSuccess) { - uiState.createdProfile?.let { profile -> - onProfileCreated(profile.id) - } - } - } - - // Show error dialog when there's an error message - LaunchedEffect(uiState.errorMessage) { - if (uiState.errorMessage != null) { - showErrorDialog = true - } - } - - // Error dialog - if (showErrorDialog) { - SelectableMessageDialog( - title = stringResource(R.string.error_title), - message = uiState.errorMessage ?: "", - onDismiss = { - showErrorDialog = false - viewModel.clearError() - }, - ) - } - - OverrideTopBar { - TopAppBar( - title = { Text(stringResource(R.string.title_new_profile)) }, - navigationIcon = { - IconButton(onClick = onNavigateBack) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.content_description_back), - ) - } - }, - colors = - TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface, - ), - ) - } - - val bottomInset = - with(LocalDensity.current) { - WindowInsets.navigationBars.getBottom(this).toDp() - } - val bottomBarPadding = 88.dp + bottomInset - - Box(modifier = Modifier.fillMaxSize()) { - Column( - modifier = - Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(16.dp) - .padding(bottom = bottomBarPadding), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - // Profile Name - Card( - modifier = Modifier.fillMaxWidth(), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), - ), - ) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - text = stringResource(R.string.basic_information), - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.primary, - ) - OutlinedTextField( - value = uiState.name, - onValueChange = viewModel::updateName, - label = { Text(stringResource(R.string.profile_name)) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - isError = uiState.nameError != null, - supportingText = { - uiState.nameError?.let { error -> - Text( - text = error, - color = MaterialTheme.colorScheme.error, - ) - } - }, - ) - } - } - - // Profile Type Selection - Card( - modifier = Modifier.fillMaxWidth(), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), - ), - ) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - text = stringResource(R.string.profile_type), - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.primary, - ) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy((-1).dp), // Overlap borders - ) { - OutlinedButton( - onClick = { viewModel.updateProfileType(ProfileType.Local) }, - modifier = Modifier.weight(1f), - shape = - RoundedCornerShape( - topStart = 12.dp, - bottomStart = 12.dp, - topEnd = 0.dp, - bottomEnd = 0.dp, - ), - colors = - if (uiState.profileType == ProfileType.Local) { - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onPrimaryContainer, - ) - } else { - ButtonDefaults.outlinedButtonColors() - }, - border = - BorderStroke( - 1.dp, - if (uiState.profileType == ProfileType.Local) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.outline - }, - ), - ) { - Text(stringResource(R.string.profile_type_local)) - } - OutlinedButton( - onClick = { viewModel.updateProfileType(ProfileType.Remote) }, - modifier = Modifier.weight(1f), - shape = - RoundedCornerShape( - topStart = 0.dp, - bottomStart = 0.dp, - topEnd = 12.dp, - bottomEnd = 12.dp, - ), - colors = - if (uiState.profileType == ProfileType.Remote) { - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onPrimaryContainer, - ) - } else { - ButtonDefaults.outlinedButtonColors() - }, - border = - BorderStroke( - 1.dp, - if (uiState.profileType == ProfileType.Remote) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.outline - }, - ), - ) { - Text(stringResource(R.string.profile_type_remote)) - } - } - } - } - - // Local Profile Options - AnimatedVisibility( - visible = uiState.profileType == ProfileType.Local, - enter = fadeIn() + expandVertically(), - exit = fadeOut() + shrinkVertically(), - ) { - Card( - modifier = Modifier.fillMaxWidth(), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.3f), - ), - ) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Text( - text = stringResource(R.string.profile_source), - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.secondary, - ) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy((-1).dp), // Overlap borders - ) { - OutlinedButton( - onClick = { viewModel.updateProfileSource(ProfileSource.CreateNew) }, - modifier = Modifier.weight(1f), - shape = - RoundedCornerShape( - topStart = 12.dp, - bottomStart = 12.dp, - topEnd = 0.dp, - bottomEnd = 0.dp, - ), - colors = - if (uiState.profileSource == ProfileSource.CreateNew) { - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.secondaryContainer, - contentColor = MaterialTheme.colorScheme.onSecondaryContainer, - ) - } else { - ButtonDefaults.outlinedButtonColors() - }, - border = - BorderStroke( - 1.dp, - if (uiState.profileSource == ProfileSource.CreateNew) { - MaterialTheme.colorScheme.secondary - } else { - MaterialTheme.colorScheme.outline - }, - ), - ) { - Icon( - Icons.Default.CreateNewFolder, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(4.dp)) - Text(stringResource(R.string.profile_source_create_new)) - } - OutlinedButton( - onClick = { viewModel.updateProfileSource(ProfileSource.Import) }, - modifier = Modifier.weight(1f), - shape = - RoundedCornerShape( - topStart = 0.dp, - bottomStart = 0.dp, - topEnd = 12.dp, - bottomEnd = 12.dp, - ), - colors = - if (uiState.profileSource == ProfileSource.Import) { - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.secondaryContainer, - contentColor = MaterialTheme.colorScheme.onSecondaryContainer, - ) - } else { - ButtonDefaults.outlinedButtonColors() - }, - border = - BorderStroke( - 1.dp, - if (uiState.profileSource == ProfileSource.Import) { - MaterialTheme.colorScheme.secondary - } else { - MaterialTheme.colorScheme.outline - }, - ), - ) { - Icon( - Icons.Default.FileUpload, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(4.dp)) - Text(stringResource(R.string.profile_source_import)) - } - } - - AnimatedVisibility( - visible = uiState.profileSource == ProfileSource.Import, - ) { - Column( - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - OutlinedCard( - onClick = { filePickerLauncher.launch("*/*") }, - modifier = Modifier.fillMaxWidth(), - border = - BorderStroke( - 1.dp, - if (uiState.importError != null) { - MaterialTheme.colorScheme.error - } else { - MaterialTheme.colorScheme.outline - }, - ), - ) { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Icon( - Icons.Default.FileUpload, - contentDescription = null, - tint = - if (uiState.importError != null) { - MaterialTheme.colorScheme.error - } else { - MaterialTheme.colorScheme.primary - }, - ) - Column(modifier = Modifier.weight(1f)) { - Text( - text = uiState.importFileName ?: stringResource(R.string.profile_import_file), - style = MaterialTheme.typography.bodyMedium, - ) - if (uiState.importFileName != null) { - Text( - text = stringResource(R.string.group_selected_title), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary, - ) - } - } - } - } - uiState.importError?.let { error -> - Text( - text = error, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(start = 16.dp), - ) - } - } - } - } - } - } - - // Remote Profile Options - AnimatedVisibility( - visible = uiState.profileType == ProfileType.Remote, - enter = fadeIn() + expandVertically(), - exit = fadeOut() + shrinkVertically(), - ) { - Card( - modifier = Modifier.fillMaxWidth(), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.3f), - ), - ) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Icon( - Icons.Default.CloudDownload, - contentDescription = null, - tint = MaterialTheme.colorScheme.tertiary, - modifier = Modifier.size(20.dp), - ) - Text( - text = stringResource(R.string.remote_configuration), - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.tertiary, - ) - } - - OutlinedTextField( - value = uiState.remoteUrl, - onValueChange = viewModel::updateRemoteUrl, - label = { Text(stringResource(R.string.profile_url)) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - isError = uiState.remoteUrlError != null, - supportingText = { - uiState.remoteUrlError?.let { error -> - Text( - text = error, - color = MaterialTheme.colorScheme.error, - ) - } - }, - ) - - HorizontalDivider() - - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text( - text = stringResource(R.string.profile_auto_update), - style = MaterialTheme.typography.bodyLarge, - ) - Switch( - checked = uiState.autoUpdate, - onCheckedChange = viewModel::updateAutoUpdate, - ) - } - - AnimatedVisibility(visible = uiState.autoUpdate) { - OutlinedTextField( - value = uiState.autoUpdateInterval.toString(), - onValueChange = viewModel::updateAutoUpdateInterval, - label = { Text(stringResource(R.string.profile_auto_update_interval)) }, - supportingText = { Text(stringResource(R.string.profile_auto_update_interval_minimum_hint)) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - ) - } - } - } - } - } - - Surface( - modifier = - Modifier - .fillMaxWidth() - .align(Alignment.BottomCenter), - color = MaterialTheme.colorScheme.surface, - tonalElevation = 3.dp, - ) { - Box( - modifier = - Modifier - .fillMaxWidth() - .windowInsetsPadding(WindowInsets.navigationBars) - .padding(16.dp), - ) { - Button( - onClick = { viewModel.validateAndCreateProfile() }, - modifier = Modifier.fillMaxWidth(), - enabled = !uiState.isSaving, - ) { - if (uiState.isSaving) { - CircularProgressIndicator( - modifier = Modifier.size(18.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onPrimary, - ) - } else { - Icon( - Icons.Default.Save, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) - Text(stringResource(R.string.profile_create)) - } - } - } - } - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/configuration/NewProfileViewModel.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/configuration/NewProfileViewModel.kt deleted file mode 100644 index 11c35bb946..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/configuration/NewProfileViewModel.kt +++ /dev/null @@ -1,319 +0,0 @@ -package io.nekohasekai.sfa.compose.screen.configuration - -import android.app.Application -import android.net.Uri -import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.viewModelScope -import io.nekohasekai.libbox.Libbox -import io.nekohasekai.sfa.R -import io.nekohasekai.sfa.bg.UpdateProfileWork -import io.nekohasekai.sfa.database.Profile -import io.nekohasekai.sfa.database.ProfileManager -import io.nekohasekai.sfa.database.TypedProfile -import io.nekohasekai.sfa.utils.HTTPClient -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 -import java.io.File -import java.io.InputStream -import java.util.Date - -data class NewProfileUiState( - val name: String = "", - val profileType: ProfileType = ProfileType.Local, - val profileSource: ProfileSource = ProfileSource.CreateNew, - // Remote profile fields - val remoteUrl: String = "", - val autoUpdate: Boolean = true, - val autoUpdateInterval: Int = 60, - // File import - val importUri: Uri? = null, - val importFileName: String? = null, - // QRS import - val qrsData: ByteArray? = null, - // State - val isLoading: Boolean = false, - val isSaving: Boolean = false, - val errorMessage: String? = null, - val isSuccess: Boolean = false, - val createdProfile: Profile? = null, - // Field errors - val nameError: String? = null, - val remoteUrlError: String? = null, - val importError: String? = null, -) - -enum class ProfileType { - Local, - Remote, -} - -enum class ProfileSource { - CreateNew, - Import, -} - -class NewProfileViewModel(application: Application) : AndroidViewModel(application) { - private val _uiState = MutableStateFlow(NewProfileUiState()) - val uiState: StateFlow = _uiState.asStateFlow() - - fun initializeFromQRImport(name: String?, url: String?) { - if (name != null && url != null) { - _uiState.update { - it.copy( - name = name, - profileType = ProfileType.Remote, - remoteUrl = url, - ) - } - } - } - - fun initializeFromQRSImport(name: String?, qrsData: ByteArray) { - _uiState.update { - it.copy( - name = name ?: "", - profileType = ProfileType.Local, - profileSource = ProfileSource.Import, - qrsData = qrsData, - ) - } - } - - fun updateName(name: String) { - _uiState.update { - it.copy( - name = name, - nameError = if (name.isNotBlank()) null else it.nameError, - ) - } - } - - fun updateProfileType(type: ProfileType) { - _uiState.update { it.copy(profileType = type) } - } - - fun updateProfileSource(source: ProfileSource) { - _uiState.update { - it.copy( - profileSource = source, - importError = null, // Clear import error when changing source - ) - } - } - - fun updateRemoteUrl(url: String) { - _uiState.update { - it.copy( - remoteUrl = url, - remoteUrlError = if (url.isNotBlank()) null else it.remoteUrlError, - ) - } - } - - fun updateAutoUpdate(enabled: Boolean) { - _uiState.update { it.copy(autoUpdate = enabled) } - } - - fun updateAutoUpdateInterval(interval: String) { - val intValue = interval.toIntOrNull() ?: 60 - _uiState.update { it.copy(autoUpdateInterval = intValue.coerceAtLeast(15)) } - } - - fun setImportUri(uri: Uri, fileName: String?) { - _uiState.update { - it.copy( - importUri = uri, - importFileName = fileName, - importError = null, // Clear error when file is selected - name = - if (it.name.isEmpty()) { - fileName?.substringBeforeLast(".") ?: "Imported Profile" - } else { - it.name - }, - ) - } - } - - fun clearError() { - _uiState.update { it.copy(errorMessage = null) } - } - - fun validateAndCreateProfile(): Boolean { - val state = _uiState.value - val context = getApplication() - - // Clear previous errors - _uiState.update { - it.copy( - nameError = null, - remoteUrlError = null, - importError = null, - ) - } - - var hasError = false - - // Validate name - if (state.name.isBlank()) { - _uiState.update { it.copy(nameError = context.getString(R.string.profile_input_required)) } - hasError = true - } - - // Validate based on profile type - when (state.profileType) { - ProfileType.Local -> { - if (state.profileSource == ProfileSource.Import && state.importUri == null && state.qrsData == null) { - _uiState.update { it.copy(importError = context.getString(R.string.profile_input_required)) } - hasError = true - } - } - ProfileType.Remote -> { - if (state.remoteUrl.isBlank()) { - _uiState.update { it.copy(remoteUrlError = context.getString(R.string.profile_input_required)) } - hasError = true - } - } - } - - if (hasError) { - return false - } - - // If validation passes, create the profile - createProfile() - return true - } - - private fun createProfile() { - viewModelScope.launch { - val state = _uiState.value - _uiState.update { it.copy(isSaving = true, errorMessage = null) } - - try { - val profile = - withContext(Dispatchers.IO) { - when (state.profileType) { - ProfileType.Local -> createLocalProfile(state) - ProfileType.Remote -> createRemoteProfile(state) - } - } - - _uiState.update { - it.copy( - isSaving = false, - isSuccess = true, - createdProfile = profile, - ) - } - } catch (e: Exception) { - _uiState.update { - it.copy( - isSaving = false, - errorMessage = e.message ?: "Unknown error", - ) - } - } - } - } - - private suspend fun createLocalProfile(state: NewProfileUiState): Profile { - val context = getApplication() - val typedProfile = - TypedProfile().apply { - type = TypedProfile.Type.Local - } - - val profile = - Profile(name = state.name, typed = typedProfile).apply { - userOrder = ProfileManager.nextOrder() - } - - val fileID = ProfileManager.nextFileID() - val configDirectory = File(context.filesDir, "configs").also { it.mkdirs() } - val configFile = File(configDirectory, "$fileID.json") - typedProfile.path = configFile.path - - // Get config content - val configContent = - when (state.profileSource) { - ProfileSource.CreateNew -> "{}" - ProfileSource.Import -> { - if (state.qrsData != null) { - val content = Libbox.decodeProfileContent(state.qrsData) - content.config - } else { - state.importUri?.let { uri -> - val sourceURL = uri.toString() - when { - sourceURL.startsWith("content://") -> { - val inputStream = context.contentResolver.openInputStream(uri) as InputStream - inputStream.use { it.bufferedReader().readText() } - } - sourceURL.startsWith("file://") -> { - File(Uri.parse(sourceURL).path!!).readText() - } - sourceURL.startsWith("http://") || sourceURL.startsWith("https://") -> { - HTTPClient().use { it.getString(sourceURL) } - } - else -> throw Exception("Unsupported source: $sourceURL") - } - } ?: "{}" - } - } - } - - // Validate config - Libbox.checkConfig(configContent) - configFile.writeText(configContent) - - // Create profile in database and select it - ProfileManager.create(profile, andSelect = true) - - return profile - } - - private suspend fun createRemoteProfile(state: NewProfileUiState): Profile { - val context = getApplication() - val typedProfile = - TypedProfile().apply { - type = TypedProfile.Type.Remote - remoteURL = state.remoteUrl - autoUpdate = state.autoUpdate - autoUpdateInterval = state.autoUpdateInterval - lastUpdated = Date() - } - - val profile = - Profile(name = state.name, typed = typedProfile).apply { - userOrder = ProfileManager.nextOrder() - } - - val fileID = ProfileManager.nextFileID() - val configDirectory = File(context.filesDir, "configs").also { it.mkdirs() } - val configFile = File(configDirectory, "$fileID.json") - typedProfile.path = configFile.path - - // Fetch initial config - this MUST succeed for remote profiles - val content = HTTPClient().use { it.getString(state.remoteUrl) } - Libbox.checkConfig(content) - val configContent = content - - configFile.writeText(configContent) - - // Create profile in database and select it - ProfileManager.create(profile, andSelect = true) - - // Reconfigure updater if auto-update is enabled - if (state.autoUpdate) { - UpdateProfileWork.reconfigureUpdater() - } - - return profile - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/configuration/ProfileImportHandler.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/configuration/ProfileImportHandler.kt deleted file mode 100644 index 0a8e9a5eae..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/configuration/ProfileImportHandler.kt +++ /dev/null @@ -1,386 +0,0 @@ -package io.nekohasekai.sfa.compose.screen.configuration - -import android.content.Context -import android.net.Uri -import android.provider.OpenableColumns -import io.nekohasekai.libbox.Libbox -import io.nekohasekai.libbox.ProfileContent -import io.nekohasekai.sfa.R -import io.nekohasekai.sfa.database.Profile -import io.nekohasekai.sfa.database.ProfileManager -import io.nekohasekai.sfa.database.TypedProfile -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import org.json.JSONObject -import java.io.File -import java.util.Date - -class ProfileImportHandler(private val context: Context) { - sealed class ImportResult { - data class Success(val profile: Profile) : ImportResult() - - data class Error(val message: String) : ImportResult() - } - - sealed class QRCodeParseResult { - data class RemoteProfile(val name: String, val host: String, val url: String) : QRCodeParseResult() - - data class LocalProfile(val name: String) : QRCodeParseResult() - - data class Error(val message: String) : QRCodeParseResult() - } - - sealed class QRSParseResult { - data class Success(val name: String) : QRSParseResult() - - data class Error(val message: String) : QRSParseResult() - } - - sealed class UriParseResult { - data class Success(val name: String) : UriParseResult() - - data class Error(val message: String) : UriParseResult() - } - - suspend fun importFromUri(uri: Uri): ImportResult = withContext(Dispatchers.IO) { - try { - val data = - context.contentResolver.openInputStream(uri)?.use { it.readBytes() } - ?: return@withContext ImportResult.Error(context.getString(R.string.error_empty_file)) - - // Get the filename from the URI - val filename = getFileNameFromUri(uri) - - // Try to detect if it's a JSON configuration file - val dataString = String(data) - if (isJsonConfiguration(dataString)) { - // It's a JSON configuration, import it directly as a local profile - return@withContext importJsonConfiguration(dataString, filename) - } - - // Try to decode as ProfileContent (the old way) - val content = - try { - Libbox.decodeProfileContent(data) - } catch (e: Exception) { - // If it fails, try one more time as JSON - if (dataString.trimStart().startsWith("{") || dataString.trimStart().startsWith("[")) { - return@withContext importJsonConfiguration(dataString, filename) - } - return@withContext ImportResult.Error( - context.getString(R.string.error_decode_profile, e.message), - ) - } - - importProfile(content) - } catch (e: Exception) { - ImportResult.Error(e.message ?: "Unknown error") - } - } - - suspend fun parseUri(uri: Uri): UriParseResult = withContext(Dispatchers.IO) { - try { - val data = - context.contentResolver.openInputStream(uri)?.use { it.readBytes() } - ?: return@withContext UriParseResult.Error(context.getString(R.string.error_empty_file)) - - val filename = getFileNameFromUri(uri) - val dataString = String(data) - - if (isJsonConfiguration(dataString)) { - return@withContext UriParseResult.Success(name = filename) - } - - val content = - try { - Libbox.decodeProfileContent(data) - } catch (e: Exception) { - if (dataString.trimStart().startsWith("{") || dataString.trimStart().startsWith("[")) { - return@withContext UriParseResult.Success(name = filename) - } - return@withContext UriParseResult.Error( - context.getString(R.string.error_decode_profile, e.message), - ) - } - - UriParseResult.Success(name = content.name) - } catch (e: Exception) { - UriParseResult.Error(e.message ?: "Unknown error") - } - } - - suspend fun parseQRCode(data: String): QRCodeParseResult = withContext(Dispatchers.IO) { - try { - // Check if it's a sing-box remote profile import link - if (data.startsWith("sing-box://import-remote-profile")) { - try { - val profileInfo = Libbox.parseRemoteProfileImportLink(data) - return@withContext QRCodeParseResult.RemoteProfile( - name = profileInfo.name, - host = profileInfo.host, - url = profileInfo.url, - ) - } catch (e: Exception) { - return@withContext QRCodeParseResult.Error( - context.getString(R.string.error_decode_profile, e.message), - ) - } - } - - // Check if it's a direct URL - if (data.startsWith("http://") || data.startsWith("https://")) { - val profileName = extractProfileNameFromUrl(data) - return@withContext QRCodeParseResult.RemoteProfile( - name = profileName, - host = extractHostFromUrl(data), - url = data, - ) - } - - // Try to decode as profile content - val content = - try { - Libbox.decodeProfileContent(data.toByteArray()) - } catch (e: Exception) { - return@withContext QRCodeParseResult.Error( - context.getString(R.string.error_decode_profile, e.message), - ) - } - - return@withContext QRCodeParseResult.LocalProfile(name = content.name) - } catch (e: Exception) { - QRCodeParseResult.Error(e.message ?: "Unknown error") - } - } - - suspend fun importFromQRCode(data: String): ImportResult = withContext(Dispatchers.IO) { - try { - // Check if it's a sing-box remote profile import link - if (data.startsWith("sing-box://import-remote-profile")) { - try { - val profileInfo = Libbox.parseRemoteProfileImportLink(data) - return@withContext importRemoteProfile(profileInfo.name, profileInfo.url) - } catch (e: Exception) { - return@withContext ImportResult.Error( - context.getString(R.string.error_decode_profile, e.message), - ) - } - } - - // Check if it's a URL or direct profile content - if (data.startsWith("http://") || data.startsWith("https://")) { - // Handle remote profile URL - val profileName = extractProfileNameFromUrl(data) - importRemoteProfile(profileName, data) - } else { - // Try to decode as profile content - val content = - try { - Libbox.decodeProfileContent(data.toByteArray()) - } catch (e: Exception) { - return@withContext ImportResult.Error( - context.getString(R.string.error_decode_profile, e.message), - ) - } - importProfile(content) - } - } catch (e: Exception) { - ImportResult.Error(e.message ?: "Unknown error") - } - } - - suspend fun parseQRSData(data: ByteArray): QRSParseResult = withContext(Dispatchers.IO) { - try { - val content = try { - Libbox.decodeProfileContent(data) - } catch (e: Exception) { - return@withContext QRSParseResult.Error( - context.getString(R.string.error_decode_profile, e.message), - ) - } - QRSParseResult.Success(name = content.name) - } catch (e: Exception) { - QRSParseResult.Error(e.message ?: "Unknown error") - } - } - - suspend fun importFromQRSData(data: ByteArray): ImportResult = withContext(Dispatchers.IO) { - try { - val content = try { - Libbox.decodeProfileContent(data) - } catch (e: Exception) { - return@withContext ImportResult.Error( - context.getString(R.string.error_decode_profile, e.message), - ) - } - importProfile(content) - } catch (e: Exception) { - ImportResult.Error(e.message ?: "Unknown error") - } - } - - private suspend fun importProfile(content: ProfileContent): ImportResult { - val typedProfile = TypedProfile() - val profile = Profile(name = content.name, typed = typedProfile) - profile.userOrder = ProfileManager.nextOrder() - - when (content.type) { - Libbox.ProfileTypeLocal -> { - typedProfile.type = TypedProfile.Type.Local - } - Libbox.ProfileTypeiCloud -> { - return ImportResult.Error(context.getString(R.string.icloud_profile_unsupported)) - } - Libbox.ProfileTypeRemote -> { - typedProfile.type = TypedProfile.Type.Remote - typedProfile.remoteURL = content.remotePath - typedProfile.autoUpdate = content.autoUpdate - typedProfile.autoUpdateInterval = content.autoUpdateInterval - typedProfile.lastUpdated = Date(content.lastUpdated) - } - } - - // Save config file - val configDirectory = File(context.filesDir, "configs").also { it.mkdirs() } - val configFile = File(configDirectory, "${profile.userOrder}.json") - configFile.writeText(content.config) - typedProfile.path = configFile.path - - // Create profile in database and select it - ProfileManager.create(profile, andSelect = true) - - return ImportResult.Success(profile) - } - - private suspend fun importRemoteProfile(name: String, url: String): ImportResult { - val typedProfile = - TypedProfile().apply { - type = TypedProfile.Type.Remote - remoteURL = url - autoUpdate = true - autoUpdateInterval = 60 - lastUpdated = Date() - } - - val profile = - Profile(name = name, typed = typedProfile).apply { - userOrder = ProfileManager.nextOrder() - } - - // Create empty config file for remote profile - val configDirectory = File(context.filesDir, "configs").also { it.mkdirs() } - val configFile = File(configDirectory, "${profile.userOrder}.json") - configFile.writeText("{}") - typedProfile.path = configFile.path - - // Create profile in database and select it - ProfileManager.create(profile, andSelect = true) - - return ImportResult.Success(profile) - } - - private fun extractProfileNameFromUrl(url: String): String { - // Extract name from URL or use default - return url.substringAfterLast("/") - .substringBeforeLast(".") - .takeIf { it.isNotEmpty() } - ?: "Remote Profile" - } - - private fun extractHostFromUrl(url: String): String = try { - val uri = Uri.parse(url) - uri.host ?: url - } catch (e: Exception) { - url - } - - private fun getFileNameFromUri(uri: Uri): String { - var filename = "Imported Profile" - - // Try to get filename from content resolver - context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> - val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) - if (nameIndex >= 0 && cursor.moveToFirst()) { - filename = cursor.getString(nameIndex) - ?.substringBeforeLast(".") // Remove extension - ?.takeIf { it.isNotEmpty() } - ?: filename - } - } - - // Fallback to getting from URI path - if (filename == "Imported Profile") { - uri.lastPathSegment?.let { segment -> - filename = segment - .substringBeforeLast(".") - .takeIf { it.isNotEmpty() } - ?: filename - } - } - - return filename - } - - private fun isJsonConfiguration(content: String): Boolean { - val trimmed = content.trim() - if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) { - return false - } - - return try { - // Try to parse as JSON and check for sing-box configuration fields - val json = JSONObject(content) - // Check for common sing-box configuration fields - json.has("inbounds") || - json.has("outbounds") || - json.has("route") || - json.has("dns") || - json.has("experimental") - } catch (e: Exception) { - // If it's an array, it might still be valid - trimmed.startsWith("[") && trimmed.endsWith("]") - } - } - - private suspend fun importJsonConfiguration(jsonContent: String, profileName: String): ImportResult { - return try { - // Validate the JSON configuration using sing-box - try { - // Try to check the configuration - Libbox.checkConfig(jsonContent) - } catch (e: Exception) { - // Configuration validation failed - return ImportResult.Error( - context.getString(R.string.error_invalid_configuration, e.message), - ) - } - - // Create a local profile with the JSON configuration - val typedProfile = - TypedProfile().apply { - type = TypedProfile.Type.Local - } - - val profile = - Profile( - name = profileName.ifEmpty { "Imported Profile" }, - typed = typedProfile, - ).apply { - userOrder = ProfileManager.nextOrder() - } - - // Save the configuration file - val configDirectory = File(context.filesDir, "configs").also { it.mkdirs() } - val configFile = File(configDirectory, "${profile.userOrder}.json") - configFile.writeText(jsonContent) - typedProfile.path = configFile.path - - // Create profile in database and select it - ProfileManager.create(profile, andSelect = true) - - ImportResult.Success(profile) - } catch (e: Exception) { - ImportResult.Error(e.message ?: "Unknown error importing JSON configuration") - } - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionDetailsScreen.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionDetailsScreen.kt deleted file mode 100644 index ef572402cf..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionDetailsScreen.kt +++ /dev/null @@ -1,364 +0,0 @@ -package io.nekohasekai.sfa.compose.screen.connections - -import androidx.compose.foundation.ScrollState -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.rememberScrollState -import androidx.compose.foundation.text.selection.SelectionContainer -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -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.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.geometry.Offset -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.nestedscroll.NestedScrollConnection -import androidx.compose.ui.input.nestedscroll.NestedScrollSource -import androidx.compose.ui.input.nestedscroll.nestedScroll -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.Velocity -import androidx.compose.ui.unit.dp -import io.nekohasekai.libbox.Libbox -import io.nekohasekai.sfa.R -import io.nekohasekai.sfa.compat.rememberOverscrollEffectCompat -import io.nekohasekai.sfa.compat.verticalScrollCompat -import io.nekohasekai.sfa.compose.model.Connection -import io.nekohasekai.sfa.compose.util.rememberSheetDismissFromContentOnlyIfGestureStartedAtTopModifier -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale - -@Composable -fun ConnectionDetailsScreen( - connection: Connection, - onBack: () -> Unit, - onClose: () -> Unit, - modifier: Modifier = Modifier, - showHeader: Boolean = true, - asSheet: Boolean = false, -) { - val dateTimeFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) - var showMenu by remember { mutableStateOf(false) } - val scrollState = rememberScrollState() - val scrollModifier = - if (asSheet) { - rememberSheetDismissFromContentOnlyIfGestureStartedAtTopModifier { - scrollState.value == 0 - } - } else { - Modifier.nestedScroll(rememberBounceBlockingNestedScrollConnection(scrollState)) - } - - Column( - modifier = modifier - .fillMaxSize() - .then(scrollModifier) - .verticalScrollCompat(scrollState, overscrollEffect = if (asSheet) null else rememberOverscrollEffectCompat()), - ) { - if (showHeader) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp) - .padding(bottom = 16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - IconButton(onClick = onBack) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.content_description_back), - ) - } - Text( - text = stringResource(R.string.connection_details), - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.weight(1f), - ) - if (connection.isActive) { - Box { - IconButton(onClick = { showMenu = true }) { - Icon(Icons.Default.MoreVert, contentDescription = null) - } - DropdownMenu( - expanded = showMenu, - onDismissRequest = { showMenu = false }, - ) { - DropdownMenuItem( - text = { Text(stringResource(R.string.connection_close)) }, - onClick = { - onClose() - showMenu = false - }, - ) - } - } - } - } - } - - DetailSection(title = stringResource(R.string.connection_section_basic)) { - DetailRow( - label = stringResource(R.string.connection_state), - value = if (connection.isActive) { - stringResource(R.string.connection_state_active) - } else { - stringResource(R.string.connection_state_closed) - }, - valueColor = if (connection.isActive) { - MaterialTheme.colorScheme.tertiary - } else { - MaterialTheme.colorScheme.error - }, - ) - DetailRow( - label = stringResource(R.string.connection_created_at), - value = dateTimeFormat.format(Date(connection.createdAt)), - ) - if (!connection.isActive && connection.closedAt != null) { - DetailRow( - label = stringResource(R.string.connection_closed_at), - value = dateTimeFormat.format(Date(connection.closedAt)), - ) - DetailRow( - label = stringResource(R.string.connection_duration), - value = Libbox.formatDuration(connection.closedAt - connection.createdAt), - ) - } - DetailRow( - label = stringResource(R.string.connection_uplink), - value = Libbox.formatBytes(connection.uploadTotal), - ) - DetailRow( - label = stringResource(R.string.connection_downlink), - value = Libbox.formatBytes(connection.downloadTotal), - ) - } - - Spacer(modifier = Modifier.height(16.dp)) - - DetailSection(title = stringResource(R.string.connection_section_metadata)) { - DetailRow( - label = stringResource(R.string.connection_inbound), - value = connection.inbound, - monospace = true, - ) - DetailRow( - label = stringResource(R.string.connection_inbound_type), - value = connection.inboundType, - monospace = true, - ) - DetailRow( - label = stringResource(R.string.connection_ip_version), - value = "IPv${connection.ipVersion}", - ) - DetailRow( - label = stringResource(R.string.connection_network), - value = connection.network.uppercase(), - ) - DetailRow( - label = stringResource(R.string.connection_source), - value = connection.source, - monospace = true, - ) - DetailRow( - label = stringResource(R.string.connection_destination), - value = connection.destination, - monospace = true, - ) - if (connection.domain.isNotEmpty()) { - DetailRow( - label = stringResource(R.string.connection_domain), - value = connection.domain, - monospace = true, - ) - } - if (connection.protocolName.isNotEmpty()) { - DetailRow( - label = stringResource(R.string.connection_protocol), - value = connection.protocolName, - monospace = true, - ) - } - if (connection.user.isNotEmpty()) { - DetailRow( - label = stringResource(R.string.connection_user), - value = connection.user, - monospace = true, - ) - } - if (connection.fromOutbound.isNotEmpty()) { - DetailRow( - label = stringResource(R.string.connection_from_outbound), - value = connection.fromOutbound, - monospace = true, - ) - } - if (connection.rule.isNotEmpty()) { - DetailRow( - label = stringResource(R.string.connection_match_rule), - value = connection.rule, - monospace = true, - ) - } - DetailRow( - label = stringResource(R.string.connection_outbound), - value = connection.outbound, - monospace = true, - ) - DetailRow( - label = stringResource(R.string.connection_outbound_type), - value = connection.outboundType, - monospace = true, - ) - if (connection.chain.size > 1) { - DetailRow( - label = stringResource(R.string.connection_chain), - value = connection.chain.joinToString(" → "), - monospace = true, - ) - } - } - - connection.processInfo?.let { processInfo -> - if (processInfo.packageName.isNotEmpty() || - processInfo.processPath.isNotEmpty() || - processInfo.processId > 0 - ) { - Spacer(modifier = Modifier.height(16.dp)) - - DetailSection(title = stringResource(R.string.connection_section_process)) { - if (processInfo.processId > 0) { - DetailRow( - label = stringResource(R.string.connection_process_id), - value = processInfo.processId.toString(), - monospace = true, - ) - } - if (processInfo.userId >= 0) { - DetailRow( - label = stringResource(R.string.connection_user_id), - value = processInfo.userId.toString(), - monospace = true, - ) - } - if (processInfo.userName.isNotEmpty()) { - DetailRow( - label = stringResource(R.string.connection_user_name), - value = processInfo.userName, - monospace = true, - ) - } - if (processInfo.processPath.isNotEmpty()) { - DetailRow( - label = stringResource(R.string.connection_process_path), - value = processInfo.processPath, - monospace = true, - ) - } - if (processInfo.packageName.isNotEmpty()) { - DetailRow( - label = stringResource(R.string.connection_package_name), - value = processInfo.packageName, - monospace = true, - ) - } - } - } - } - - Spacer(modifier = Modifier.height(24.dp)) - } -} - -@Composable -private fun DetailSection(title: String, content: @Composable ColumnScope.() -> Unit) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - ) { - Text( - text = title, - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(bottom = 8.dp), - ) - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), - ), - ) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - content = content, - ) - } - } -} - -@Composable -private fun DetailRow(label: String, value: String, monospace: Boolean = false, valueColor: Color = MaterialTheme.colorScheme.onSurface) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = label, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(end = 16.dp), - ) - SelectionContainer(modifier = Modifier.weight(1f)) { - Text( - text = value, - style = if (monospace) { - MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace) - } else { - MaterialTheme.typography.bodyMedium - }, - color = valueColor, - textAlign = TextAlign.End, - modifier = Modifier.fillMaxWidth(), - ) - } - } -} - -@Composable -private fun rememberBounceBlockingNestedScrollConnection(scrollState: ScrollState): NestedScrollConnection = remember(scrollState) { - object : NestedScrollConnection { - override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset = if (available.y < 0) available else Offset.Zero - - override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity = if (available.y < 0) available else Velocity.Zero - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionItem.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionItem.kt deleted file mode 100644 index 49dc7a0e59..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionItem.kt +++ /dev/null @@ -1,221 +0,0 @@ -package io.nekohasekai.sfa.compose.screen.connections - -import android.content.pm.PackageManager -import android.graphics.Bitmap -import android.graphics.Canvas -import android.graphics.drawable.BitmapDrawable -import android.graphics.drawable.Drawable -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.Image -import androidx.compose.foundation.combinedClickable -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.material.icons.Icons -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.outlined.Circle -import androidx.compose.material3.Card -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -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.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.ImageBitmap -import androidx.compose.ui.graphics.asImageBitmap -import androidx.compose.ui.platform.LocalContext -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 io.nekohasekai.libbox.Libbox -import io.nekohasekai.sfa.R -import io.nekohasekai.sfa.compose.model.Connection - -private fun Drawable.toBitmap(): Bitmap { - if (this is BitmapDrawable) return bitmap - val bitmap = Bitmap.createBitmap( - intrinsicWidth.coerceAtLeast(1), - intrinsicHeight.coerceAtLeast(1), - Bitmap.Config.ARGB_8888, - ) - val canvas = Canvas(bitmap) - setBounds(0, 0, canvas.width, canvas.height) - draw(canvas) - return bitmap -} - -data class AppInfo(val icon: ImageBitmap, val label: String) - -@Composable -private fun rememberAppInfo(packageName: String): AppInfo? { - val context = LocalContext.current - return remember(packageName) { - try { - val pm = context.packageManager - val appInfo = pm.getApplicationInfo(packageName, 0) - AppInfo( - icon = appInfo.loadIcon(pm).toBitmap().asImageBitmap(), - label = appInfo.loadLabel(pm).toString(), - ) - } catch (e: PackageManager.NameNotFoundException) { - null - } - } -} - -@OptIn(ExperimentalFoundationApi::class) -@Composable -fun ConnectionItem(connection: Connection, onClick: () -> Unit, onClose: () -> Unit, modifier: Modifier = Modifier) { - var showContextMenu by remember { mutableStateOf(false) } - val packageName = connection.processInfo?.packageName?.takeIf { it.isNotEmpty() } - val appInfo = packageName?.let { rememberAppInfo(it) } - - Box(modifier = modifier) { - Card( - modifier = Modifier - .fillMaxWidth() - .combinedClickable( - onClick = onClick, - onLongClick = { showContextMenu = true }, - ), - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(12.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - // Column 1: App icon - if (appInfo != null) { - Image( - bitmap = appInfo.icon, - contentDescription = null, - modifier = Modifier.size(32.dp), - ) - } else { - Icon( - imageVector = Icons.Outlined.Circle, - contentDescription = null, - modifier = Modifier.size(32.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - // Content column - Column(modifier = Modifier.weight(1f)) { - // Row 1: Title (destination + status) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "${connection.network.uppercase()} ${connection.displayDestination}", - style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Bold, - ), - modifier = Modifier.weight(1f), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = if (connection.isActive) { - stringResource(R.string.connection_state_active) - } else { - stringResource(R.string.connection_state_closed) - }, - style = MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.Bold), - color = if (connection.isActive) { - MaterialTheme.colorScheme.tertiary - } else { - MaterialTheme.colorScheme.error - }, - ) - } - - Spacer(modifier = Modifier.height(4.dp)) - - // Row 2: Upload stats + inbound tag - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "↑ ${Libbox.formatBytes(connection.upload)}/s | ${Libbox.formatBytes(connection.uploadTotal)}", - style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = "${connection.inboundType}/${connection.inbound}", - style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - // Row 3: Download stats + outbound tag - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "↓ ${Libbox.formatBytes(connection.download)}/s | ${Libbox.formatBytes(connection.downloadTotal)}", - style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = connection.chain.firstOrNull() ?: connection.outbound, - style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - } - - DropdownMenu( - expanded = showContextMenu, - onDismissRequest = { showContextMenu = false }, - ) { - if (connection.isActive) { - DropdownMenuItem( - text = { - Text( - stringResource(R.string.connection_close), - color = MaterialTheme.colorScheme.error, - ) - }, - leadingIcon = { - Icon( - Icons.Default.Close, - contentDescription = null, - tint = MaterialTheme.colorScheme.error, - ) - }, - onClick = { - showContextMenu = false - onClose() - }, - ) - } - } - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionsScreen.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionsScreen.kt deleted file mode 100644 index ee5be77c28..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionsScreen.kt +++ /dev/null @@ -1,594 +0,0 @@ -package io.nekohasekai.sfa.compose.screen.connections - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.expandVertically -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.shrinkVertically -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.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.Clear -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material.icons.filled.Search -import androidx.compose.material.icons.filled.SwapVert -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FilterChip -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -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.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.input.nestedscroll.NestedScrollConnection -import androidx.compose.ui.input.nestedscroll.NestedScrollSource -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.Velocity -import androidx.compose.ui.unit.dp -import androidx.lifecycle.viewmodel.compose.viewModel -import io.nekohasekai.sfa.R -import io.nekohasekai.sfa.compat.LazyColumnCompat -import io.nekohasekai.sfa.compat.rememberOverscrollEffectCompat -import io.nekohasekai.sfa.compose.model.Connection -import io.nekohasekai.sfa.compose.model.ConnectionSort -import io.nekohasekai.sfa.compose.model.ConnectionStateFilter -import io.nekohasekai.sfa.compose.topbar.OverrideTopBar -import io.nekohasekai.sfa.compose.util.rememberSheetDismissFromContentOnlyIfGestureStartedAtTopModifier -import io.nekohasekai.sfa.constant.Status - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun ConnectionsPage( - serviceStatus: Status, - viewModel: ConnectionsViewModel = viewModel(), - asSheet: Boolean = false, - showTitle: Boolean = true, - showTopBar: Boolean = false, - onConnectionClick: (String) -> Unit = {}, - modifier: Modifier = Modifier, -) { - val uiState by viewModel.uiState.collectAsState() - var showStateMenu by remember { mutableStateOf(false) } - var showSortMenu by remember { mutableStateOf(false) } - var showConnectionsMenu by remember { mutableStateOf(false) } - - if (showTopBar) { - OverrideTopBar { - TopAppBar( - title = { Text(stringResource(R.string.title_connections)) }, - ) - } - } - - val headerRowModifier = - if (asSheet) { - Modifier - .fillMaxWidth() - .padding(bottom = 16.dp) - } else { - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .padding(bottom = 16.dp) - } - - val headerContent: @Composable () -> Unit = { - Row( - modifier = headerRowModifier, - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - if (showTitle) { - Text( - text = stringResource(R.string.title_connections), - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - ) - } - - Spacer(modifier = Modifier.weight(1f)) - - Box { - FilterChip( - selected = uiState.stateFilter != ConnectionStateFilter.Active, - onClick = { showStateMenu = true }, - label = { - Text( - when (uiState.stateFilter) { - ConnectionStateFilter.All -> stringResource(R.string.connection_state_all) - ConnectionStateFilter.Active -> stringResource(R.string.connection_state_active) - ConnectionStateFilter.Closed -> stringResource(R.string.connection_state_closed) - }, - ) - }, - ) - - DropdownMenu( - expanded = showStateMenu, - onDismissRequest = { showStateMenu = false }, - ) { - DropdownMenuItem( - text = { Text(stringResource(R.string.connection_state_all)) }, - onClick = { - viewModel.setStateFilter(ConnectionStateFilter.All) - showStateMenu = false - }, - leadingIcon = { - if (uiState.stateFilter == ConnectionStateFilter.All) { - Icon(Icons.Default.Check, contentDescription = null) - } - }, - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.connection_state_active)) }, - onClick = { - viewModel.setStateFilter(ConnectionStateFilter.Active) - showStateMenu = false - }, - leadingIcon = { - if (uiState.stateFilter == ConnectionStateFilter.Active) { - Icon(Icons.Default.Check, contentDescription = null) - } - }, - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.connection_state_closed)) }, - onClick = { - viewModel.setStateFilter(ConnectionStateFilter.Closed) - showStateMenu = false - }, - leadingIcon = { - if (uiState.stateFilter == ConnectionStateFilter.Closed) { - Icon(Icons.Default.Check, contentDescription = null) - } - }, - ) - } - } - - Box { - IconButton(onClick = { showSortMenu = true }) { - Icon(Icons.Default.SwapVert, contentDescription = null) - } - - DropdownMenu( - expanded = showSortMenu, - onDismissRequest = { showSortMenu = false }, - ) { - DropdownMenuItem( - text = { Text(stringResource(R.string.connection_sort_date)) }, - onClick = { - viewModel.setSort(ConnectionSort.ByDate) - showSortMenu = false - }, - leadingIcon = { - if (uiState.sort == ConnectionSort.ByDate) { - Icon(Icons.Default.Check, contentDescription = null) - } - }, - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.connection_sort_traffic)) }, - onClick = { - viewModel.setSort(ConnectionSort.ByTraffic) - showSortMenu = false - }, - leadingIcon = { - if (uiState.sort == ConnectionSort.ByTraffic) { - Icon(Icons.Default.Check, contentDescription = null) - } - }, - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.connection_sort_traffic_total)) }, - onClick = { - viewModel.setSort(ConnectionSort.ByTrafficTotal) - showSortMenu = false - }, - leadingIcon = { - if (uiState.sort == ConnectionSort.ByTrafficTotal) { - Icon(Icons.Default.Check, contentDescription = null) - } - }, - ) - } - } - - Box { - IconButton(onClick = { showConnectionsMenu = true }) { - Icon(Icons.Default.MoreVert, contentDescription = null) - } - - DropdownMenu( - expanded = showConnectionsMenu, - onDismissRequest = { showConnectionsMenu = false }, - ) { - DropdownMenuItem( - text = { - Text( - if (uiState.isSearchActive) { - stringResource(R.string.close_search) - } else { - stringResource(R.string.search) - }, - ) - }, - onClick = { - viewModel.toggleSearch() - showConnectionsMenu = false - }, - leadingIcon = { - Icon( - imageVector = if (uiState.isSearchActive) Icons.Default.Close else Icons.Default.Search, - contentDescription = null, - ) - }, - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.connection_close_all)) }, - onClick = { - viewModel.closeAllConnections() - showConnectionsMenu = false - }, - leadingIcon = { - Icon(Icons.Default.Close, contentDescription = null) - }, - enabled = uiState.connections.any { it.isActive }, - ) - } - } - } - } - - if (asSheet) { - ConnectionsScreen( - serviceStatus = serviceStatus, - viewModel = viewModel, - onConnectionClick = { connection -> onConnectionClick(connection.id) }, - listHeaderContent = headerContent, - asSheet = true, - modifier = modifier.fillMaxSize(), - ) - } else { - Column( - modifier = modifier.fillMaxSize(), - ) { - headerContent() - ConnectionsScreen( - serviceStatus = serviceStatus, - viewModel = viewModel, - onConnectionClick = { connection -> onConnectionClick(connection.id) }, - modifier = Modifier.fillMaxSize(), - ) - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun ConnectionDetailsRoute( - connectionId: String, - serviceStatus: Status, - viewModel: ConnectionsViewModel = viewModel(), - onBack: () -> Unit, - modifier: Modifier = Modifier, -) { - val uiState by viewModel.uiState.collectAsState() - val connection = - uiState.allConnections.find { it.id == connectionId } - ?: uiState.connections.find { it.id == connectionId } - var cachedConnection by remember { mutableStateOf(connection) } - if (connection != null) { - cachedConnection = connection - } else if (cachedConnection?.isActive == true) { - cachedConnection = cachedConnection?.copy(closedAt = System.currentTimeMillis()) - } - - OverrideTopBar { - TopAppBar( - title = { Text(stringResource(R.string.connection_details)) }, - navigationIcon = { - IconButton(onClick = onBack) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.content_description_back), - ) - } - }, - actions = { - if (cachedConnection?.isActive == true) { - IconButton(onClick = { viewModel.closeConnection(connectionId) }) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = stringResource(R.string.connection_close), - ) - } - } - }, - ) - } - - LaunchedEffect(Unit) { - viewModel.setVisible(true) - } - - DisposableEffect(Unit) { - onDispose { - viewModel.setVisible(false) - } - } - - LaunchedEffect(serviceStatus) { - viewModel.updateServiceStatus(serviceStatus) - } - - if (cachedConnection == null) { - LaunchedEffect(connectionId) { - onBack() - } - Box(modifier = modifier.fillMaxSize()) - } else { - ConnectionDetailsScreen( - connection = cachedConnection!!, - onBack = onBack, - onClose = { viewModel.closeConnection(connectionId) }, - modifier = modifier, - showHeader = false, - ) - } -} - -@Composable -fun ConnectionsScreen( - serviceStatus: Status, - viewModel: ConnectionsViewModel = viewModel(), - onConnectionClick: (Connection) -> Unit = {}, - listHeaderContent: (@Composable () -> Unit)? = null, - asSheet: Boolean = false, - modifier: Modifier = Modifier, -) { - val uiState by viewModel.uiState.collectAsState() - - LaunchedEffect(Unit) { - viewModel.setVisible(true) - } - - DisposableEffect(Unit) { - onDispose { - viewModel.setVisible(false) - } - } - - LaunchedEffect(serviceStatus) { - viewModel.updateServiceStatus(serviceStatus) - } - - val lazyListState = rememberSaveable(saver = LazyListState.Saver) { LazyListState() } - - if (asSheet) { - val sheetSwipeToDismissModifier = - rememberSheetDismissFromContentOnlyIfGestureStartedAtTopModifier { - lazyListState.firstVisibleItemIndex == 0 && - lazyListState.firstVisibleItemScrollOffset == 0 - } - LazyColumnCompat( - modifier = - modifier - .fillMaxSize() - .then(sheetSwipeToDismissModifier), - state = lazyListState, - contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - overscrollEffect = null, - ) { - if (listHeaderContent != null) { - item(key = "connections_list_header") { - listHeaderContent() - } - } - - item(key = "connections_search") { - AnimatedVisibility( - visible = uiState.isSearchActive, - enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut(), - ) { - val focusRequester = remember { FocusRequester() } - - LaunchedEffect(Unit) { - focusRequester.requestFocus() - } - - OutlinedTextField( - value = uiState.searchText, - onValueChange = { viewModel.setSearchText(it) }, - modifier = - Modifier - .fillMaxWidth() - .padding(bottom = 8.dp) - .focusRequester(focusRequester), - placeholder = { Text(stringResource(R.string.search_connections)) }, - leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, - trailingIcon = { - if (uiState.searchText.isNotEmpty()) { - IconButton(onClick = { viewModel.setSearchText("") }) { - Icon(Icons.Default.Clear, contentDescription = null) - } - } - }, - singleLine = true, - ) - } - } - - when { - uiState.isLoading -> { - item(key = "connections_loading") { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 48.dp), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator() - } - } - } - - uiState.connections.isEmpty() -> { - item(key = "connections_empty") { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 48.dp), - contentAlignment = Alignment.Center, - ) { - Text( - text = stringResource(R.string.empty_connections), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - - else -> { - items( - items = uiState.connections, - key = { it.id }, - ) { connection -> - ConnectionItem( - connection = connection, - onClick = { onConnectionClick(connection) }, - onClose = { viewModel.closeConnection(connection.id) }, - ) - } - } - } - } - } else { - Column(modifier = modifier.fillMaxSize()) { - AnimatedVisibility( - visible = uiState.isSearchActive, - enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut(), - ) { - val focusRequester = remember { FocusRequester() } - - LaunchedEffect(Unit) { - focusRequester.requestFocus() - } - - OutlinedTextField( - value = uiState.searchText, - onValueChange = { viewModel.setSearchText(it) }, - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .padding(bottom = 8.dp) - .focusRequester(focusRequester), - placeholder = { Text(stringResource(R.string.search_connections)) }, - leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, - trailingIcon = { - if (uiState.searchText.isNotEmpty()) { - IconButton(onClick = { viewModel.setSearchText("") }) { - Icon(Icons.Default.Clear, contentDescription = null) - } - } - }, - singleLine = true, - ) - } - - when { - uiState.isLoading -> { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator() - } - } - - uiState.connections.isEmpty() -> { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - Text( - text = stringResource(R.string.empty_connections), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - - else -> { - val bounceBlockingConnection = rememberBounceBlockingNestedScrollConnection(lazyListState) - LazyColumnCompat( - modifier = - Modifier - .fillMaxSize() - .nestedScroll(bounceBlockingConnection), - state = lazyListState, - contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - overscrollEffect = rememberOverscrollEffectCompat(), - ) { - items( - items = uiState.connections, - key = { it.id }, - ) { connection -> - ConnectionItem( - connection = connection, - onClick = { onConnectionClick(connection) }, - onClose = { viewModel.closeConnection(connection.id) }, - ) - } - } - } - } - } - } -} - -@Composable -private fun rememberBounceBlockingNestedScrollConnection(lazyListState: LazyListState): NestedScrollConnection = remember(lazyListState) { - object : NestedScrollConnection { - override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset = if (available.y < 0) available else Offset.Zero - - override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity = if (available.y < 0) available else Velocity.Zero - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionsViewModel.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionsViewModel.kt deleted file mode 100644 index c54087c0bf..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionsViewModel.kt +++ /dev/null @@ -1,282 +0,0 @@ -package io.nekohasekai.sfa.compose.screen.connections - -import androidx.lifecycle.viewModelScope -import io.nekohasekai.libbox.ConnectionEvents -import io.nekohasekai.libbox.Connections -import io.nekohasekai.libbox.Libbox -import io.nekohasekai.sfa.compose.base.BaseViewModel -import io.nekohasekai.sfa.compose.base.ScreenEvent -import io.nekohasekai.sfa.compose.model.Connection -import io.nekohasekai.sfa.compose.model.ConnectionSort -import io.nekohasekai.sfa.compose.model.ConnectionStateFilter -import io.nekohasekai.sfa.constant.Status -import io.nekohasekai.sfa.ktx.toList -import io.nekohasekai.sfa.utils.AppLifecycleObserver -import io.nekohasekai.sfa.utils.CommandClient -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withContext -import java.util.concurrent.atomic.AtomicLong - -data class ConnectionsUiState( - val connections: List = emptyList(), - val allConnections: List = emptyList(), - val isLoading: Boolean = false, - val stateFilter: ConnectionStateFilter = ConnectionStateFilter.Active, - val sort: ConnectionSort = ConnectionSort.ByDate, - val searchText: String = "", - val isSearchActive: Boolean = false, -) - -sealed class ConnectionsEvent : ScreenEvent { - data class ConnectionClosed(val id: String) : ConnectionsEvent() - data object AllConnectionsClosed : ConnectionsEvent() -} - -class ConnectionsViewModel : - BaseViewModel(), - CommandClient.Handler { - private val commandClient = CommandClient( - viewModelScope, - CommandClient.ConnectionType.Connections, - this, - ) - - private val _serviceStatus = MutableStateFlow(Status.Stopped) - val serviceStatus = _serviceStatus.asStateFlow() - private var lastServiceStatus: Status = Status.Stopped - - private val _visibleCount = MutableStateFlow(0) - - private var connectionsStore: Connections? = null - private val connectionsMutex = Mutex() - private val connectionsGeneration = AtomicLong(0) - - override fun createInitialState() = ConnectionsUiState() - - private data class ConnectionState( - val foreground: Boolean, - val screenOn: Boolean, - val visibleCount: Int, - val status: Status, - ) - - init { - viewModelScope.launch { - combine( - AppLifecycleObserver.isForeground, - AppLifecycleObserver.isScreenOn, - _visibleCount, - _serviceStatus, - ) { foreground, screenOn, visibleCount, status -> - ConnectionState(foreground, screenOn, visibleCount, status) - }.collect { state -> - val shouldConnect = state.foreground && state.screenOn && - state.visibleCount > 0 && state.status == Status.Started - if (shouldConnect) { - updateState { copy(isLoading = true) } - commandClient.connect() - } else { - commandClient.disconnect() - } - } - } - } - - fun setVisible(visible: Boolean) { - _visibleCount.value += if (visible) 1 else -1 - } - - override fun onCleared() { - super.onCleared() - commandClient.disconnect() - } - - private suspend fun handleServiceStatusChange(status: Status) { - if (status != Status.Started) { - withContext(Dispatchers.Default) { - connectionsMutex.withLock { - connectionsStore = null - } - connectionsGeneration.incrementAndGet() - } - updateState { - copy(connections = emptyList(), allConnections = emptyList(), isLoading = false) - } - } - } - - fun updateServiceStatus(status: Status) { - if (status == lastServiceStatus) return - lastServiceStatus = status - viewModelScope.launch { - _serviceStatus.emit(status) - handleServiceStatusChange(status) - } - } - - fun setStateFilter(filter: ConnectionStateFilter) { - updateState { copy(stateFilter = filter) } - requestConnectionsRefresh() - } - - fun setSort(sort: ConnectionSort) { - updateState { copy(sort = sort) } - requestConnectionsRefresh() - } - - fun setSearchText(text: String) { - updateState { copy(searchText = text) } - requestConnectionsRefresh() - } - - fun toggleSearch() { - val newSearchActive = !currentState.isSearchActive - updateState { - copy( - isSearchActive = newSearchActive, - searchText = if (newSearchActive) searchText else "", - ) - } - if (!newSearchActive) { - requestConnectionsRefresh() - } - } - - fun closeConnection(connectionId: String) { - viewModelScope.launch(Dispatchers.IO) { - try { - Libbox.newStandaloneCommandClient().closeConnection(connectionId) - withContext(Dispatchers.Main) { - sendEvent(ConnectionsEvent.ConnectionClosed(connectionId)) - } - } catch (e: Exception) { - sendError(e) - } - } - } - - fun closeAllConnections() { - viewModelScope.launch(Dispatchers.IO) { - try { - Libbox.newStandaloneCommandClient().closeConnections() - withContext(Dispatchers.Main) { - sendEvent(ConnectionsEvent.AllConnectionsClosed) - } - } catch (e: Exception) { - sendError(e) - } - } - } - - override fun onConnected() { - viewModelScope.launch(Dispatchers.Main) { - updateState { copy(isLoading = false) } - } - } - - override fun onDisconnected() { - viewModelScope.launch(Dispatchers.Default) { - connectionsMutex.withLock { - connectionsStore = null - } - connectionsGeneration.incrementAndGet() - withContext(Dispatchers.Main) { - updateState { - copy(connections = emptyList(), allConnections = emptyList(), isLoading = false) - } - } - } - } - - override fun writeConnectionEvents(events: ConnectionEvents) { - viewModelScope.launch(Dispatchers.Default) { - val generation = connectionsGeneration.get() - val snapshot = connectionsMutex.withLock { - if (connectionsStore == null) { - connectionsStore = Connections() - } - val store = connectionsStore ?: return@withLock null - store.applyEvents(events) - buildConnectionLists(store, uiState.value) - } ?: return@launch - if (connectionsGeneration.get() != generation) { - return@launch - } - withContext(Dispatchers.Main) { - if (connectionsGeneration.get() != generation) { - return@withContext - } - updateState { - copy( - connections = snapshot.connections, - allConnections = snapshot.allConnections, - isLoading = false, - ) - } - } - } - } - - private fun requestConnectionsRefresh() { - viewModelScope.launch(Dispatchers.Default) { - val generation = connectionsGeneration.get() - val snapshot = connectionsMutex.withLock { - val store = connectionsStore ?: return@withLock null - buildConnectionLists(store, uiState.value) - } ?: return@launch - if (connectionsGeneration.get() != generation) { - return@launch - } - withContext(Dispatchers.Main) { - if (connectionsGeneration.get() != generation) { - return@withContext - } - updateState { - copy( - connections = snapshot.connections, - allConnections = snapshot.allConnections, - isLoading = false, - ) - } - } - } - } - - private fun buildConnectionLists( - connections: Connections, - currentState: ConnectionsUiState, - ): ConnectionLists { - val allConnectionList = connections.iterator().toList() - .filter { it.outboundType != "dns" } - .map { Connection.from(it) } - - connections.filterState(currentState.stateFilter.libboxValue) - - when (currentState.sort) { - ConnectionSort.ByDate -> connections.sortByDate() - ConnectionSort.ByTraffic -> connections.sortByTraffic() - ConnectionSort.ByTrafficTotal -> connections.sortByTrafficTotal() - } - - val connectionList = connections.iterator().toList() - .filter { it.outboundType != "dns" } - .map { Connection.from(it) } - .filter { it.performSearch(currentState.searchText) } - - return ConnectionLists( - connections = connectionList, - allConnections = allConnectionList, - ) - } - - private data class ConnectionLists( - val connections: List, - val allConnections: List, - ) -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/ClashModeCard.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/ClashModeCard.kt deleted file mode 100644 index 80d0072bb8..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/ClashModeCard.kt +++ /dev/null @@ -1,183 +0,0 @@ -package io.nekohasekai.sfa.compose.screen.dashboard - -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints -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.material.icons.Icons -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.UnfoldMore -import androidx.compose.material.icons.outlined.Tune -import androidx.compose.material3.Card -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.SegmentedButton -import androidx.compose.material3.SegmentedButtonDefaults -import androidx.compose.material3.SingleChoiceSegmentedButtonRow -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.platform.LocalDensity -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.rememberTextMeasurer -import androidx.compose.ui.unit.dp -import io.nekohasekai.sfa.R - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun ClashModeCard(modes: List, selectedMode: String, onModeSelected: (String) -> Unit, modifier: Modifier = Modifier) { - Card( - modifier = modifier.fillMaxWidth(), - ) { - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(16.dp), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = Icons.Outlined.Tune, - contentDescription = null, - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.primary, - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = stringResource(R.string.mode), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - ) - } - - Spacer(modifier = Modifier.height(12.dp)) - - BoxWithConstraints( - modifier = Modifier.fillMaxWidth(), - ) { - val textMeasurer = rememberTextMeasurer() - val textStyle = MaterialTheme.typography.labelLarge - val density = LocalDensity.current - - val totalTextWidth = remember(modes, textStyle, density) { - modes.sumOf { mode -> - textMeasurer.measure(mode, textStyle).size.width - } - } - val buttonPadding = with(density) { (24.dp * modes.size).roundToPx() } - val estimatedWidth = totalTextWidth + buttonPadding - val availableWidth = with(density) { maxWidth.roundToPx() } - - val useDropdown = estimatedWidth > availableWidth - - if (useDropdown) { - ModeDropdown( - modes = modes, - selectedMode = selectedMode, - onModeSelected = onModeSelected, - ) - } else { - SingleChoiceSegmentedButtonRow( - modifier = Modifier.fillMaxWidth(), - ) { - modes.forEachIndexed { index, mode -> - SegmentedButton( - shape = - SegmentedButtonDefaults.itemShape( - index = index, - count = modes.size, - ), - onClick = { onModeSelected(mode) }, - selected = mode == selectedMode, - ) { - Text(mode) - } - } - } - } - } - } - } -} - -@Composable -private fun ModeDropdown(modes: List, selectedMode: String, onModeSelected: (String) -> Unit) { - var expanded by remember { mutableStateOf(false) } - - Box(modifier = Modifier.fillMaxWidth()) { - Surface( - onClick = { expanded = true }, - shape = RoundedCornerShape(12.dp), - color = if (isSystemInDarkTheme()) { - MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) - } else { - MaterialTheme.colorScheme.surfaceDim - }, - modifier = Modifier.fillMaxWidth(), - ) { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = selectedMode, - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.weight(1f), - ) - Icon( - imageVector = Icons.Default.UnfoldMore, - contentDescription = null, - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - - DropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false }, - ) { - modes.forEach { mode -> - DropdownMenuItem( - text = { Text(mode) }, - onClick = { - onModeSelected(mode) - expanded = false - }, - leadingIcon = { - if (mode == selectedMode) { - Icon( - imageVector = Icons.Default.Check, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - } - }, - ) - } - } - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/ConnectionsCard.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/ConnectionsCard.kt deleted file mode 100644 index 572338b0d3..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/ConnectionsCard.kt +++ /dev/null @@ -1,94 +0,0 @@ -package io.nekohasekai.sfa.compose.screen.dashboard - -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.material.icons.Icons -import androidx.compose.material.icons.outlined.Cable -import androidx.compose.material3.Card -import androidx.compose.material3.Icon -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.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import io.nekohasekai.sfa.R - -@Composable -fun ConnectionsCard(connectionsIn: String, connectionsOut: String, modifier: Modifier = Modifier) { - Card( - modifier = modifier.fillMaxWidth(), - ) { - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(16.dp), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = Icons.Outlined.Cable, - contentDescription = null, - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.primary, - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = stringResource(R.string.title_connections), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - ) - } - Spacer(modifier = Modifier.height(12.dp)) - - // Inbound connections - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringResource(R.string.connections_in), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = connectionsIn, - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Medium, - ) - } - - Spacer(modifier = Modifier.height(8.dp)) - - // Outbound connections - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringResource(R.string.connections_out), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = connectionsOut, - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Medium, - ) - } - } - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardCardRenderer.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardCardRenderer.kt deleted file mode 100644 index 5c57f20532..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardCardRenderer.kt +++ /dev/null @@ -1,130 +0,0 @@ -package io.nekohasekai.sfa.compose.screen.dashboard - -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import io.nekohasekai.sfa.compose.navigation.NewProfileArgs -import io.nekohasekai.sfa.constant.Status -import io.nekohasekai.sfa.database.Profile -import io.nekohasekai.sfa.utils.CommandClient - -@Composable -fun DashboardCardRenderer( - cardGroup: CardGroup, - cardWidth: CardWidth, - uiState: DashboardUiState, - serviceStatus: Status = Status.Stopped, - onClashModeSelected: (String) -> Unit, - onSystemProxyToggle: (Boolean) -> Unit, - // Profile card specific props - profiles: List = emptyList(), - selectedProfileId: Long = -1L, - isLoading: Boolean = false, - showAddProfileSheet: Boolean = false, - showProfilePickerSheet: Boolean = false, - updatingProfileId: Long? = null, - updatedProfileId: Long? = null, - onProfileSelected: (Long) -> Unit = {}, - onProfileEdit: (Profile) -> Unit = {}, - onProfileDelete: (Profile) -> Unit = {}, - onProfileShare: (Profile) -> Unit = {}, - onProfileShareURL: (Profile) -> Unit = {}, - onProfileUpdate: (Profile) -> Unit = {}, - onProfileMove: (Int, Int) -> Unit = { _, _ -> }, - onShowAddProfileSheet: () -> Unit = {}, - onHideAddProfileSheet: () -> Unit = {}, - onShowProfilePickerSheet: () -> Unit = {}, - onHideProfilePickerSheet: () -> Unit = {}, - onOpenNewProfile: (NewProfileArgs) -> Unit = {}, - commandClient: CommandClient? = null, - modifier: Modifier = Modifier, -) { - when (cardGroup) { - CardGroup.ClashMode -> { - if (uiState.clashModeVisible) { - ClashModeCard( - modes = uiState.clashModes, - selectedMode = uiState.selectedClashMode, - onModeSelected = onClashModeSelected, - modifier = modifier, - ) - } - } - - CardGroup.UploadTraffic -> { - if (uiState.trafficVisible) { - UploadTrafficCard( - uplink = uiState.uplink, - uplinkTotal = uiState.uplinkTotal, - uplinkHistory = uiState.uplinkHistory, - modifier = modifier, - ) - } - } - - CardGroup.DownloadTraffic -> { - if (uiState.trafficVisible) { - DownloadTrafficCard( - downlink = uiState.downlink, - downlinkTotal = uiState.downlinkTotal, - downlinkHistory = uiState.downlinkHistory, - modifier = modifier, - ) - } - } - - CardGroup.Debug -> { - if (uiState.isStatusVisible) { - DebugCard( - memory = uiState.memory, - goroutines = uiState.goroutines, - modifier = modifier, - ) - } - } - - CardGroup.Connections -> { - if (uiState.trafficVisible) { - ConnectionsCard( - connectionsIn = uiState.connectionsIn, - connectionsOut = uiState.connectionsOut, - modifier = modifier, - ) - } - } - - CardGroup.SystemProxy -> { - if (uiState.systemProxyVisible) { - SystemProxyCard( - enabled = uiState.systemProxyEnabled, - isSwitching = uiState.systemProxySwitching, - onToggle = onSystemProxyToggle, - modifier = modifier, - ) - } - } - - CardGroup.Profiles -> { - ProfilesCard( - profiles = profiles, - selectedProfileId = selectedProfileId, - isLoading = isLoading, - showAddProfileSheet = showAddProfileSheet, - showProfilePickerSheet = showProfilePickerSheet, - updatingProfileId = updatingProfileId, - updatedProfileId = updatedProfileId, - onProfileSelected = onProfileSelected, - onProfileEdit = onProfileEdit, - onProfileDelete = onProfileDelete, - onProfileShare = onProfileShare, - onProfileShareURL = onProfileShareURL, - onProfileUpdate = onProfileUpdate, - onProfileMove = onProfileMove, - onShowAddProfileSheet = onShowAddProfileSheet, - onHideAddProfileSheet = onHideAddProfileSheet, - onShowProfilePickerSheet = onShowProfilePickerSheet, - onHideProfilePickerSheet = onHideProfilePickerSheet, - onOpenNewProfile = onOpenNewProfile, - ) - } - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardScreen.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardScreen.kt deleted file mode 100644 index b13ea3825d..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardScreen.kt +++ /dev/null @@ -1,314 +0,0 @@ -package io.nekohasekai.sfa.compose.screen.dashboard - -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.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberCoroutineScope -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.viewmodel.compose.viewModel -import io.nekohasekai.sfa.R -import io.nekohasekai.sfa.compose.base.UiEvent -import io.nekohasekai.sfa.compose.navigation.NewProfileArgs -import io.nekohasekai.sfa.compose.topbar.OverrideTopBar -import io.nekohasekai.sfa.constant.Status -import kotlinx.coroutines.launch - -data class CardRenderItem(val cards: List, val isRow: Boolean) - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun DashboardScreen( - serviceStatus: Status = Status.Stopped, - showStartFab: Boolean = false, - showStatusBar: Boolean = false, - onOpenNewProfile: (NewProfileArgs) -> Unit = {}, - viewModel: DashboardViewModel = viewModel(), -) { - val uiState by viewModel.uiState.collectAsState() - - OverrideTopBar { - TopAppBar( - title = { Text(stringResource(R.string.title_dashboard)) }, - actions = { - IconButton(onClick = { viewModel.toggleCardSettingsDialog() }) { - Icon( - imageVector = Icons.Default.MoreVert, - contentDescription = stringResource(R.string.title_others), - ) - } - }, - ) - } - - // Update service status in ViewModel - LaunchedEffect(serviceStatus) { - viewModel.updateServiceStatus(serviceStatus) - } - - // Events are now handled globally in ComposeActivity via GlobalEventBus - - // Show deprecated notes dialog - if (uiState.showDeprecatedDialog && uiState.deprecatedNotes.isNotEmpty()) { - val note = uiState.deprecatedNotes.first() - AlertDialog( - onDismissRequest = { }, - title = { Text(stringResource(R.string.error_deprecated_warning)) }, - text = { Text(note.message) }, - confirmButton = { - TextButton(onClick = { viewModel.dismissDeprecatedNote() }) { - Text(stringResource(R.string.ok)) - } - }, - dismissButton = - if (!note.migrationLink.isNullOrBlank()) { - { - TextButton(onClick = { - viewModel.sendGlobalEvent(UiEvent.OpenUrl(note.migrationLink)) - viewModel.dismissDeprecatedNote() - }) { - Text(stringResource(R.string.error_deprecated_documentation)) - } - } - } else { - null - }, - ) - } - - val sheetState = rememberModalBottomSheetState() - val scope = rememberCoroutineScope() - val context = LocalContext.current - - // Show dashboard settings bottom sheet - if (uiState.showCardSettingsDialog) { - DashboardSettingsBottomSheet( - sheetState = sheetState, - visibleCards = uiState.visibleCards, - cardOrder = uiState.cardOrder, - onToggleCard = viewModel::toggleCardVisibility, - onReorderCards = viewModel::reorderCards, - onResetOrder = viewModel::resetCardOrder, - onDismiss = { - scope.launch { - sheetState.hide() - viewModel.closeCardSettingsDialog() - } - }, - ) - } - - Box( - modifier = Modifier.fillMaxSize(), - ) { - val bottomPadding = when { - showStartFab -> 88.dp - showStatusBar -> 74.dp - else -> 0.dp - } - LazyColumn( - modifier = - Modifier - .fillMaxSize() - .padding(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - contentPadding = PaddingValues(bottom = bottomPadding), - ) { - // Dynamic dashboard cards - // Show cards when service is running OR if it's the Profiles card (always available) - val serviceRunning = uiState.isStatusVisible - - // Filter cards based on availability - val actuallyVisibleCards = - uiState.visibleCards.filter { cardGroup -> - when (cardGroup) { - CardGroup.Profiles -> true // Profiles card is always available - else -> serviceRunning && isCardAvailableWhenServiceRunning(cardGroup, uiState) - } - }.toSet() - - // Process cards to group half-width cards together - val cardRenderItems = - processCardsForRendering( - cardOrder = uiState.cardOrder, - visibleCards = actuallyVisibleCards, - cardWidths = uiState.cardWidths, - ) - - items(cardRenderItems) { renderItem -> - if (renderItem.isRow && renderItem.cards.size >= 2) { - // Render two half-width cards in a row - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(16.dp), - ) { - renderItem.cards.forEach { cardGroup -> - DashboardCardRenderer( - cardGroup = cardGroup, - cardWidth = - uiState.cardWidths[cardGroup] - ?: CardWidth.Full, - uiState = uiState, - onClashModeSelected = viewModel::selectClashMode, - onSystemProxyToggle = viewModel::toggleSystemProxy, - // Profile card specific props - profiles = uiState.profiles, - selectedProfileId = uiState.selectedProfileId, - isLoading = uiState.isLoading, - showAddProfileSheet = uiState.showAddProfileSheet, - showProfilePickerSheet = uiState.showProfilePickerSheet, - updatingProfileId = uiState.updatingProfileId, - updatedProfileId = uiState.updatedProfileId, - onProfileSelected = viewModel::selectProfile, - onProfileEdit = viewModel::editProfile, - onProfileDelete = viewModel::deleteProfile, - onProfileShare = viewModel::shareProfile, - onProfileShareURL = viewModel::shareProfileURL, - onProfileUpdate = viewModel::updateProfile, - onProfileMove = viewModel::moveProfile, - onShowAddProfileSheet = viewModel::showAddProfileSheet, - onHideAddProfileSheet = viewModel::hideAddProfileSheet, - onShowProfilePickerSheet = viewModel::showProfilePickerSheet, - onHideProfilePickerSheet = viewModel::hideProfilePickerSheet, - onOpenNewProfile = onOpenNewProfile, - commandClient = viewModel.commandClient, - modifier = - Modifier - .weight(1f) - .fillMaxWidth(), - ) - } - } - } else { - // Render single card (full-width or single half-width) - renderItem.cards.forEach { cardGroup -> - DashboardCardRenderer( - cardGroup = cardGroup, - cardWidth = - uiState.cardWidths[cardGroup] - ?: CardWidth.Full, - uiState = uiState, - serviceStatus = serviceStatus, - onClashModeSelected = viewModel::selectClashMode, - onSystemProxyToggle = viewModel::toggleSystemProxy, - // Profile card specific props - profiles = uiState.profiles, - selectedProfileId = uiState.selectedProfileId, - isLoading = uiState.isLoading, - showAddProfileSheet = uiState.showAddProfileSheet, - showProfilePickerSheet = uiState.showProfilePickerSheet, - updatingProfileId = uiState.updatingProfileId, - updatedProfileId = uiState.updatedProfileId, - onProfileSelected = viewModel::selectProfile, - onProfileEdit = viewModel::editProfile, - onProfileDelete = viewModel::deleteProfile, - onProfileShare = viewModel::shareProfile, - onProfileShareURL = viewModel::shareProfileURL, - onProfileUpdate = viewModel::updateProfile, - onProfileMove = viewModel::moveProfile, - onShowAddProfileSheet = viewModel::showAddProfileSheet, - onHideAddProfileSheet = viewModel::hideAddProfileSheet, - onShowProfilePickerSheet = viewModel::showProfilePickerSheet, - onHideProfilePickerSheet = viewModel::hideProfilePickerSheet, - onOpenNewProfile = onOpenNewProfile, - commandClient = viewModel.commandClient, - ) - } - } - } - } - } -} - -/** - * Process cards for rendering, grouping consecutive half-width cards into rows - */ -fun processCardsForRendering( - cardOrder: List, - visibleCards: Set, - cardWidths: Map, -): List { - val renderItems = mutableListOf() - val visibleOrderedCards = cardOrder.filter { visibleCards.contains(it) } - - var i = 0 - while (i < visibleOrderedCards.size) { - val currentCard = visibleOrderedCards[i] - val currentWidth = cardWidths[currentCard] ?: CardWidth.Full - - if (currentWidth == CardWidth.Half) { - // Check if next card is also half-width - if (i + 1 < visibleOrderedCards.size) { - val nextCard = visibleOrderedCards[i + 1] - val nextWidth = cardWidths[nextCard] ?: CardWidth.Full - - if (nextWidth == CardWidth.Half) { - // Group two half-width cards together - renderItems.add( - CardRenderItem( - cards = listOf(currentCard, nextCard), - isRow = true, - ), - ) - i += 2 - continue - } - } - // Single half-width card - renderItems.add( - CardRenderItem( - cards = listOf(currentCard), - isRow = false, - ), - ) - } else { - // Full-width card - renderItems.add( - CardRenderItem( - cards = listOf(currentCard), - isRow = false, - ), - ) - } - i++ - } - - return renderItems -} - -/** - * Determine if a service-dependent card has data available to display. - * This function is only relevant when the service is running. - * Note: Profiles card is always available and should not use this function. - */ -fun isCardAvailableWhenServiceRunning(cardGroup: CardGroup, uiState: DashboardUiState): Boolean = when (cardGroup) { - CardGroup.ClashMode -> uiState.clashModeVisible - CardGroup.UploadTraffic -> uiState.trafficVisible - CardGroup.DownloadTraffic -> uiState.trafficVisible - CardGroup.Debug -> true // Debug info is always available when service is running - CardGroup.Connections -> uiState.trafficVisible - CardGroup.SystemProxy -> uiState.systemProxyVisible - CardGroup.Profiles -> true // This shouldn't be called for Profiles, but return true for safety -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardSettingsBottomSheet.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardSettingsBottomSheet.kt deleted file mode 100644 index 12ec111b0a..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardSettingsBottomSheet.kt +++ /dev/null @@ -1,437 +0,0 @@ -package io.nekohasekai.sfa.compose.screen.dashboard - -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.animateDpAsState -import androidx.compose.animation.core.spring -import androidx.compose.animation.core.tween -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.gestures.Orientation -import androidx.compose.foundation.gestures.draggable -import androidx.compose.foundation.gestures.rememberDraggableState -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.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.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.DragHandle -import androidx.compose.material.icons.filled.RestartAlt -import androidx.compose.material.icons.outlined.BugReport -import androidx.compose.material.icons.outlined.Cable -import androidx.compose.material.icons.outlined.Download -import androidx.compose.material.icons.outlined.Person -import androidx.compose.material.icons.outlined.Route -import androidx.compose.material.icons.outlined.SettingsEthernet -import androidx.compose.material.icons.outlined.Upload -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.ModalBottomSheet -import androidx.compose.material3.SheetState -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.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.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.zIndex -import io.nekohasekai.sfa.R -import io.nekohasekai.sfa.compat.animateItemCompat - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun DashboardSettingsBottomSheet( - sheetState: SheetState, - visibleCards: Set, - cardOrder: List, - onToggleCard: (CardGroup) -> Unit, - onReorderCards: (List) -> Unit, - onResetOrder: () -> Unit, - onDismiss: () -> Unit, -) { - var reorderedList by remember(cardOrder) { mutableStateOf(cardOrder) } - var currentVisibleCards by remember(visibleCards) { mutableStateOf(visibleCards) } - - // Update local state when props change (e.g., after reset) - LaunchedEffect(cardOrder, visibleCards) { - reorderedList = cardOrder - currentVisibleCards = visibleCards - } - - val hapticFeedback = LocalHapticFeedback.current - val scope = rememberCoroutineScope() - val listState = rememberLazyListState() - - // Dragging state - var draggedItem by remember { mutableStateOf(null) } - var draggedIndex by remember { mutableStateOf(-1) } - var dragOffset by remember { mutableStateOf(0f) } - val density = LocalDensity.current - - fun onMove(fromIndex: Int, toIndex: Int) { - if (fromIndex != toIndex && - fromIndex >= 0 && - toIndex >= 0 && - fromIndex < reorderedList.size && - toIndex < reorderedList.size - ) { - val newList = reorderedList.toMutableList() - val item = newList.removeAt(fromIndex) - newList.add(toIndex, item) - reorderedList = newList - hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove) - } - } - - ModalBottomSheet( - onDismissRequest = { - if (reorderedList != cardOrder) { - onReorderCards(reorderedList) - } - onDismiss() - }, - sheetState = sheetState, - containerColor = MaterialTheme.colorScheme.surface, - contentColor = MaterialTheme.colorScheme.onSurface, - dragHandle = { - Surface( - modifier = Modifier.padding(vertical = 12.dp), - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), - shape = RoundedCornerShape(16.dp), - ) { - Box( - modifier = Modifier.size(width = 48.dp, height = 4.dp), - ) - } - }, - ) { - Column( - modifier = - Modifier - .fillMaxWidth() - .fillMaxHeight(0.8f), - ) { - // Header with reset button - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp) - .padding(bottom = 16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringResource(R.string.dashboard_items), - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onSurface, - ) - TextButton( - onClick = { - val defaultOrder = - listOfNotNull( - CardGroup.UploadTraffic, - CardGroup.DownloadTraffic, - CardGroup.Debug, - CardGroup.Connections, - CardGroup.SystemProxy, - CardGroup.ClashMode, - CardGroup.Profiles, - ) - val allCardsEnabled = - setOfNotNull( - CardGroup.ClashMode, - CardGroup.UploadTraffic, - CardGroup.DownloadTraffic, - CardGroup.Debug, - CardGroup.Connections, - CardGroup.SystemProxy, - CardGroup.Profiles, - ) - reorderedList = defaultOrder - currentVisibleCards = allCardsEnabled - onResetOrder() - hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) - }, - ) { - Icon( - imageVector = Icons.Default.RestartAlt, - contentDescription = stringResource(R.string.reset_order), - modifier = Modifier.size(20.dp), - ) - Spacer(modifier = Modifier.width(4.dp)) - Text(stringResource(R.string.reset)) - } - } - - // Instruction text - Text( - text = stringResource(R.string.drag_handle_to_reorder), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = - Modifier - .padding(horizontal = 24.dp) - .padding(bottom = 12.dp), - ) - - // Reorderable list - LazyColumn( - state = listState, - modifier = - Modifier - .fillMaxWidth() - .weight(1f), - contentPadding = PaddingValues(horizontal = 24.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - itemsIndexed( - items = reorderedList, - key = { _, item -> item }, - ) { index, cardGroup -> - val isVisible = currentVisibleCards.contains(cardGroup) - val isDragging = draggedIndex == index - - DashboardItemCard( - cardGroup = cardGroup, - isVisible = isVisible, - isDragging = isDragging, - dragOffset = if (isDragging) dragOffset else 0f, - onToggleVisibility = { - currentVisibleCards = - if (currentVisibleCards.contains(cardGroup)) { - currentVisibleCards - cardGroup - } else { - currentVisibleCards + cardGroup - } - onToggleCard(cardGroup) - }, - onDragStart = { - draggedItem = cardGroup - draggedIndex = index - dragOffset = 0f - hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) - }, - onDrag = { delta -> - if (draggedIndex == index) { - dragOffset += delta - - // Calculate target index based on drag offset - val itemHeight = with(density) { 80.dp.toPx() } - val threshold = itemHeight * 0.5f - - when { - dragOffset < -threshold && draggedIndex > 0 -> { - // Moving up - onMove(draggedIndex, draggedIndex - 1) - draggedIndex -= 1 - dragOffset += itemHeight - } - - dragOffset > threshold && draggedIndex < reorderedList.size - 1 -> { - // Moving down - onMove(draggedIndex, draggedIndex + 1) - draggedIndex += 1 - dragOffset -= itemHeight - } - } - } - }, - onDragEnd = { - if (reorderedList != cardOrder) { - onReorderCards(reorderedList) - } - draggedItem = null - draggedIndex = -1 - dragOffset = 0f - }, - modifier = - animateItemCompat( - placementSpec = - spring( - dampingRatio = Spring.DampingRatioMediumBouncy, - stiffness = Spring.StiffnessLow, - ), - ), - ) - } - } - } - } -} - -@Composable -fun DashboardItemCard( - cardGroup: CardGroup, - isVisible: Boolean, - isDragging: Boolean, - dragOffset: Float, - onToggleVisibility: () -> Unit, - onDragStart: () -> Unit, - onDrag: (Float) -> Unit, - onDragEnd: () -> Unit, - modifier: Modifier = Modifier, -) { - val offsetY = remember { mutableStateOf(0f) } - - LaunchedEffect(dragOffset) { - offsetY.value = dragOffset - } - - val cardElevation by animateDpAsState( - targetValue = if (isDragging) 6.dp else 1.dp, - animationSpec = tween(durationMillis = 300), - label = "elevation", - ) - - Card( - modifier = - modifier - .fillMaxWidth() - .offset(y = with(LocalDensity.current) { offsetY.value.toDp() }) - .zIndex(if (isDragging) 1f else 0f) - .clip(RoundedCornerShape(12.dp)), - elevation = - CardDefaults.cardElevation( - defaultElevation = cardElevation, - ), - colors = - CardDefaults.cardColors( - containerColor = - if (isDragging) { - MaterialTheme.colorScheme.surface.copy(alpha = 0.95f) - } else { - MaterialTheme.colorScheme.surface - }, - ), - border = - BorderStroke( - width = 1.dp, - color = - if (isVisible) { - MaterialTheme.colorScheme.primary.copy(alpha = 0.3f) - } else { - MaterialTheme.colorScheme.outline.copy(alpha = 0.12f) - }, - ), - ) { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - // Drag handle - val draggableState = - rememberDraggableState { delta -> - onDrag(delta) - } - - Icon( - imageVector = Icons.Default.DragHandle, - contentDescription = stringResource(R.string.drag_to_reorder), - modifier = - Modifier - .size(24.dp) - .draggable( - state = draggableState, - orientation = Orientation.Vertical, - onDragStarted = { onDragStart() }, - onDragStopped = { onDragEnd() }, - ) - .padding(4.dp), - tint = - if (isDragging) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - ) - - // Card icon - Icon( - imageVector = - when (cardGroup) { - CardGroup.Debug -> Icons.Outlined.BugReport - CardGroup.Connections -> Icons.Outlined.Cable - CardGroup.UploadTraffic -> Icons.Outlined.Upload - CardGroup.DownloadTraffic -> Icons.Outlined.Download - CardGroup.ClashMode -> Icons.Outlined.Route - CardGroup.SystemProxy -> Icons.Outlined.SettingsEthernet - CardGroup.Profiles -> Icons.Outlined.Person - }, - contentDescription = null, - modifier = - Modifier - .size(24.dp) - .padding(horizontal = 4.dp), - tint = - if (isVisible) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - ) - - // Card info - Column( - modifier = - Modifier - .weight(1f) - .padding(horizontal = 8.dp), - ) { - Text( - text = - when (cardGroup) { - CardGroup.Debug -> stringResource(R.string.title_debug) - CardGroup.Connections -> stringResource(R.string.title_connections) - CardGroup.UploadTraffic -> stringResource(R.string.upload) - CardGroup.DownloadTraffic -> stringResource(R.string.download) - CardGroup.ClashMode -> stringResource(R.string.clash_mode) - CardGroup.SystemProxy -> stringResource(R.string.system_proxy) - CardGroup.Profiles -> stringResource(R.string.title_configuration) - }, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onSurface, - ) - } - - // Visibility toggle - Profiles card cannot be disabled - Switch( - checked = isVisible, - onCheckedChange = { onToggleVisibility() }, - enabled = cardGroup != CardGroup.Profiles, // Disable switch for Profiles card - ) - } - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardViewModel.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardViewModel.kt deleted file mode 100644 index 943eca0595..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardViewModel.kt +++ /dev/null @@ -1,766 +0,0 @@ -package io.nekohasekai.sfa.compose.screen.dashboard - -import androidx.lifecycle.viewModelScope -import io.nekohasekai.libbox.Libbox -import io.nekohasekai.libbox.OutboundGroup -import io.nekohasekai.libbox.StatusMessage -import io.nekohasekai.sfa.bg.BoxService -import io.nekohasekai.sfa.compose.base.BaseViewModel -import io.nekohasekai.sfa.compose.base.UiEvent -import io.nekohasekai.sfa.constant.Status -import io.nekohasekai.sfa.database.Profile -import io.nekohasekai.sfa.database.ProfileManager -import io.nekohasekai.sfa.database.Settings -import io.nekohasekai.sfa.database.TypedProfile -import io.nekohasekai.sfa.utils.AppLifecycleObserver -import io.nekohasekai.sfa.utils.CommandClient -import io.nekohasekai.sfa.utils.HTTPClient -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.launch -import kotlinx.coroutines.withContext -import org.json.JSONArray -import org.json.JSONException -import java.io.File -import java.util.Collections -import java.util.Date - -enum class CardGroup { - ClashMode, - UploadTraffic, - DownloadTraffic, - Debug, - Connections, - SystemProxy, - Profiles, -} - -enum class CardWidth { - Half, - Full, -} - -data class DashboardUiState( - val serviceStatus: Status = Status.Stopped, - val profiles: List = emptyList(), - val selectedProfileId: Long = -1L, - val selectedProfileName: String? = null, - val isLoading: Boolean = false, - val hasGroups: Boolean = false, - val groupsCount: Int = 0, - val connectionsCount: Int = 0, - val serviceStartTime: Long? = null, - val deprecatedNotes: List = emptyList(), - val showDeprecatedDialog: Boolean = false, - val showAddProfileSheet: Boolean = false, - val showProfilePickerSheet: Boolean = false, - val updatingProfileId: Long? = null, - val updatedProfileId: Long? = null, - // Status - val memory: String = "", - val goroutines: String = "", - val isStatusVisible: Boolean = false, - // Traffic - val trafficVisible: Boolean = false, - val connectionsIn: String = "0", - val connectionsOut: String = "0", - val uplink: String = "0 B/s", - val downlink: String = "0 B/s", - val uplinkTotal: String = "0 B", - val downlinkTotal: String = "0 B", - val uplinkHistory: List = List(30) { 0f }, - val downlinkHistory: List = List(30) { 0f }, - // Clash Mode - val clashModeVisible: Boolean = false, - val clashModes: List = emptyList(), - val selectedClashMode: String = "", - // System Proxy - val systemProxyVisible: Boolean = false, - val systemProxyEnabled: Boolean = false, - val systemProxySwitching: Boolean = false, - // Card visibility settings - val visibleCards: Set = - setOf( - CardGroup.ClashMode, - CardGroup.UploadTraffic, - CardGroup.DownloadTraffic, - CardGroup.Debug, - CardGroup.Connections, - CardGroup.SystemProxy, - CardGroup.Profiles, - ), - val cardOrder: List = - listOf( - CardGroup.UploadTraffic, - CardGroup.DownloadTraffic, - CardGroup.Debug, - CardGroup.Connections, - CardGroup.SystemProxy, - CardGroup.ClashMode, - CardGroup.Profiles, - ), - val cardWidths: Map = - mapOf( - CardGroup.ClashMode to CardWidth.Full, - CardGroup.UploadTraffic to CardWidth.Half, - CardGroup.DownloadTraffic to CardWidth.Half, - CardGroup.Debug to CardWidth.Half, - CardGroup.Connections to CardWidth.Half, - CardGroup.SystemProxy to CardWidth.Full, - CardGroup.Profiles to CardWidth.Full, - ), - val showCardSettingsDialog: Boolean = false, -) { - data class DeprecatedNote(val message: String, val migrationLink: String?) -} - -// DashboardViewModel now only uses UiEvent for all events -// No need for DashboardEvent anymore as all events are handled globally - -class DashboardViewModel : - BaseViewModel(), - CommandClient.Handler { - private val _serviceStatus = MutableStateFlow(Status.Stopped) - val serviceStatus: StateFlow = _serviceStatus.asStateFlow() - - internal val commandClient = - CommandClient( - viewModelScope, - listOf( - CommandClient.ConnectionType.Status, - CommandClient.ConnectionType.ClashMode, - CommandClient.ConnectionType.Groups, - ), - this, - ) - - override fun createInitialState(): DashboardUiState { - val savedOrder = loadItemOrder() - val disabledItems = loadDisabledItems() - - // Calculate visible items (all items minus disabled) - val allItems = CardGroup.values().toSet() - val visibleCards = allItems - disabledItems - - return DashboardUiState( - cardOrder = savedOrder, - visibleCards = visibleCards, - ) - } - - init { - loadProfiles() - ProfileManager.registerCallback(::onProfilesChanged) - - viewModelScope.launch { - AppLifecycleObserver.isForeground.collect { foreground -> - if (_serviceStatus.value != Status.Started) return@collect - if (foreground) { - commandClient.connect() - } else { - commandClient.disconnect() - } - } - } - } - - override fun onCleared() { - super.onCleared() - ProfileManager.unregisterCallback(::onProfilesChanged) - commandClient.disconnect() - } - - private fun onProfilesChanged() { - loadProfiles() - } - - private fun loadProfiles() { - viewModelScope.launch(Dispatchers.IO) { - try { - val profiles = ProfileManager.list() - val selectedId = Settings.selectedProfile - - withContext(Dispatchers.Main) { - updateState { - copy( - profiles = profiles, - selectedProfileId = selectedId, - selectedProfileName = profiles.find { it.id == selectedId }?.name, - ) - } - } - } catch (e: Exception) { - sendError(e) - } - } - } - - private fun checkDeprecatedNotes() { - viewModelScope.launch(Dispatchers.IO) { - try { - // Check if deprecated warnings are disabled - if (Settings.disableDeprecatedWarnings) { - return@launch - } - - val notes = Libbox.newStandaloneCommandClient().deprecatedNotes - if (notes.hasNext()) { - val notesList = mutableListOf() - while (notes.hasNext()) { - val note = notes.next() - notesList.add( - DashboardUiState.DeprecatedNote( - message = note.message(), - migrationLink = note.migrationLink, - ), - ) - } - withContext(Dispatchers.Main) { - updateState { - copy( - deprecatedNotes = notesList, - showDeprecatedDialog = notesList.isNotEmpty(), - ) - } - } - } - } catch (e: Exception) { - sendError(e) - } - } - } - - fun toggleService() { - when (currentState.serviceStatus) { - Status.Starting, Status.Started -> stopService() - Status.Stopped -> sendGlobalEvent(UiEvent.RequestStartService) - else -> { /* Ignore while transitioning */ } - } - } - - private fun stopService() { - viewModelScope.launch(Dispatchers.IO) { - try { - BoxService.stop() - // Status will be updated via updateServiceStatus callback - } catch (e: Exception) { - sendError(e) - } - } - } - - fun dismissDeprecatedNote() { - val notes = currentState.deprecatedNotes - if (notes.isNotEmpty()) { - updateState { - copy( - deprecatedNotes = notes.drop(1), - showDeprecatedDialog = notes.size > 1, - ) - } - } - } - - fun selectProfile(profileId: Long) { - if (currentState.isLoading) return - - viewModelScope.launch(Dispatchers.IO) { - try { - updateState { copy(isLoading = true) } - val profile = ProfileManager.get(profileId) ?: return@launch - - Settings.selectedProfile = profileId - - // Check if service is running - if (_serviceStatus.value == Status.Started) { - val restart = Settings.rebuildServiceMode() - if (restart) { - // Need full restart - BoxService.stop() - sendGlobalEvent(UiEvent.RequestReconnectService) - for (i in 0 until 30) { - if (_serviceStatus.value == Status.Stopped) { - break - } - delay(100L) - } - sendGlobalEvent(UiEvent.RequestStartService) - } else { - // Just reload - Libbox.newStandaloneCommandClient().serviceReload() - } - } - - withContext(Dispatchers.Main) { - loadProfiles() - } - } catch (e: Exception) { - sendError(e) - } finally { - updateState { copy(isLoading = false) } - } - } - } - - fun editProfile(profile: Profile) { - sendGlobalEvent(UiEvent.EditProfile(profile.id)) - } - - fun deleteProfile(profile: Profile) { - viewModelScope.launch(Dispatchers.IO) { - try { - // Update UI immediately for responsiveness - withContext(Dispatchers.Main) { - updateState { - copy( - profiles = profiles.filter { p -> p.id != profile.id }, - ) - } - } - // Then delete from database - ProfileManager.delete(profile) - } catch (e: Exception) { - // Reload profiles if deletion fails - loadProfiles() - sendError(e) - } - } - } - - fun shareProfile(profile: Profile) { - // Handled directly in ProfilesCard - } - - fun shareProfileURL(profile: Profile) { - // Handled directly in ProfilesCard - } - - fun updateProfile(profile: Profile) { - if (profile.typed.type != TypedProfile.Type.Remote) return - - viewModelScope.launch(Dispatchers.IO) { - // Set updating state - withContext(Dispatchers.Main) { - updateState { copy(updatingProfileId = profile.id) } - } - - try { - // Fetch remote config - val content = HTTPClient().use { it.getString(profile.typed.remoteURL) } - Libbox.checkConfig(content) - - // Check if content changed - val file = File(profile.typed.path) - var contentChanged = false - if (!file.exists() || file.readText() != content) { - file.writeText(content) - contentChanged = true - } - - // Update last updated time - profile.typed.lastUpdated = Date() - ProfileManager.update(profile) - - // Reload profiles - loadProfiles() - - // Show success state - withContext(Dispatchers.Main) { - updateState { copy(updatingProfileId = null, updatedProfileId = profile.id) } - } - - // Clear success state after delay - withContext(Dispatchers.Main) { - delay(1500) - updateState { copy(updatedProfileId = null) } - } - - // Restart service if this is the selected profile and content changed - if (contentChanged && profile.id == Settings.selectedProfile) { - withContext(Dispatchers.Main) { - sendGlobalEvent(UiEvent.RequestReconnectService) - } - } - } catch (e: Exception) { - sendErrorMessage("Failed to update profile: ${e.message}") - // Clear updating state on error - withContext(Dispatchers.Main) { - updateState { copy(updatingProfileId = null) } - } - } - } - } - - fun moveProfile(from: Int, to: Int) { - val currentProfiles = currentState.profiles.toMutableList() - - if (from < to) { - for (i in from until to) { - Collections.swap(currentProfiles, i, i + 1) - } - } else { - for (i in from downTo to + 1) { - Collections.swap(currentProfiles, i, i - 1) - } - } - - // Update UI immediately - updateState { copy(profiles = currentProfiles) } - - // Update user order in database - viewModelScope.launch(Dispatchers.IO) { - currentProfiles.forEachIndexed { index, profile -> - profile.userOrder = index.toLong() - } - ProfileManager.update(currentProfiles) - } - } - - fun showAddProfileSheet() { - updateState { copy(showAddProfileSheet = true) } - } - - fun hideAddProfileSheet() { - updateState { copy(showAddProfileSheet = false) } - } - - fun showProfilePickerSheet() { - updateState { copy(showProfilePickerSheet = true) } - } - - fun hideProfilePickerSheet() { - updateState { copy(showProfilePickerSheet = false) } - } - - fun updateServiceStatus(status: Status) { - viewModelScope.launch { - _serviceStatus.emit(status) - updateState { - copy( - serviceStatus = status, - isStatusVisible = status == Status.Starting || status == Status.Started, - ) - } - handleServiceStatusChange(status) - } - } - - private fun handleServiceStatusChange(status: Status) { - when (status) { - Status.Started -> { - checkDeprecatedNotes() - if (AppLifecycleObserver.isForeground.value) { - commandClient.connect() - } - reloadSystemProxyStatus() - reloadStartedAt() - } - - Status.Stopped -> { - commandClient.disconnect() - updateState { - copy( - hasGroups = false, - groupsCount = 0, - connectionsCount = 0, - serviceStartTime = null, - clashModeVisible = false, - systemProxyVisible = false, - trafficVisible = false, - memory = "", - goroutines = "", - connectionsIn = "0", - connectionsOut = "0", - uplink = "0 B/s", - downlink = "0 B/s", - uplinkTotal = "0 B", - downlinkTotal = "0 B", - uplinkHistory = List(30) { 0f }, - downlinkHistory = List(30) { 0f }, - ) - } - } - - else -> {} - } - } - - private fun reloadStartedAt() { - viewModelScope.launch(Dispatchers.IO) { - try { - val startedAt = Libbox.newStandaloneCommandClient().startedAt - withContext(Dispatchers.Main) { - updateState { - copy(serviceStartTime = startedAt) - } - } - } catch (_: Exception) { - } - } - } - - private fun reloadSystemProxyStatus() { - viewModelScope.launch(Dispatchers.IO) { - try { - val status = Libbox.newStandaloneCommandClient().systemProxyStatus - withContext(Dispatchers.Main) { - updateState { - copy( - systemProxyVisible = status.available, - systemProxyEnabled = status.enabled, - ) - } - } - } catch (e: Exception) { - // Ignore errors - } - } - } - - fun toggleSystemProxy(enabled: Boolean) { - if (currentState.systemProxySwitching) return - - viewModelScope.launch(Dispatchers.IO) { - try { - updateState { copy(systemProxySwitching = true) } - Settings.systemProxyEnabled = enabled - Libbox.newStandaloneCommandClient().setSystemProxyEnabled(enabled) - delay(1000L) - withContext(Dispatchers.Main) { - updateState { - copy( - systemProxyEnabled = enabled, - systemProxySwitching = false, - ) - } - } - } catch (e: Exception) { - sendError(e) - updateState { copy(systemProxySwitching = false) } - } - } - } - - fun selectClashMode(mode: String) { - viewModelScope.launch(Dispatchers.IO) { - try { - Libbox.newStandaloneCommandClient().setClashMode(mode) - // Update UI state directly without reconnecting - withContext(Dispatchers.Main) { - updateState { - copy(selectedClashMode = mode) - } - } - } catch (e: Exception) { - sendError(e) - } - } - } - - // CommandClient.Handler implementation - override fun onConnected() { - viewModelScope.launch(Dispatchers.Main) { - updateState { copy(isStatusVisible = true) } - } - } - - override fun onDisconnected() { - viewModelScope.launch(Dispatchers.Main) { - updateState { - copy( - memory = "", - goroutines = "", - isStatusVisible = false, - ) - } - } - } - - override fun updateStatus(status: StatusMessage) { - viewModelScope.launch(Dispatchers.Main) { - updateState { - // Update history by adding new values and removing old ones - val newUplinkHistory = (uplinkHistory.drop(1) + status.uplink.toFloat()) - val newDownlinkHistory = (downlinkHistory.drop(1) + status.downlink.toFloat()) - - // Format the total values - val newUplinkTotal = Libbox.formatBytes(status.uplinkTotal) - val newDownlinkTotal = Libbox.formatBytes(status.downlinkTotal) - - copy( - memory = Libbox.formatBytes(status.memory), - goroutines = status.goroutines.toString(), - // Only set trafficVisible to true, never back to false from status updates - trafficVisible = if (status.trafficAvailable) true else trafficVisible, - connectionsCount = status.connectionsIn, - connectionsIn = status.connectionsIn.toString(), - connectionsOut = status.connectionsOut.toString(), - uplink = "${Libbox.formatBytes(status.uplink)}/s", - downlink = "${Libbox.formatBytes(status.downlink)}/s", - // Only update total values if they've actually changed - uplinkTotal = if (newUplinkTotal != uplinkTotal) newUplinkTotal else uplinkTotal, - downlinkTotal = if (newDownlinkTotal != downlinkTotal) newDownlinkTotal else downlinkTotal, - uplinkHistory = newUplinkHistory, - downlinkHistory = newDownlinkHistory, - ) - } - } - } - - override fun initializeClashMode(modeList: List, currentMode: String) { - viewModelScope.launch(Dispatchers.Main) { - updateState { - copy( - clashModeVisible = modeList.size > 1, - clashModes = modeList, - selectedClashMode = currentMode, - ) - } - } - } - - override fun updateClashMode(newMode: String) { - viewModelScope.launch(Dispatchers.Main) { - updateState { - copy(selectedClashMode = newMode) - } - } - } - - override fun updateGroups(newGroups: MutableList) { - viewModelScope.launch(Dispatchers.Main) { - val hasGroups = newGroups.isNotEmpty() - updateState { - copy(hasGroups = hasGroups, groupsCount = newGroups.size) - } - } - } - - fun toggleCardSettingsDialog() { - updateState { - copy(showCardSettingsDialog = !showCardSettingsDialog) - } - } - - fun toggleCardVisibility(cardGroup: CardGroup) { - // Profiles card cannot be disabled - if (cardGroup == CardGroup.Profiles) { - return - } - - updateState { - val newVisibleCards = - if (visibleCards.contains(cardGroup)) { - visibleCards - cardGroup - } else { - visibleCards + cardGroup - } - // Save disabled items to settings - saveDisabledItems(newVisibleCards) - // Also save the current order if not already saved (indicates user has configured dashboard) - if (Settings.dashboardItemOrder.isBlank()) { - saveItemOrder(cardOrder) - } - copy(visibleCards = newVisibleCards) - } - } - - fun closeCardSettingsDialog() { - updateState { - copy(showCardSettingsDialog = false) - } - } - - fun reorderCards(newOrder: List) { - updateState { - saveItemOrder(newOrder) - copy(cardOrder = newOrder) - } - } - - fun resetCardOrder() { - // Clear saved settings to restore defaults - Settings.dashboardItemOrder = "" - Settings.dashboardDisabledItems = emptySet() - - updateState { - copy( - cardOrder = getDefaultItemOrder(), - visibleCards = CardGroup.values().toSet(), - ) - } - } - - // Helper functions for serialization - private fun getDefaultItemOrder() = listOf( - CardGroup.UploadTraffic, - CardGroup.DownloadTraffic, - CardGroup.Debug, - CardGroup.Connections, - CardGroup.SystemProxy, - CardGroup.ClashMode, - CardGroup.Profiles, - ) - - private fun loadItemOrder(): List { - val savedOrder = Settings.dashboardItemOrder - if (savedOrder.isBlank()) { - return getDefaultItemOrder() - } - - return try { - val jsonArray = JSONArray(savedOrder) - val order = mutableListOf() - - for (i in 0 until jsonArray.length()) { - val itemName = jsonArray.getString(i) - stringToCardGroup(itemName)?.let { order.add(it) } - } - - // Add any new items that aren't in the saved order - val allItems = CardGroup.values().toSet() - val savedItems = order.toSet() - val newItems = allItems - savedItems - - order.addAll(newItems) - order - } catch (e: JSONException) { - getDefaultItemOrder() - } - } - - private fun saveItemOrder(order: List) { - val jsonArray = JSONArray() - order.forEach { item -> - jsonArray.put(cardGroupToString(item)) - } - Settings.dashboardItemOrder = jsonArray.toString() - } - - private fun loadDisabledItems(): Set { - val savedDisabled = Settings.dashboardDisabledItems - // Filter out Profiles from disabled items (it cannot be disabled) - return savedDisabled.mapNotNull { stringToCardGroup(it) } - .filter { it != CardGroup.Profiles } - .toSet() - } - - private fun saveDisabledItems(visibleCards: Set) { - val allItems = CardGroup.values().toSet() - // Always ensure Profiles is in visibleCards (cannot be disabled) - val actualVisibleCards = visibleCards + CardGroup.Profiles - val disabledItems = allItems - actualVisibleCards - Settings.dashboardDisabledItems = disabledItems.map { cardGroupToString(it) }.toSet() - } - - private fun cardGroupToString(card: CardGroup): String = card.name - - private fun stringToCardGroup(name: String): CardGroup? = try { - CardGroup.valueOf(name) - } catch (e: IllegalArgumentException) { - null - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DebugCard.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DebugCard.kt deleted file mode 100644 index 817bd97e12..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DebugCard.kt +++ /dev/null @@ -1,94 +0,0 @@ -package io.nekohasekai.sfa.compose.screen.dashboard - -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.material.icons.Icons -import androidx.compose.material.icons.outlined.BugReport -import androidx.compose.material3.Card -import androidx.compose.material3.Icon -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.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import io.nekohasekai.sfa.R - -@Composable -fun DebugCard(memory: String, goroutines: String, modifier: Modifier = Modifier) { - Card( - modifier = modifier.fillMaxWidth(), - ) { - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(16.dp), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = Icons.Outlined.BugReport, - contentDescription = null, - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.primary, - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = stringResource(R.string.title_debug), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - ) - } - Spacer(modifier = Modifier.height(12.dp)) - - // Memory item - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringResource(R.string.memory), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = memory.ifEmpty { stringResource(R.string.loading) }, - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Medium, - ) - } - - Spacer(modifier = Modifier.height(8.dp)) - - // Goroutines item - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringResource(R.string.goroutines), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = goroutines.ifEmpty { stringResource(R.string.loading) }, - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Medium, - ) - } - } - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DownloadTrafficCard.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DownloadTrafficCard.kt deleted file mode 100644 index 5f86056854..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DownloadTrafficCard.kt +++ /dev/null @@ -1,79 +0,0 @@ -package io.nekohasekai.sfa.compose.screen.dashboard - -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.material.icons.Icons -import androidx.compose.material.icons.outlined.Download -import androidx.compose.material3.Card -import androidx.compose.material3.Icon -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.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import io.nekohasekai.sfa.R -import io.nekohasekai.sfa.compose.LineChart - -@Composable -fun DownloadTrafficCard(downlink: String, downlinkTotal: String, downlinkHistory: List, modifier: Modifier = Modifier) { - Card( - modifier = modifier.fillMaxWidth(), - ) { - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(16.dp), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = Icons.Outlined.Download, - contentDescription = null, - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.primary, - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = stringResource(R.string.download), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - ) - } - - Spacer(modifier = Modifier.height(8.dp)) - - Text( - text = downlink, - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onSurface, - ) - - Text( - text = downlinkTotal, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - Spacer(modifier = Modifier.height(12.dp)) - - LineChart( - data = downlinkHistory, - lineColor = MaterialTheme.colorScheme.primary, - animate = false, - modifier = Modifier.fillMaxWidth(), - ) - } - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/GroupsCard.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/GroupsCard.kt deleted file mode 100644 index 741f8a1af2..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/GroupsCard.kt +++ /dev/null @@ -1,660 +0,0 @@ -package io.nekohasekai.sfa.compose.screen.dashboard - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.tween -import androidx.compose.animation.expandVertically -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.shrinkVertically -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically -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.LazyListState -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ExpandMore -import androidx.compose.material.icons.filled.Speed -import androidx.compose.material.icons.filled.UnfoldLess -import androidx.compose.material.icons.filled.UnfoldMore -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.ListItem -import androidx.compose.material3.ListItemDefaults -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.key -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.input.nestedscroll.NestedScrollConnection -import androidx.compose.ui.input.nestedscroll.NestedScrollSource -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.Velocity -import androidx.compose.ui.unit.dp -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.viewmodel.compose.viewModel -import io.nekohasekai.libbox.Libbox -import io.nekohasekai.sfa.R -import io.nekohasekai.sfa.compat.LazyColumnCompat -import io.nekohasekai.sfa.compat.rememberOverscrollEffectCompat -import io.nekohasekai.sfa.compose.model.Group -import io.nekohasekai.sfa.compose.model.GroupItem -import io.nekohasekai.sfa.compose.screen.dashboard.groups.GroupsViewModel -import io.nekohasekai.sfa.compose.topbar.OverrideTopBar -import io.nekohasekai.sfa.compose.util.rememberSheetDismissFromContentOnlyIfGestureStartedAtTopModifier -import io.nekohasekai.sfa.constant.Status -import io.nekohasekai.sfa.utils.CommandClient - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun GroupsCard( - serviceStatus: Status, - commandClient: CommandClient? = null, - viewModel: GroupsViewModel? = null, - showTopBar: Boolean = false, - listHeaderContent: (@Composable () -> Unit)? = null, - asSheet: Boolean = false, - modifier: Modifier = Modifier, -) { - val actualViewModel: GroupsViewModel = viewModel ?: viewModel( - factory = - object : ViewModelProvider.Factory { - override fun create(modelClass: Class): T { - @Suppress("UNCHECKED_CAST") - return GroupsViewModel(commandClient) as T - } - }, - ) - val snackbarHostState = remember { SnackbarHostState() } - val uiState by actualViewModel.uiState.collectAsState() - - if (showTopBar) { - val allCollapsed = uiState.expandedGroups.isEmpty() - OverrideTopBar { - TopAppBar( - title = { Text(stringResource(R.string.title_groups)) }, - actions = { - if (uiState.groups.isNotEmpty()) { - IconButton(onClick = { actualViewModel.toggleAllGroups() }) { - Icon( - imageVector = - if (allCollapsed) { - Icons.Default.UnfoldMore - } else { - Icons.Default.UnfoldLess - }, - contentDescription = - if (allCollapsed) { - stringResource(R.string.expand_all) - } else { - stringResource(R.string.collapse_all) - }, - ) - } - } - }, - ) - } - } - - // Stable callbacks to prevent recomposition - use remember with viewModel as key - val onToggleExpanded = - remember(actualViewModel) { - { groupTag: String -> actualViewModel.toggleGroupExpand(groupTag) } - } - val onItemSelected = - remember(actualViewModel) { - { groupTag: String, itemTag: String -> actualViewModel.selectGroupItem(groupTag, itemTag) } - } - val onUrlTest = - remember(actualViewModel) { - { groupTag: String -> actualViewModel.urlTest(groupTag) } - } - - // Only update service status when it actually changes - LaunchedEffect(serviceStatus) { - actualViewModel.updateServiceStatus(serviceStatus) - } - - // Show snackbar when needed - LaunchedEffect(uiState.showCloseConnectionsSnackbar) { - if (uiState.showCloseConnectionsSnackbar) { - val result = - snackbarHostState.showSnackbar( - message = "Close all connections?", - actionLabel = "Close", - duration = androidx.compose.material3.SnackbarDuration.Indefinite, - withDismissAction = true, - ) - when (result) { - androidx.compose.material3.SnackbarResult.ActionPerformed -> { - actualViewModel.closeConnections() - } - - androidx.compose.material3.SnackbarResult.Dismissed -> { - actualViewModel.dismissCloseConnectionsSnackbar() - } - } - } - } - - GroupsCardContent( - uiState = uiState, - onToggleExpanded = onToggleExpanded, - onItemSelected = onItemSelected, - onUrlTest = onUrlTest, - listHeaderContent = listHeaderContent, - asSheet = asSheet, - modifier = modifier, - ) -} - -@Composable -private fun GroupsCardContent( - uiState: io.nekohasekai.sfa.compose.screen.dashboard.groups.GroupsUiState, - onToggleExpanded: (String) -> Unit, - onItemSelected: (String, String) -> Unit, - onUrlTest: (String) -> Unit, - listHeaderContent: (@Composable () -> Unit)? = null, - asSheet: Boolean = false, - modifier: Modifier = Modifier, -) { - val lazyListState = rememberSaveable(saver = LazyListState.Saver) { LazyListState() } - val scrollModifier = - if (asSheet) { - rememberSheetDismissFromContentOnlyIfGestureStartedAtTopModifier { - lazyListState.firstVisibleItemIndex == 0 && - lazyListState.firstVisibleItemScrollOffset == 0 - } - } else { - Modifier.nestedScroll(rememberBounceBlockingNestedScrollConnection(lazyListState)) - } - val overscrollEffect = if (asSheet) null else rememberOverscrollEffectCompat() - - LazyColumnCompat( - modifier = - modifier - .fillMaxSize() - .then(scrollModifier), - state = lazyListState, - contentPadding = - PaddingValues( - start = 16.dp, - end = 16.dp, - top = 8.dp, - bottom = 16.dp, - ), - verticalArrangement = Arrangement.spacedBy(12.dp), - overscrollEffect = overscrollEffect, - ) { - if (listHeaderContent != null) { - item(key = "groups_list_header") { - listHeaderContent() - } - } - - when { - uiState.isLoading -> { - item(key = "groups_loading") { - Box( - modifier = - Modifier - .fillMaxWidth() - .height(200.dp), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator() - } - } - } - - uiState.groups.isEmpty() -> { - item(key = "groups_empty") { - Box( - modifier = - Modifier - .fillMaxWidth() - .height(100.dp), - contentAlignment = Alignment.Center, - ) { - Text( - text = "No groups available", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - - else -> { - items( - items = uiState.groups, - key = { it.tag }, - contentType = { "GroupCard" }, - ) { group -> - ProxyGroupItem( - group = group, - isExpanded = uiState.expandedGroups.contains(group.tag), - onToggleExpanded = { onToggleExpanded(group.tag) }, - onItemSelected = { itemTag -> onItemSelected(group.tag, itemTag) }, - onUrlTest = { onUrlTest(group.tag) }, - ) - } - } - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun ProxyGroupItem( - group: Group, - isExpanded: Boolean, - onToggleExpanded: () -> Unit, - onItemSelected: (String) -> Unit, - onUrlTest: () -> Unit, -) { - Card( - modifier = Modifier.fillMaxWidth(), - ) { - Column( - modifier = Modifier.fillMaxWidth(), - ) { - // Header (clickable to expand/collapse) - Surface( - onClick = onToggleExpanded, - color = Color.Transparent, - ) { - ListItem( - headlineContent = { - Column { - Text( - text = group.tag, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface, - ) - - Spacer(modifier = Modifier.height(4.dp)) - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - text = Libbox.proxyDisplayType(group.type), - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - // Show selected item when collapsed - AnimatedVisibility( - visible = !isExpanded && group.selected.isNotEmpty(), - enter = fadeIn(), - exit = fadeOut(), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - Text( - text = "•", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = group.selected, - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.primary, - ) - } - } - } - } - }, - trailingContent = { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - // URL Test button - AnimatedVisibility( - visible = group.selectable, - enter = slideInVertically() + fadeIn(), - exit = slideOutVertically() + fadeOut(), - ) { - IconButton( - onClick = { - onUrlTest() - }, - modifier = Modifier.size(40.dp), - ) { - Icon( - imageVector = Icons.Default.Speed, - contentDescription = stringResource(R.string.url_test), - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - - // Expand/Collapse indicator - val rotationAngle by animateFloatAsState( - targetValue = if (isExpanded) 180f else 0f, - animationSpec = tween(300), - label = "ExpandIcon", - ) - - Icon( - imageVector = Icons.Default.ExpandMore, - contentDescription = if (isExpanded) "Collapse" else "Expand", - modifier = - Modifier - .size(24.dp) - .graphicsLayer { rotationZ = rotationAngle }, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - }, - colors = - ListItemDefaults.colors( - containerColor = Color.Transparent, - ), - ) - } - - // Expandable content - AnimatedVisibility( - visible = isExpanded && group.items.isNotEmpty(), - enter = - expandVertically(animationSpec = tween(300)) + - fadeIn( - animationSpec = - tween( - 300, - ), - ), - exit = - shrinkVertically(animationSpec = tween(300)) + - fadeOut( - animationSpec = - tween( - 300, - ), - ), - ) { - Column { - HorizontalDivider( - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f), - thickness = 1.dp, - ) - - // Proxy Items - ProxyItemsList( - items = group.items, - selectedTag = group.selected, - isSelectable = group.selectable, - onItemSelected = onItemSelected, - ) - } - } - } - } -} - -@Composable -private fun ProxyItemsList(items: List, selectedTag: String, isSelectable: Boolean, onItemSelected: (String) -> Unit) { - val itemsPerRow = 2 - val chunkedItems = - remember(items) { - items.chunked(itemsPerRow) - } - - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - chunkedItems.forEach { rowItems -> - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - rowItems.forEach { item -> - key(item.tag) { - Box( - modifier = Modifier.weight(1f), - ) { - ProxyChip( - item = item, - isSelected = item.tag == selectedTag, - isSelectable = isSelectable, - onClick = { onItemSelected(item.tag) }, - modifier = Modifier.fillMaxWidth(), - ) - } - } - } - repeat(itemsPerRow - rowItems.size) { - Box(modifier = Modifier.weight(1f)) - } - } - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun ProxyChip(item: GroupItem, isSelected: Boolean, isSelectable: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) { - // Use simpler, faster animations - val animatedElevation by animateFloatAsState( - targetValue = if (isSelected) 6.dp.value else 1.dp.value, - animationSpec = tween(150), - label = "Elevation", - ) - - val surfaceModifier = modifier - val surfaceShape = RoundedCornerShape(8.dp) - val surfaceColor = - when { - isSelected -> MaterialTheme.colorScheme.primaryContainer - else -> MaterialTheme.colorScheme.surface - } - val surfaceBorder = - androidx.compose.foundation.BorderStroke( - width = if (isSelected) 2.dp else 1.dp, - color = - when { - isSelected -> MaterialTheme.colorScheme.primary.copy(alpha = 0.8f) - else -> MaterialTheme.colorScheme.outline.copy(alpha = 0.2f) - }, - ) - - val content: @Composable () -> Unit = { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(12.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - // First line: Name - Text( - text = item.tag, - style = MaterialTheme.typography.bodyMedium, - fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium, - color = - if (isSelected) { - MaterialTheme.colorScheme.onPrimaryContainer - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - - // Second line: Type on left, Latency on right - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - // Type - Text( - text = Libbox.proxyDisplayType(item.type), - style = MaterialTheme.typography.labelSmall, - color = - if (isSelected) { - MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f) - } else { - MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) - }, - ) - - // Latency - AnimatedVisibility( - visible = item.urlTestTime > 0, - enter = fadeIn(), - exit = fadeOut(), - ) { - ProxyLatencyBadge( - delay = item.urlTestDelay, - isSelected = isSelected, - ) - } - } - } - } - } - - if (isSelectable) { - Surface( - onClick = onClick, - modifier = surfaceModifier, - shape = surfaceShape, - color = surfaceColor, - tonalElevation = animatedElevation.dp, - border = surfaceBorder, - content = content, - ) - } else { - Surface( - modifier = surfaceModifier, - shape = surfaceShape, - color = surfaceColor, - tonalElevation = animatedElevation.dp, - border = surfaceBorder, - content = content, - ) - } -} - -@Composable -private fun ProxyLatencyBadge(delay: Int, isSelected: Boolean, modifier: Modifier = Modifier) { - // Direct color calculation without animation for better performance - val colorScheme = MaterialTheme.colorScheme - val latencyColor = - remember(delay, isSelected) { - when { - delay < 100 -> { - // Excellent - green/tertiary - if (isSelected) { - colorScheme.tertiary - } else { - colorScheme.tertiary.copy(alpha = 0.9f) - } - } - - delay < 300 -> { - // Good - primary - if (isSelected) { - colorScheme.primary - } else { - colorScheme.primary.copy(alpha = 0.9f) - } - } - - delay < 500 -> { - // Fair - secondary/warning - if (isSelected) { - colorScheme.secondary - } else { - colorScheme.secondary.copy(alpha = 0.9f) - } - } - - else -> { - // Poor - error - if (isSelected) { - colorScheme.error - } else { - colorScheme.error.copy(alpha = 0.9f) - } - } - } - } - - Text( - text = "${delay}ms", - style = MaterialTheme.typography.labelSmall, - fontWeight = FontWeight.SemiBold, - color = latencyColor, - modifier = modifier, - ) -} - -@Composable -private fun rememberBounceBlockingNestedScrollConnection(lazyListState: LazyListState): NestedScrollConnection = remember(lazyListState) { - object : NestedScrollConnection { - override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset { - // Only block upward scroll (y < 0) at bottom to prevent sheet expansion - // Allow downward scroll (y > 0) at top to let sheet collapse - return if (available.y < 0) available else Offset.Zero - } - - override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { - // Only block upward fling (y < 0) to prevent sheet expansion - // Allow downward fling (y > 0) to let sheet collapse - return if (available.y < 0) available else Velocity.Zero - } - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/ProfilePickerSheet.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/ProfilePickerSheet.kt deleted file mode 100644 index c4a863145a..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/ProfilePickerSheet.kt +++ /dev/null @@ -1,490 +0,0 @@ -package io.nekohasekai.sfa.compose.screen.dashboard - -import android.widget.Toast -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.tween -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.isSystemInDarkTheme -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.heightIn -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.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.InsertDriveFile -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.Edit -import androidx.compose.material.icons.filled.ExpandLess -import androidx.compose.material.icons.filled.ExpandMore -import androidx.compose.material.icons.filled.IosShare -import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material.icons.filled.QrCode2 -import androidx.compose.material.icons.filled.Save -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.ui.graphics.lerp -import androidx.compose.material3.rememberModalBottomSheetState -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.graphics.toArgb -import androidx.compose.ui.platform.LocalContext -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 io.nekohasekai.libbox.Libbox -import io.nekohasekai.libbox.ProfileContent -import io.nekohasekai.sfa.R -import io.nekohasekai.sfa.compose.component.qr.QRCodeDialog -import io.nekohasekai.sfa.compose.util.ProfileIcons -import io.nekohasekai.sfa.compose.util.QRCodeGenerator -import io.nekohasekai.sfa.compose.util.RelativeTimeFormatter -import io.nekohasekai.sfa.database.Profile -import io.nekohasekai.sfa.database.TypedProfile -import io.nekohasekai.sfa.ktx.shareProfile -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import sh.calvin.reorderable.ReorderableItem -import sh.calvin.reorderable.rememberReorderableLazyListState - -@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) -@Composable -fun ProfilePickerSheet( - profiles: List, - selectedProfileId: Long, - onProfileSelected: (Profile) -> Unit, - onProfileEdit: (Profile) -> Unit, - onProfileDelete: (Profile) -> Unit, - onProfileMove: (Int, Int) -> Unit, - onDismiss: () -> Unit, -) { - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - val context = LocalContext.current - val coroutineScope = rememberCoroutineScope() - - var showQRCodeDialog by remember { mutableStateOf(false) } - var qrCodeProfile by remember { mutableStateOf(null) } - - ModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = sheetState, - containerColor = MaterialTheme.colorScheme.surfaceContainerLow, - contentColor = MaterialTheme.colorScheme.onSurface, - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 32.dp), - ) { - Text( - text = stringResource(R.string.title_configuration), - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding( - start = 24.dp, - end = 24.dp, - top = 8.dp, - bottom = 16.dp, - ), - ) - - val lazyListState = rememberLazyListState() - val reorderableLazyListState = - rememberReorderableLazyListState(lazyListState) { from, to -> - onProfileMove(from.index, to.index) - } - - LazyColumn( - state = lazyListState, - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 100.dp, max = 400.dp) - .padding(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - itemsIndexed(profiles, key = { _, profile -> profile.id }) { _, profile -> - ReorderableItem( - reorderableLazyListState, - key = profile.id, - ) { isDragging -> - ProfilePickerRow( - profile = profile, - isSelected = profile.id == selectedProfileId, - isDragging = isDragging, - onSelect = { - onProfileSelected(profile) - onDismiss() - }, - onEdit = { onProfileEdit(profile) }, - onShare = { - coroutineScope.launch(Dispatchers.IO) { - try { - context.shareProfile(profile) - } catch (_: Exception) { - } - } - }, - onShareURL = { - qrCodeProfile = profile - showQRCodeDialog = true - }, - onDelete = { onProfileDelete(profile) }, - modifier = Modifier.longPressDraggableHandle(), - ) - } - } - } - } - } - - if (showQRCodeDialog && qrCodeProfile != null) { - val profile = qrCodeProfile!! - val link = remember(profile) { - Libbox.generateRemoteProfileImportLink( - profile.name, - profile.typed.remoteURL, - ) - } - val surfaceColor = MaterialTheme.colorScheme.surface.toArgb() - val qrBitmap = QRCodeGenerator.rememberPrimaryBitmap(link, backgroundColor = surfaceColor) - - QRCodeDialog( - bitmap = qrBitmap, - onDismiss = { - showQRCodeDialog = false - qrCodeProfile = null - }, - ) - } -} - -private suspend fun createProfileContent(profile: Profile): ByteArray { - val content = ProfileContent() - content.name = profile.name - when (profile.typed.type) { - TypedProfile.Type.Local -> { - content.type = Libbox.ProfileTypeLocal - } - TypedProfile.Type.Remote -> { - content.type = Libbox.ProfileTypeRemote - } - } - content.config = java.io.File(profile.typed.path).readText() - content.remotePath = profile.typed.remoteURL - content.autoUpdate = profile.typed.autoUpdate - content.autoUpdateInterval = profile.typed.autoUpdateInterval - content.lastUpdated = profile.typed.lastUpdated.time - return content.encode() -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun ProfilePickerRow( - profile: Profile, - isSelected: Boolean, - isDragging: Boolean, - onSelect: () -> Unit, - onEdit: () -> Unit, - onShare: () -> Unit, - onShareURL: () -> Unit, - onDelete: () -> Unit, - modifier: Modifier = Modifier, -) { - var showMenu by remember { mutableStateOf(false) } - var expandedShareSubmenu by remember { mutableStateOf(false) } - val context = LocalContext.current - val coroutineScope = rememberCoroutineScope() - - val animatedElevation by animateFloatAsState( - targetValue = when { - isDragging -> 8.dp.value - isSelected -> 2.dp.value - else -> 0.dp.value - }, - animationSpec = tween(300), - label = "Elevation", - ) - - val saveFileLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.CreateDocument("application/octet-stream"), - ) { uri -> - if (uri != null) { - coroutineScope.launch(Dispatchers.IO) { - try { - val profileData = createProfileContent(profile) - context.contentResolver.openOutputStream(uri)?.use { outputStream -> - outputStream.write(profileData) - } - withContext(Dispatchers.Main) { - Toast.makeText( - context, - context.getString(R.string.success_profile_saved), - Toast.LENGTH_SHORT, - ).show() - } - } catch (e: Exception) { - withContext(Dispatchers.Main) { - Toast.makeText( - context, - "${context.getString(R.string.failed_save_profile)}: ${e.message}", - Toast.LENGTH_SHORT, - ).show() - } - } - } - } - } - - Surface( - onClick = onSelect, - modifier = modifier.fillMaxWidth(), - shape = RoundedCornerShape(8.dp), - color = when { - isDragging -> MaterialTheme.colorScheme.tertiaryContainer - isSelected -> if (isSystemInDarkTheme()) { - lerp( - MaterialTheme.colorScheme.surfaceContainerLow, - MaterialTheme.colorScheme.surfaceContainerHigh, - 0.5f, - ) - } else { - MaterialTheme.colorScheme.surfaceDim - } - else -> if (isSystemInDarkTheme()) { - lerp( - MaterialTheme.colorScheme.surfaceContainerLow, - MaterialTheme.colorScheme.surfaceContainerHigh, - 0.35f, - ) - } else { - MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) - } - }, - tonalElevation = animatedElevation.dp, - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - val profileIcon = - ProfileIcons.getIconById(profile.icon) - ?: Icons.AutoMirrored.Default.InsertDriveFile - - Icon( - imageVector = profileIcon, - contentDescription = null, - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - Spacer(modifier = Modifier.width(12.dp)) - - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(2.dp), - ) { - Text( - text = profile.name, - style = MaterialTheme.typography.bodyMedium, - fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - - Text( - text = when (profile.typed.type) { - TypedProfile.Type.Local -> stringResource(R.string.profile_type_local) - TypedProfile.Type.Remote -> stringResource( - R.string.profile_type_remote_updated, - RelativeTimeFormatter.format(context, profile.typed.lastUpdated), - ) - }, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), - ) - } - - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - if (isSelected) { - Icon( - imageVector = Icons.Default.Check, - contentDescription = null, - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.primary, - ) - } else { - Spacer(modifier = Modifier.size(24.dp)) - } - - Box { - IconButton( - onClick = { - showMenu = true - expandedShareSubmenu = false - }, - modifier = Modifier.size(32.dp), - ) { - Icon( - imageVector = Icons.Default.MoreVert, - contentDescription = stringResource(R.string.more_options), - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - DropdownMenu( - expanded = showMenu, - onDismissRequest = { - showMenu = false - expandedShareSubmenu = false - }, - modifier = Modifier.widthIn(min = 200.dp), - ) { - DropdownMenuItem( - text = { Text(stringResource(R.string.edit)) }, - onClick = { - showMenu = false - onEdit() - }, - leadingIcon = { - Icon( - imageVector = Icons.Default.Edit, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - }, - ) - - DropdownMenuItem( - text = { Text(stringResource(R.string.menu_share)) }, - onClick = { - expandedShareSubmenu = !expandedShareSubmenu - }, - leadingIcon = { - Icon( - imageVector = Icons.Default.IosShare, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - }, - trailingIcon = { - Icon( - imageVector = if (expandedShareSubmenu) { - Icons.Default.ExpandLess - } else { - Icons.Default.ExpandMore - }, - contentDescription = null, - ) - }, - ) - - if (expandedShareSubmenu) { - DropdownMenuItem( - text = { Text(stringResource(R.string.save_as_file)) }, - onClick = { - showMenu = false - saveFileLauncher.launch("${profile.name}.bpf") - }, - leadingIcon = { - Icon( - imageVector = Icons.Default.Save, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(start = 24.dp), - ) - }, - ) - - DropdownMenuItem( - text = { Text(stringResource(R.string.share_as_file)) }, - onClick = { - showMenu = false - onShare() - }, - leadingIcon = { - Icon( - imageVector = Icons.Default.IosShare, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(start = 24.dp), - ) - }, - ) - - if (profile.typed.type == TypedProfile.Type.Remote) { - DropdownMenuItem( - text = { Text(stringResource(R.string.profile_share_url)) }, - onClick = { - showMenu = false - onShareURL() - }, - leadingIcon = { - Icon( - imageVector = Icons.Default.QrCode2, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(start = 24.dp), - ) - }, - ) - } - } - - DropdownMenuItem( - text = { - Text( - stringResource(R.string.menu_delete), - color = MaterialTheme.colorScheme.error, - ) - }, - onClick = { - showMenu = false - onDelete() - }, - leadingIcon = { - Icon( - imageVector = Icons.Default.Delete, - contentDescription = null, - tint = MaterialTheme.colorScheme.error, - ) - }, - ) - } - } - } - } - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/ProfileSelectorButton.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/ProfileSelectorButton.kt deleted file mode 100644 index 31a5daae77..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/ProfileSelectorButton.kt +++ /dev/null @@ -1,95 +0,0 @@ -package io.nekohasekai.sfa.compose.screen.dashboard - -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Box -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.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.InsertDriveFile -import androidx.compose.material.icons.filled.UnfoldMore -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.ui.Alignment -import androidx.compose.ui.graphics.lerp -import androidx.compose.ui.Modifier -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 io.nekohasekai.sfa.R -import io.nekohasekai.sfa.compose.util.ProfileIcons -import io.nekohasekai.sfa.database.Profile - -@Composable -fun ProfileSelectorButton(selectedProfile: Profile?, onClick: () -> Unit, modifier: Modifier = Modifier) { - Surface( - onClick = onClick, - modifier = modifier.fillMaxWidth().height(48.dp), - shape = RoundedCornerShape(12.dp), - color = if (isSystemInDarkTheme()) { - lerp( - MaterialTheme.colorScheme.surfaceContainerHighest, - MaterialTheme.colorScheme.surfaceContainerHigh, - 0.5f, - ) - } else { - MaterialTheme.colorScheme.surfaceDim - }, - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - if (selectedProfile != null) { - val profileIcon = - ProfileIcons.getIconById(selectedProfile.icon) - ?: Icons.AutoMirrored.Default.InsertDriveFile - - Icon( - imageVector = profileIcon, - contentDescription = null, - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - Spacer(modifier = Modifier.width(12.dp)) - - Text( - text = selectedProfile.name, - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - ) - } else { - Box(modifier = Modifier.weight(1f)) { - Text( - text = stringResource(R.string.not_selected), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - - Icon( - imageVector = Icons.Default.UnfoldMore, - contentDescription = stringResource(R.string.expand), - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/ProfilesCard.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/ProfilesCard.kt deleted file mode 100644 index c4d9c8477d..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/ProfilesCard.kt +++ /dev/null @@ -1,905 +0,0 @@ -package io.nekohasekai.sfa.compose.screen.dashboard - -import android.net.Uri -import android.widget.Toast -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.clickable -import androidx.compose.foundation.isSystemInDarkTheme -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.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.AccessTime -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.Cloud -import androidx.compose.material.icons.filled.DataObject -import androidx.compose.material.icons.filled.Edit -import androidx.compose.material.icons.filled.IosShare -import androidx.compose.material.icons.filled.QrCode2 -import androidx.compose.material.icons.filled.QrCodeScanner -import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material.icons.filled.Save -import androidx.compose.material.icons.outlined.CreateNewFolder -import androidx.compose.material.icons.outlined.Description -import androidx.compose.material.icons.outlined.FileUpload -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Card -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.ListItem -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -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.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.lerp -import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import io.nekohasekai.libbox.Libbox -import io.nekohasekai.libbox.ProfileContent -import io.nekohasekai.sfa.R -import io.nekohasekai.sfa.compose.component.qr.QRCodeDialog -import io.nekohasekai.sfa.compose.component.qr.QRSDialog -import io.nekohasekai.sfa.compose.component.qr.QRScanSheet -import io.nekohasekai.sfa.compose.navigation.NewProfileArgs -import io.nekohasekai.sfa.compose.screen.configuration.ProfileImportHandler -import io.nekohasekai.sfa.compose.screen.qrscan.QRScanResult -import io.nekohasekai.sfa.compose.util.QRCodeGenerator -import io.nekohasekai.sfa.compose.util.RelativeTimeFormatter -import io.nekohasekai.sfa.database.Profile -import io.nekohasekai.sfa.database.TypedProfile -import io.nekohasekai.sfa.ktx.errorDialogBuilder -import io.nekohasekai.sfa.ktx.shareProfile -import io.nekohasekai.sfa.ktx.shareProfileAsJson -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.io.File - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun ProfilesCard( - profiles: List, - selectedProfileId: Long, - isLoading: Boolean, - showAddProfileSheet: Boolean, - showProfilePickerSheet: Boolean, - updatingProfileId: Long? = null, - updatedProfileId: Long? = null, - onProfileSelected: (Long) -> Unit, - onProfileEdit: (Profile) -> Unit, - onProfileDelete: (Profile) -> Unit, - onProfileShare: (Profile) -> Unit, - onProfileShareURL: (Profile) -> Unit, - onProfileUpdate: (Profile) -> Unit, - onProfileMove: (Int, Int) -> Unit, - onShowAddProfileSheet: () -> Unit, - onHideAddProfileSheet: () -> Unit, - onShowProfilePickerSheet: () -> Unit, - onHideProfilePickerSheet: () -> Unit, - onOpenNewProfile: (NewProfileArgs) -> Unit, -) { - val context = LocalContext.current - val coroutineScope = rememberCoroutineScope() - - val importHandler = remember { ProfileImportHandler(context) } - - var showQRCodeDialog by remember { mutableStateOf(false) } - var qrCodeProfile by remember { mutableStateOf(null) } - - var showQRSDialog by remember { mutableStateOf(false) } - var qrsProfile by remember { mutableStateOf(null) } - var qrsProfileData by remember { mutableStateOf(null) } - - var showImportConfirmDialog by remember { mutableStateOf(false) } - var pendingImportName by remember { mutableStateOf(null) } - var pendingQrsData by remember { mutableStateOf(null) } - var pendingImportUri by remember { mutableStateOf(null) } - - var showQRScanSheet by remember { mutableStateOf(false) } - - val importFromFileLauncher = - rememberLauncherForActivityResult( - ActivityResultContracts.GetContent(), - ) { uri -> - uri?.let { - coroutineScope.launch { - when (val parseResult = importHandler.parseUri(uri)) { - is ProfileImportHandler.UriParseResult.Success -> { - withContext(Dispatchers.Main) { - pendingImportName = parseResult.name - pendingImportUri = uri - showImportConfirmDialog = true - } - } - is ProfileImportHandler.UriParseResult.Error -> { - withContext(Dispatchers.Main) { - context.errorDialogBuilder(Exception(parseResult.message)).show() - } - } - } - } - } - } - - val saveFileLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.CreateDocument("application/octet-stream"), - ) { uri -> - if (uri != null) { - val selectedProfile = profiles.find { it.id == selectedProfileId } - if (selectedProfile != null) { - coroutineScope.launch(Dispatchers.IO) { - try { - val profileData = createProfileContent(selectedProfile) - context.contentResolver.openOutputStream(uri)?.use { outputStream -> - outputStream.write(profileData) - } - withContext(Dispatchers.Main) { - Toast.makeText( - context, - context.getString(R.string.success_profile_saved), - Toast.LENGTH_SHORT, - ).show() - } - } catch (e: Exception) { - withContext(Dispatchers.Main) { - Toast.makeText( - context, - "${context.getString(R.string.failed_save_profile)}: ${e.message}", - Toast.LENGTH_SHORT, - ).show() - } - } - } - } - } - } - - val saveJsonFileLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.CreateDocument("application/json"), - ) { uri -> - if (uri != null) { - val selectedProfile = profiles.find { it.id == selectedProfileId } - if (selectedProfile != null) { - coroutineScope.launch(Dispatchers.IO) { - try { - val jsonContent = File(selectedProfile.typed.path).readText() - context.contentResolver.openOutputStream(uri)?.use { outputStream -> - outputStream.write(jsonContent.toByteArray()) - } - withContext(Dispatchers.Main) { - Toast.makeText( - context, - context.getString(R.string.success_profile_saved), - Toast.LENGTH_SHORT, - ).show() - } - } catch (e: Exception) { - withContext(Dispatchers.Main) { - Toast.makeText( - context, - "${context.getString(R.string.failed_save_profile)}: ${e.message}", - Toast.LENGTH_SHORT, - ).show() - } - } - } - } - } - } - - val selectedProfile = profiles.find { it.id == selectedProfileId } - - Card( - modifier = Modifier.fillMaxWidth(), - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = Icons.Outlined.Description, - contentDescription = null, - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.primary, - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = stringResource(R.string.title_configuration), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - ) - } - - Surface( - onClick = onShowAddProfileSheet, - shape = RoundedCornerShape(12.dp), - color = if (isSystemInDarkTheme()) { - lerp( - MaterialTheme.colorScheme.surfaceContainerHighest, - MaterialTheme.colorScheme.surfaceContainerHigh, - 0.5f, - ) - } else { - MaterialTheme.colorScheme.surfaceDim - }, - modifier = Modifier.size(44.dp), - ) { - Box(contentAlignment = Alignment.Center) { - Icon( - imageVector = Icons.Default.Add, - contentDescription = stringResource(R.string.add_profile), - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - - Spacer(modifier = Modifier.height(12.dp)) - - if (profiles.isEmpty()) { - Text( - text = stringResource(R.string.no_profiles), - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(vertical = 16.dp), - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } else { - ProfileSelectorButton( - selectedProfile = selectedProfile, - onClick = onShowProfilePickerSheet, - ) - - Spacer(modifier = Modifier.height(12.dp)) - - ProfileInfoRow(profile = selectedProfile) - - Spacer(modifier = Modifier.height(16.dp)) - - ProfileActionRow( - profile = selectedProfile, - isUpdating = selectedProfile?.id == updatingProfileId, - showUpdateSuccess = selectedProfile?.id == updatedProfileId, - onEdit = { selectedProfile?.let { onProfileEdit(it) } }, - onUpdate = { selectedProfile?.let { onProfileUpdate(it) } }, - onShareFile = { - selectedProfile?.let { - coroutineScope.launch(Dispatchers.IO) { - try { - context.shareProfile(it) - } catch (e: Exception) { - withContext(Dispatchers.Main) { - context.errorDialogBuilder(e).show() - } - } - } - } - }, - onSaveFile = { - selectedProfile?.let { - saveFileLauncher.launch("${it.name}.bpf") - } - }, - onSaveJson = { - selectedProfile?.let { - saveJsonFileLauncher.launch("${it.name}.json") - } - }, - onShareJson = { - selectedProfile?.let { - coroutineScope.launch(Dispatchers.IO) { - try { - context.shareProfileAsJson(it) - } catch (e: Exception) { - withContext(Dispatchers.Main) { - context.errorDialogBuilder(e).show() - } - } - } - } - }, - onShareURL = { - selectedProfile?.let { - qrCodeProfile = it - showQRCodeDialog = true - } - }, - onShareQRS = { - selectedProfile?.let { profile -> - coroutineScope.launch(Dispatchers.IO) { - try { - val data = createProfileContent(profile) - withContext(Dispatchers.Main) { - qrsProfile = profile - qrsProfileData = data - showQRSDialog = true - } - } catch (e: Exception) { - withContext(Dispatchers.Main) { - context.errorDialogBuilder(e).show() - } - } - } - } - }, - ) - } - } - } - - if (showProfilePickerSheet) { - ProfilePickerSheet( - profiles = profiles, - selectedProfileId = selectedProfileId, - onProfileSelected = { profile -> onProfileSelected(profile.id) }, - onProfileEdit = onProfileEdit, - onProfileDelete = onProfileDelete, - onProfileMove = onProfileMove, - onDismiss = onHideProfilePickerSheet, - ) - } - - if (showAddProfileSheet) { - ModalBottomSheet( - onDismissRequest = onHideAddProfileSheet, - containerColor = MaterialTheme.colorScheme.surface, - contentColor = MaterialTheme.colorScheme.onSurface, - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 32.dp), - ) { - Text( - text = stringResource(R.string.add_profile), - style = MaterialTheme.typography.titleLarge, - modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp), - ) - - ListItem( - modifier = Modifier.clickable { - onHideAddProfileSheet() - importFromFileLauncher.launch("*/*") - }, - leadingContent = { - Icon( - imageVector = Icons.Outlined.FileUpload, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - }, - headlineContent = { - Text(stringResource(R.string.profile_add_import_file)) - }, - supportingContent = { - Text(stringResource(R.string.import_from_file_description)) - }, - ) - - ListItem( - modifier = Modifier.clickable { - onHideAddProfileSheet() - showQRScanSheet = true - }, - leadingContent = { - Icon( - imageVector = Icons.Default.QrCodeScanner, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - }, - headlineContent = { - Text(stringResource(R.string.profile_add_scan_qr_code)) - }, - supportingContent = { - Text(stringResource(R.string.scan_qr_code_description)) - }, - ) - - ListItem( - modifier = Modifier.clickable { - onHideAddProfileSheet() - onOpenNewProfile(NewProfileArgs()) - }, - leadingContent = { - Icon( - imageVector = Icons.Outlined.CreateNewFolder, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - }, - headlineContent = { - Text(stringResource(R.string.profile_add_create_manually)) - }, - supportingContent = { - Text(stringResource(R.string.create_new_profile_description)) - }, - ) - } - } - } - - if (showQRCodeDialog && qrCodeProfile != null) { - val profile = qrCodeProfile!! - val link = remember(profile) { - Libbox.generateRemoteProfileImportLink( - profile.name, - profile.typed.remoteURL, - ) - } - val surfaceColor = MaterialTheme.colorScheme.surface.toArgb() - val qrBitmap = QRCodeGenerator.rememberPrimaryBitmap(link, backgroundColor = surfaceColor) - - QRCodeDialog( - bitmap = qrBitmap, - onDismiss = { - showQRCodeDialog = false - qrCodeProfile = null - }, - ) - } - - if (showQRSDialog && qrsProfile != null && qrsProfileData != null) { - QRSDialog( - profileData = qrsProfileData!!, - profileName = qrsProfile!!.name, - onDismiss = { - showQRSDialog = false - qrsProfile = null - qrsProfileData = null - }, - ) - } - - if (showImportConfirmDialog && pendingImportName != null) { - AlertDialog( - onDismissRequest = { - showImportConfirmDialog = false - pendingImportName = null - pendingQrsData = null - pendingImportUri = null - }, - title = { Text(stringResource(R.string.import_profile_confirm_title)) }, - text = { Text(stringResource(R.string.import_profile_confirm_message, pendingImportName!!)) }, - confirmButton = { - TextButton( - onClick = { - showImportConfirmDialog = false - val qrsData = pendingQrsData - val importUri = pendingImportUri - pendingImportName = null - pendingQrsData = null - pendingImportUri = null - coroutineScope.launch { - if (qrsData != null) { - when (val result = importHandler.importFromQRSData(qrsData)) { - is ProfileImportHandler.ImportResult.Success -> { - withContext(Dispatchers.Main) { - onProfileEdit(result.profile) - } - } - is ProfileImportHandler.ImportResult.Error -> { - withContext(Dispatchers.Main) { - context.errorDialogBuilder(Exception(result.message)).show() - } - } - } - } else if (importUri != null) { - when (val result = importHandler.importFromUri(importUri)) { - is ProfileImportHandler.ImportResult.Success -> { - withContext(Dispatchers.Main) { - onProfileEdit(result.profile) - } - } - is ProfileImportHandler.ImportResult.Error -> { - withContext(Dispatchers.Main) { - context.errorDialogBuilder(Exception(result.message)).show() - } - } - } - } - } - }, - ) { - Text(stringResource(R.string.import_action)) - } - }, - dismissButton = { - TextButton( - onClick = { - showImportConfirmDialog = false - pendingImportName = null - pendingQrsData = null - pendingImportUri = null - }, - ) { - Text(stringResource(R.string.cancel)) - } - }, - ) - } - - if (showQRScanSheet) { - QRScanSheet( - onDismiss = { showQRScanSheet = false }, - onScanResult = { result -> - showQRScanSheet = false - when (result) { - is QRScanResult.QRSData -> { - coroutineScope.launch { - when (val parseResult = importHandler.parseQRSData(result.data)) { - is ProfileImportHandler.QRSParseResult.Success -> { - withContext(Dispatchers.Main) { - pendingImportName = parseResult.name - pendingQrsData = result.data - showImportConfirmDialog = true - } - } - is ProfileImportHandler.QRSParseResult.Error -> { - withContext(Dispatchers.Main) { - context.errorDialogBuilder(Exception(parseResult.message)).show() - } - } - } - } - } - is QRScanResult.RemoteProfile -> { - coroutineScope.launch { - when (val parseResult = importHandler.parseQRCode(result.uri.toString())) { - is ProfileImportHandler.QRCodeParseResult.RemoteProfile -> { - withContext(Dispatchers.Main) { - onOpenNewProfile( - NewProfileArgs( - importName = parseResult.name, - importUrl = parseResult.url, - ), - ) - } - } - is ProfileImportHandler.QRCodeParseResult.LocalProfile -> { - when (val importResult = importHandler.importFromQRCode(result.uri.toString())) { - is ProfileImportHandler.ImportResult.Success -> { - withContext(Dispatchers.Main) { - onProfileEdit(importResult.profile) - } - } - is ProfileImportHandler.ImportResult.Error -> { - withContext(Dispatchers.Main) { - context.errorDialogBuilder(Exception(importResult.message)).show() - } - } - } - } - is ProfileImportHandler.QRCodeParseResult.Error -> { - withContext(Dispatchers.Main) { - context.errorDialogBuilder(Exception(parseResult.message)).show() - } - } - } - } - } - } - }, - ) - } -} - -private suspend fun createProfileContent(profile: Profile): ByteArray { - val content = ProfileContent() - content.name = profile.name - when (profile.typed.type) { - TypedProfile.Type.Local -> { - content.type = Libbox.ProfileTypeLocal - } - TypedProfile.Type.Remote -> { - content.type = Libbox.ProfileTypeRemote - } - } - content.config = java.io.File(profile.typed.path).readText() - content.remotePath = profile.typed.remoteURL - content.autoUpdate = profile.typed.autoUpdate - content.autoUpdateInterval = profile.typed.autoUpdateInterval - content.lastUpdated = profile.typed.lastUpdated.time - return content.encode() -} - -@Composable -private fun ProfileInfoRow(profile: Profile?) { - if (profile == null) return - - val context = LocalContext.current - - Row( - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - Icon( - imageVector = if (profile.typed.type == TypedProfile.Type.Remote) { - Icons.Default.Cloud - } else { - Icons.Outlined.Description - }, - contentDescription = null, - modifier = Modifier.size(14.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = if (profile.typed.type == TypedProfile.Type.Remote) { - stringResource(R.string.profile_type_remote) - } else { - stringResource(R.string.profile_type_local) - }, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - if (profile.typed.type == TypedProfile.Type.Remote) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - Icon( - imageVector = Icons.Default.AccessTime, - contentDescription = null, - modifier = Modifier.size(14.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = RelativeTimeFormatter.format(context, profile.typed.lastUpdated), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } -} - -@Composable -private fun ProfileActionRow( - profile: Profile?, - isUpdating: Boolean, - showUpdateSuccess: Boolean, - onEdit: () -> Unit, - onUpdate: () -> Unit, - onShareFile: () -> Unit, - onSaveFile: () -> Unit, - onSaveJson: () -> Unit, - onShareJson: () -> Unit, - onShareURL: () -> Unit, - onShareQRS: () -> Unit, -) { - if (profile == null) return - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - ActionButton( - icon = Icons.Default.Edit, - contentDescription = stringResource(R.string.edit), - onClick = onEdit, - ) - - if (profile.typed.type == TypedProfile.Type.Remote) { - ActionButton( - icon = when { - showUpdateSuccess -> Icons.Default.Check - else -> Icons.Default.Refresh - }, - contentDescription = stringResource(R.string.update_profile), - onClick = onUpdate, - enabled = !isUpdating && !showUpdateSuccess, - isLoading = isUpdating, - ) - } - - ShareButton( - profile = profile, - onShareFile = onShareFile, - onSaveFile = onSaveFile, - onSaveJson = onSaveJson, - onShareJson = onShareJson, - onShareURL = onShareURL, - onShareQRS = onShareQRS, - ) - } -} - -@Composable -private fun ActionButton( - icon: ImageVector, - contentDescription: String, - onClick: () -> Unit, - enabled: Boolean = true, - isLoading: Boolean = false, -) { - Surface( - onClick = onClick, - enabled = enabled, - shape = RoundedCornerShape(12.dp), - color = if (isSystemInDarkTheme()) { - lerp( - MaterialTheme.colorScheme.surfaceContainerHighest, - MaterialTheme.colorScheme.surfaceContainerHigh, - 0.5f, - ) - } else { - MaterialTheme.colorScheme.surfaceDim - }, - modifier = Modifier.size(44.dp), - ) { - Box(contentAlignment = Alignment.Center) { - if (isLoading) { - CircularProgressIndicator( - modifier = Modifier.size(20.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.primary, - ) - } else { - Icon( - imageVector = icon, - contentDescription = contentDescription, - modifier = Modifier.size(20.dp), - tint = if (enabled) { - MaterialTheme.colorScheme.onSurfaceVariant - } else { - MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f) - }, - ) - } - } - } -} - -@Composable -private fun ShareButton( - profile: Profile, - onShareFile: () -> Unit, - onSaveFile: () -> Unit, - onSaveJson: () -> Unit, - onShareJson: () -> Unit, - onShareURL: () -> Unit, - onShareQRS: () -> Unit, -) { - var expanded by remember { mutableStateOf(false) } - - Box { - ActionButton( - icon = Icons.Default.IosShare, - contentDescription = stringResource(R.string.menu_share), - onClick = { expanded = true }, - ) - - DropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false }, - ) { - DropdownMenuItem( - text = { Text(stringResource(R.string.save_as_file)) }, - onClick = { - expanded = false - onSaveFile() - }, - leadingIcon = { - Icon( - imageVector = Icons.Default.Save, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - }, - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.share_as_file)) }, - onClick = { - expanded = false - onShareFile() - }, - leadingIcon = { - Icon( - imageVector = Icons.Default.IosShare, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - }, - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.save_content_json)) }, - onClick = { - expanded = false - onSaveJson() - }, - leadingIcon = { - Icon( - imageVector = Icons.Default.DataObject, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - }, - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.share_content_json)) }, - onClick = { - expanded = false - onShareJson() - }, - leadingIcon = { - Icon( - imageVector = Icons.Default.DataObject, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - }, - ) - if (profile.typed.type == TypedProfile.Type.Remote) { - DropdownMenuItem( - text = { Text(stringResource(R.string.profile_share_url)) }, - onClick = { - expanded = false - onShareURL() - }, - leadingIcon = { - Icon( - imageVector = Icons.Default.QrCode2, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - }, - ) - } - DropdownMenuItem( - text = { Text(stringResource(R.string.share_as_qrs)) }, - onClick = { - expanded = false - onShareQRS() - }, - leadingIcon = { - Icon( - imageVector = Icons.Default.QrCode2, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - }, - ) - } - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/SystemProxyCard.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/SystemProxyCard.kt deleted file mode 100644 index 479ddb0da9..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/SystemProxyCard.kt +++ /dev/null @@ -1,62 +0,0 @@ -package io.nekohasekai.sfa.compose.screen.dashboard - -import androidx.compose.foundation.layout.Arrangement -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.layout.width -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.SettingsEthernet -import androidx.compose.material3.Card -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Switch -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.text.font.FontWeight -import androidx.compose.ui.unit.dp -import io.nekohasekai.sfa.R - -@Composable -fun SystemProxyCard(enabled: Boolean, isSwitching: Boolean, onToggle: (Boolean) -> Unit, modifier: Modifier = Modifier) { - Card( - modifier = modifier.fillMaxWidth(), - ) { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Row( - modifier = Modifier.weight(1f), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = Icons.Outlined.SettingsEthernet, - contentDescription = null, - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.primary, - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = stringResource(R.string.system_http_proxy), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - ) - } - Switch( - checked = enabled, - onCheckedChange = onToggle, - enabled = !isSwitching, - ) - } - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/UploadTrafficCard.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/UploadTrafficCard.kt deleted file mode 100644 index 23ed1fb578..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/UploadTrafficCard.kt +++ /dev/null @@ -1,79 +0,0 @@ -package io.nekohasekai.sfa.compose.screen.dashboard - -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.material.icons.Icons -import androidx.compose.material.icons.outlined.Upload -import androidx.compose.material3.Card -import androidx.compose.material3.Icon -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.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import io.nekohasekai.sfa.R -import io.nekohasekai.sfa.compose.LineChart - -@Composable -fun UploadTrafficCard(uplink: String, uplinkTotal: String, uplinkHistory: List, modifier: Modifier = Modifier) { - Card( - modifier = modifier.fillMaxWidth(), - ) { - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(16.dp), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = Icons.Outlined.Upload, - contentDescription = null, - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.primary, - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = stringResource(R.string.upload), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - ) - } - - Spacer(modifier = Modifier.height(8.dp)) - - Text( - text = uplink, - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onSurface, - ) - - Text( - text = uplinkTotal, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - Spacer(modifier = Modifier.height(12.dp)) - - LineChart( - data = uplinkHistory, - lineColor = MaterialTheme.colorScheme.primary, - animate = false, - modifier = Modifier.fillMaxWidth(), - ) - } - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/groups/GroupsScreen.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/groups/GroupsScreen.kt deleted file mode 100644 index d18be3f682..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/groups/GroupsScreen.kt +++ /dev/null @@ -1,503 +0,0 @@ -package io.nekohasekai.sfa.compose.screen.dashboard.groups - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.tween -import androidx.compose.animation.expandVertically -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.shrinkVertically -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically -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.items -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ExpandMore -import androidx.compose.material.icons.filled.Speed -import androidx.compose.material3.Card -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -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.SnackbarHostState -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.platform.LocalContext -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.viewmodel.compose.viewModel -import io.nekohasekai.libbox.Libbox -import io.nekohasekai.sfa.R -import io.nekohasekai.sfa.compose.model.Group -import io.nekohasekai.sfa.compose.model.GroupItem -import io.nekohasekai.sfa.constant.Status - -@Composable -fun GroupsScreen( - serviceStatus: Status, - viewModel: GroupsViewModel = viewModel(), - snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, - onToggleAllGroups: () -> Unit = { viewModel.toggleAllGroups() }, - modifier: Modifier = Modifier, -) { - val uiState by viewModel.uiState.collectAsState() - val context = LocalContext.current - - // Stable callbacks to prevent recomposition - val onToggleExpanded = - remember<(String) -> Unit> { - { groupTag -> viewModel.toggleGroupExpand(groupTag) } - } - val onItemSelected = - remember<(String, String) -> Unit> { - { groupTag, itemTag -> viewModel.selectGroupItem(groupTag, itemTag) } - } - val onUrlTest = - remember<(String) -> Unit> { - { groupTag -> viewModel.urlTest(groupTag) } - } - - LaunchedEffect(serviceStatus, viewModel) { - viewModel.updateServiceStatus(serviceStatus) - } - - // Show snackbar when needed - LaunchedEffect(uiState.showCloseConnectionsSnackbar) { - if (uiState.showCloseConnectionsSnackbar) { - val message = context.getString(R.string.close_connections_confirm) - val actionLabel = context.getString(R.string.close) - val result = - snackbarHostState.showSnackbar( - message = message, - actionLabel = actionLabel, - duration = androidx.compose.material3.SnackbarDuration.Indefinite, - withDismissAction = true, - ) - when (result) { - androidx.compose.material3.SnackbarResult.ActionPerformed -> { - viewModel.closeConnections() - } - androidx.compose.material3.SnackbarResult.Dismissed -> { - viewModel.dismissCloseConnectionsSnackbar() - } - } - } - } - - if (uiState.isLoading) { - Box( - modifier = modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator() - } - } else { - LazyColumn( - modifier = modifier.fillMaxSize(), - contentPadding = - PaddingValues( - start = 16.dp, - end = 16.dp, - top = 8.dp, - bottom = 16.dp, - ), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - items( - items = uiState.groups, - key = { it.tag }, - contentType = { "GroupCard" }, - ) { group -> - ProxyGroupCard( - group = group, - isExpanded = uiState.expandedGroups.contains(group.tag), - onToggleExpanded = remember { { onToggleExpanded(group.tag) } }, - onItemSelected = remember { { itemTag -> onItemSelected(group.tag, itemTag) } }, - onUrlTest = remember { { onUrlTest(group.tag) } }, - ) - } - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun ProxyGroupCard( - group: Group, - isExpanded: Boolean, - onToggleExpanded: () -> Unit, - onItemSelected: (String) -> Unit, - onUrlTest: () -> Unit, -) { - Card( - modifier = Modifier.fillMaxWidth(), - ) { - Column( - modifier = Modifier.fillMaxWidth(), - ) { - // Header (clickable to expand/collapse) - Surface( - onClick = onToggleExpanded, - color = Color.Transparent, - ) { - ListItem( - headlineContent = { - Column { - Text( - text = group.tag, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface, - ) - - Spacer(modifier = Modifier.height(4.dp)) - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - text = Libbox.proxyDisplayType(group.type), - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - // Show selected item when collapsed - AnimatedVisibility( - visible = !isExpanded && group.selected.isNotEmpty(), - enter = fadeIn(), - exit = fadeOut(), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - Text( - text = "•", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = group.selected, - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.primary, - ) - } - } - } - } - }, - trailingContent = { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - // URL Test button - AnimatedVisibility( - visible = group.selectable, - enter = slideInVertically() + fadeIn(), - exit = slideOutVertically() + fadeOut(), - ) { - IconButton( - onClick = { - onUrlTest() - // Don't toggle expansion when clicking URL test - }, - modifier = Modifier.size(40.dp), - ) { - Icon( - imageVector = Icons.Default.Speed, - contentDescription = stringResource(R.string.url_test), - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - - // Expand/Collapse indicator - val rotationAngle by animateFloatAsState( - targetValue = if (isExpanded) 180f else 0f, - animationSpec = tween(300), - label = "ExpandIcon", - ) - - val expandContentDescription = stringResource(R.string.expand) - val collapseContentDescription = stringResource(R.string.collapse) - Icon( - imageVector = Icons.Default.ExpandMore, - contentDescription = if (isExpanded) collapseContentDescription else expandContentDescription, - modifier = - Modifier - .size(24.dp) - .graphicsLayer { rotationZ = rotationAngle }, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - }, - colors = - ListItemDefaults.colors( - containerColor = Color.Transparent, - ), - ) - } - - // Expandable content - AnimatedVisibility( - visible = isExpanded && group.items.isNotEmpty(), - enter = expandVertically(animationSpec = tween(300)) + fadeIn(animationSpec = tween(300)), - exit = shrinkVertically(animationSpec = tween(300)) + fadeOut(animationSpec = tween(300)), - ) { - Column { - androidx.compose.material3.HorizontalDivider( - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f), - thickness = 1.dp, - ) - - // Proxy Items - ProxyItemsList( - items = group.items, - selectedTag = group.selected, - isSelectable = group.selectable, - onItemSelected = onItemSelected, - ) - } - } - } - } -} - -@Composable -private fun ProxyItemsList(items: List, selectedTag: String, isSelectable: Boolean, onItemSelected: (String) -> Unit) { - // Cache the chunked items to avoid re-chunking on every recomposition - val itemsPerRow = 2 - val chunkedItems = - remember(items) { - items.chunked(itemsPerRow) - } - - // Use Column with Rows for better control over item sizing - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - chunkedItems.forEach { rowItems -> - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - rowItems.forEach { item -> - Box( - modifier = Modifier.weight(1f), - ) { - ProxyChip( - item = item, - isSelected = item.tag == selectedTag, - isSelectable = isSelectable, - onClick = remember { { onItemSelected(item.tag) } }, - modifier = Modifier.fillMaxWidth(), - ) - } - } - // Add empty boxes for incomplete rows to maintain equal sizing - repeat(itemsPerRow - rowItems.size) { - Box(modifier = Modifier.weight(1f)) - } - } - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun ProxyChip(item: GroupItem, isSelected: Boolean, isSelectable: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) { - // Use simpler, faster animations - val animatedElevation by animateFloatAsState( - targetValue = if (isSelected) 6.dp.value else 1.dp.value, - animationSpec = tween(150), - label = "Elevation", - ) - - val surfaceModifier = modifier - val surfaceShape = RoundedCornerShape(8.dp) - val surfaceColor = - when { - isSelected -> MaterialTheme.colorScheme.primaryContainer - else -> MaterialTheme.colorScheme.surface - } - val surfaceBorder = - androidx.compose.foundation.BorderStroke( - width = if (isSelected) 2.dp else 1.dp, - color = - when { - isSelected -> MaterialTheme.colorScheme.primary.copy(alpha = 0.8f) - else -> MaterialTheme.colorScheme.outline.copy(alpha = 0.2f) - }, - ) - - val content: @Composable () -> Unit = { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(12.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - // First line: Name - Text( - text = item.tag, - style = MaterialTheme.typography.bodyMedium, - fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium, - color = - if (isSelected) { - MaterialTheme.colorScheme.onPrimaryContainer - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - - // Second line: Type on left, Latency on right - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - // Type - Text( - text = Libbox.proxyDisplayType(item.type), - style = MaterialTheme.typography.labelSmall, - color = - if (isSelected) { - MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f) - } else { - MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) - }, - ) - - // Latency - AnimatedVisibility( - visible = item.urlTestTime > 0, - enter = fadeIn(), - exit = fadeOut(), - ) { - ProxyLatencyBadge( - delay = item.urlTestDelay, - isSelected = isSelected, - ) - } - } - } - } - } - - if (isSelectable) { - Surface( - onClick = onClick, - modifier = surfaceModifier, - shape = surfaceShape, - color = surfaceColor, - tonalElevation = animatedElevation.dp, - border = surfaceBorder, - content = content, - ) - } else { - Surface( - modifier = surfaceModifier, - shape = surfaceShape, - color = surfaceColor, - tonalElevation = animatedElevation.dp, - border = surfaceBorder, - content = content, - ) - } -} - -@Composable -private fun ProxyLatencyBadge(delay: Int, isSelected: Boolean, modifier: Modifier = Modifier) { - // Direct color calculation without animation for better performance - val colorScheme = MaterialTheme.colorScheme - val latencyColor = - remember(delay, isSelected, colorScheme) { - when { - delay < 100 -> { - // Excellent - green/tertiary - if (isSelected) { - colorScheme.tertiary - } else { - colorScheme.tertiary.copy(alpha = 0.9f) - } - } - - delay < 300 -> { - // Good - primary - if (isSelected) { - colorScheme.primary - } else { - colorScheme.primary.copy(alpha = 0.9f) - } - } - - delay < 500 -> { - // Fair - secondary/warning - if (isSelected) { - colorScheme.secondary - } else { - colorScheme.secondary.copy(alpha = 0.9f) - } - } - - else -> { - // Poor - error - if (isSelected) { - colorScheme.error - } else { - colorScheme.error.copy(alpha = 0.9f) - } - } - } - } - - Text( - text = "${delay}ms", - style = MaterialTheme.typography.labelSmall, - fontWeight = FontWeight.SemiBold, - color = latencyColor, - modifier = modifier, - ) -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/groups/GroupsViewModel.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/groups/GroupsViewModel.kt deleted file mode 100644 index 5d1c0f83bb..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/groups/GroupsViewModel.kt +++ /dev/null @@ -1,320 +0,0 @@ -package io.nekohasekai.sfa.compose.screen.dashboard.groups - -import androidx.lifecycle.viewModelScope -import io.nekohasekai.libbox.Libbox -import io.nekohasekai.libbox.OutboundGroup -import io.nekohasekai.sfa.compose.base.BaseViewModel -import io.nekohasekai.sfa.compose.base.ScreenEvent -import io.nekohasekai.sfa.compose.model.Group -import io.nekohasekai.sfa.compose.model.GroupItem -import io.nekohasekai.sfa.compose.model.toList -import io.nekohasekai.sfa.constant.Status -import io.nekohasekai.sfa.utils.AppLifecycleObserver -import io.nekohasekai.sfa.utils.CommandClient -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -data class GroupsUiState( - val groups: List = emptyList(), - val isLoading: Boolean = false, - val expandedGroups: Set = emptySet(), - val showCloseConnectionsSnackbar: Boolean = false, -) - -sealed class GroupsEvent : ScreenEvent { - data class GroupSelected(val groupTag: String, val itemTag: String) : GroupsEvent() -} - -class GroupsViewModel(private val sharedCommandClient: CommandClient? = null) : - BaseViewModel(), - CommandClient.Handler { - private val commandClient: CommandClient - private val isUsingSharedClient: Boolean - - private val _serviceStatus = MutableStateFlow(Status.Stopped) - val serviceStatus = _serviceStatus.asStateFlow() - private var lastServiceStatus: Status = Status.Stopped - - init { - if (sharedCommandClient != null) { - commandClient = sharedCommandClient - isUsingSharedClient = true - commandClient.addHandler(this) - } else { - commandClient = - CommandClient( - viewModelScope, - CommandClient.ConnectionType.Groups, - this, - ) - isUsingSharedClient = false - } - - viewModelScope.launch { - AppLifecycleObserver.isForeground.collect { foreground -> - if (lastServiceStatus != Status.Started) return@collect - if (foreground) { - if (isUsingSharedClient) { - commandClient.addHandler(this@GroupsViewModel) - } else { - updateState { copy(isLoading = true) } - commandClient.connect() - } - } else { - if (isUsingSharedClient) { - commandClient.removeHandler(this@GroupsViewModel) - } else { - commandClient.disconnect() - } - } - } - } - } - - override fun createInitialState() = GroupsUiState() - - override fun onCleared() { - super.onCleared() - if (isUsingSharedClient) { - commandClient.removeHandler(this) - } else { - commandClient.disconnect() - } - } - - private fun handleServiceStatusChange(status: Status) { - if (status == Status.Started) { - if (!isUsingSharedClient && AppLifecycleObserver.isForeground.value) { - updateState { copy(isLoading = true) } - commandClient.connect() - } - } else { - if (!isUsingSharedClient) { - commandClient.disconnect() - } - updateState { - copy( - groups = emptyList(), - isLoading = false, - ) - } - } - } - - fun updateServiceStatus(status: Status) { - if (status == lastServiceStatus) { - return - } - lastServiceStatus = status - viewModelScope.launch { - _serviceStatus.emit(status) - handleServiceStatusChange(status) - } - } - - fun toggleGroupExpand(groupTag: String) { - val newExpanded = !uiState.value.expandedGroups.contains(groupTag) - updateState { - val newExpandedGroups = if (newExpanded) { - expandedGroups + groupTag - } else { - expandedGroups - groupTag - } - copy(expandedGroups = newExpandedGroups) - } - viewModelScope.launch(Dispatchers.IO) { - runCatching { - Libbox.newStandaloneCommandClient().setGroupExpand(groupTag, newExpanded) - } - } - } - - fun toggleAllGroups() { - val groups = uiState.value.groups - val allCollapsed = uiState.value.expandedGroups.isEmpty() - val newExpanded = allCollapsed - - updateState { - if (allCollapsed) { - copy(expandedGroups = groups.map { it.tag }.toSet()) - } else { - copy(expandedGroups = emptySet()) - } - } - - viewModelScope.launch(Dispatchers.IO) { - groups.forEach { group -> - runCatching { - Libbox.newStandaloneCommandClient().setGroupExpand(group.tag, newExpanded) - } - } - } - } - - fun selectGroupItem(groupTag: String, itemTag: String) { - // Check if this is actually a different selection - val currentGroup = uiState.value.groups.find { it.tag == groupTag } - if (currentGroup?.selected == itemTag) { - // Same item selected, no need to do anything - return - } - - viewModelScope.launch(Dispatchers.IO) { - try { - // Select the new outbound immediately - Libbox.newStandaloneCommandClient().selectOutbound(groupTag, itemTag) - - // Update local state and show snackbar - withContext(Dispatchers.Main) { - updateState { - copy( - groups = - groups.map { group -> - if (group.tag == groupTag) { - group.copy(selected = itemTag) - } else { - group - } - }, - showCloseConnectionsSnackbar = true, - ) - } - sendEvent(GroupsEvent.GroupSelected(groupTag, itemTag)) - } - } catch (e: Exception) { - sendError(e) - } - } - } - - fun closeConnections() { - viewModelScope.launch(Dispatchers.IO) { - try { - Libbox.newStandaloneCommandClient().closeConnections() - withContext(Dispatchers.Main) { - dismissCloseConnectionsSnackbar() - } - } catch (e: Exception) { - withContext(Dispatchers.Main) { - dismissCloseConnectionsSnackbar() - } - sendError(e) - } - } - } - - fun dismissCloseConnectionsSnackbar() { - updateState { - copy(showCloseConnectionsSnackbar = false) - } - } - - fun urlTest(groupTag: String) { - viewModelScope.launch(Dispatchers.IO) { - try { - Libbox.newStandaloneCommandClient().urlTest(groupTag) - } catch (e: Exception) { - sendError(e) - } - } - } - - // CommandClient.Handler implementation - override fun onConnected() { - viewModelScope.launch(Dispatchers.Main) { - // Connection established, waiting for groups - } - } - - override fun onDisconnected() { - viewModelScope.launch(Dispatchers.Main) { - updateState { - copy( - groups = emptyList(), - isLoading = false, - ) - } - } - } - - override fun updateGroups(newGroups: MutableList) { - viewModelScope.launch(Dispatchers.Default) { - val currentGroups = uiState.value.groups - val newGroupsMap = newGroups.associateBy { it.tag } - - // Smart merge: preserve existing Group objects when only delays change - val mergedGroups = - if (currentGroups.isEmpty()) { - // Initial load - newGroups.map(::Group) - } else { - currentGroups.map { existingGroup -> - val newGroupData = newGroupsMap[existingGroup.tag] - if (newGroupData != null) { - // Check if only delays have changed - val newItems = newGroupData.items.toList() - val hasStructuralChange = - existingGroup.items.size != newItems.size || - existingGroup.selected != newGroupData.selected || - existingGroup.type != newGroupData.type || - existingGroup.selectable != newGroupData.selectable - - if (hasStructuralChange) { - // Structural change, create new Group - Group(newGroupData) - } else { - // Only delays might have changed, update items efficiently - val updatedItems = - existingGroup.items.mapIndexed { index, item -> - val newItemData = newItems.getOrNull(index) - if (newItemData != null && - item.tag == newItemData.tag && - item.type == newItemData.type - ) { - // Only update if delay actually changed - if (item.urlTestDelay != newItemData.urlTestDelay || - item.urlTestTime != newItemData.urlTestTime - ) { - GroupItem(newItemData) - } else { - item // Keep existing object - } - } else { - if (newItemData != null) { - GroupItem(newItemData) - } else { - item // Keep existing if index out of bounds - } - } - } - existingGroup.copy(items = updatedItems) - } - } else { - existingGroup - } - } + - newGroups.filter { newGroup -> - currentGroups.none { it.tag == newGroup.tag } - }.map(::Group) - } - - withContext(Dispatchers.Main) { - updateState { - val initialExpandedGroups = if (expandedGroups.isEmpty() && currentGroups.isEmpty()) { - mergedGroups.filter { it.isExpand }.map { it.tag }.toSet() - } else { - expandedGroups - } - copy( - groups = mergedGroups, - expandedGroups = initialExpandedGroups, - isLoading = false, - ) - } - } - } - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/BaseLogViewModel.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/BaseLogViewModel.kt deleted file mode 100644 index 9fe1a46298..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/BaseLogViewModel.kt +++ /dev/null @@ -1,153 +0,0 @@ -package io.nekohasekai.sfa.compose.screen.log - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import io.nekohasekai.sfa.compose.util.AnsiColorUtils -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import java.util.LinkedList -import java.util.concurrent.atomic.AtomicLong - -@OptIn(FlowPreview::class) -abstract class BaseLogViewModel : - ViewModel(), - LogViewerViewModel { - protected val _uiState = MutableStateFlow(LogUiState()) - override val uiState: StateFlow = _uiState.asStateFlow() - - protected val _autoScrollEnabled = MutableStateFlow(true) - override val isAtBottom: StateFlow = _autoScrollEnabled.asStateFlow() - - protected val _scrollToBottomTrigger = MutableStateFlow(0) - override val scrollToBottomTrigger: StateFlow = _scrollToBottomTrigger.asStateFlow() - - protected val _searchQueryInternal = MutableStateFlow("") - protected val logIdGenerator = AtomicLong(0) - protected val allLogs = LinkedList() - - init { - viewModelScope.launch { - _searchQueryInternal - .debounce(300) - .distinctUntilChanged() - .collect { - updateDisplayedLogs() - } - } - } - - override fun toggleSearch() { - _uiState.update { - it.copy( - isSearchActive = !it.isSearchActive, - searchQuery = if (!it.isSearchActive) it.searchQuery else "", - ) - } - updateDisplayedLogs() - } - - override fun toggleOptionsMenu() { - _uiState.update { it.copy(isOptionsMenuOpen = !it.isOptionsMenuOpen) } - } - - override fun updateSearchQuery(query: String) { - _uiState.update { it.copy(searchQuery = query) } - _searchQueryInternal.value = query - } - - override fun setLogLevel(level: LogLevel) { - _uiState.update { it.copy(filterLogLevel = level) } - updateDisplayedLogs() - } - - override fun setAutoScrollEnabled(enabled: Boolean) { - _autoScrollEnabled.value = enabled - } - - override fun scrollToBottom() { - _autoScrollEnabled.value = true - _scrollToBottomTrigger.value++ - } - - override fun toggleSelectionMode() { - _uiState.update { - if (it.isSelectionMode) { - it.copy(isSelectionMode = false, selectedLogIndices = emptySet(), isPaused = false) - } else { - it.copy(isSelectionMode = true, isPaused = true) - } - } - } - - override fun toggleLogSelection(index: Int) { - _uiState.update { state -> - val newSelection = - if (state.selectedLogIndices.contains(index)) { - state.selectedLogIndices - index - } else { - state.selectedLogIndices + index - } - if (newSelection.isEmpty()) { - state.copy( - isSelectionMode = false, - selectedLogIndices = emptySet(), - isPaused = false, - ) - } else { - state.copy(selectedLogIndices = newSelection) - } - } - } - - override fun clearSelection() { - _uiState.update { - it.copy(isSelectionMode = false, selectedLogIndices = emptySet(), isPaused = false) - } - } - - override fun getSelectedLogsText(): String { - val state = _uiState.value - return state.selectedLogIndices - .sorted() - .mapNotNull { index -> - state.logs.getOrNull(index)?.entry?.message?.let { AnsiColorUtils.stripAnsi(it) } - } - .joinToString("\n") - } - - override fun getAllLogsText(): String = _uiState.value.logs.joinToString("\n") { AnsiColorUtils.stripAnsi(it.entry.message) } - - protected fun updateDisplayedLogs() { - val currentState = _uiState.value - val levelPriority = - if (currentState.filterLogLevel != LogLevel.Default) { - currentState.filterLogLevel.priority - } else { - currentState.defaultLogLevel.priority - } - val searchQuery = currentState.searchQuery - - val logsToDisplay = - allLogs.asSequence() - .filter { log -> log.entry.level.priority <= levelPriority } - .filter { log -> - searchQuery.isEmpty() || log.entry.message.contains(searchQuery, ignoreCase = true) - } - .toList() - - val selectionCleared = - if (_uiState.value.isSelectionMode && _uiState.value.logs != logsToDisplay) { - emptySet() - } else { - _uiState.value.selectedLogIndices - } - - _uiState.update { it.copy(logs = logsToDisplay, selectedLogIndices = selectionCleared) } - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/HookLogScreen.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/HookLogScreen.kt deleted file mode 100644 index c5ed95ef61..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/HookLogScreen.kt +++ /dev/null @@ -1,32 +0,0 @@ -package io.nekohasekai.sfa.compose.screen.log - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.platform.LocalContext -import androidx.lifecycle.viewmodel.compose.viewModel -import io.nekohasekai.sfa.R -import io.nekohasekai.sfa.constant.Status - -@Composable -fun HookLogScreen(onBack: () -> Unit) { - val viewModel: HookLogViewModel = viewModel() - val context = LocalContext.current - - LaunchedEffect(Unit) { - viewModel.loadLogs(context) - } - - LogScreen( - serviceStatus = Status.Stopped, - showStartFab = false, - showStatusBar = false, - title = context.getString(R.string.title_log), - viewModel = viewModel, - showPause = false, - showClear = false, - showStatusInfo = false, - emptyMessage = context.getString(R.string.privilege_settings_hook_logs_empty), - saveFilePrefix = "hook_logs", - onBack = onBack, - ) -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/HookLogViewModel.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/HookLogViewModel.kt deleted file mode 100644 index 777bf50685..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/HookLogViewModel.kt +++ /dev/null @@ -1,117 +0,0 @@ -package io.nekohasekai.sfa.compose.screen.log - -import android.content.Context -import android.text.format.DateFormat -import androidx.lifecycle.viewModelScope -import io.nekohasekai.sfa.bg.LogEntry -import io.nekohasekai.sfa.compose.util.AnsiColorUtils -import io.nekohasekai.sfa.constant.Status -import io.nekohasekai.sfa.utils.HookErrorClient -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.util.Date - -class HookLogViewModel : BaseLogViewModel() { - - fun loadLogs(context: Context) { - viewModelScope.launch { - val result = withContext(Dispatchers.IO) { - HookErrorClient.query(context) - } - if (result.failure != null) { - val detail = buildErrorMessage(result) - allLogs.clear() - _uiState.update { - it.copy( - logs = emptyList(), - isConnected = false, - errorTitle = "Error", - errorMessage = detail, - ) - } - return@launch - } - val logs = result.logs.map { processLogEntry(it) } - allLogs.clear() - allLogs.addAll(logs) - _uiState.update { - it.copy( - logs = emptyList(), - isConnected = true, - errorTitle = null, - errorMessage = null, - ) - } - updateDisplayedLogs() - } - } - - private companion object { - private const val ANSI_RESET = "\u001B[0m" - private const val ANSI_RED = "\u001B[31m" - private const val ANSI_YELLOW = "\u001B[33m" - private const val ANSI_CYAN = "\u001B[36m" - private const val ANSI_WHITE = "\u001B[37m" - } - - private fun processLogEntry(entry: LogEntry): ProcessedLogEntry { - val level = when (entry.level) { - LogEntry.LEVEL_DEBUG -> LogLevel.DEBUG - LogEntry.LEVEL_INFO -> LogLevel.INFO - LogEntry.LEVEL_WARN -> LogLevel.WARNING - LogEntry.LEVEL_ERROR -> LogLevel.ERROR - else -> LogLevel.Default - } - val (levelName, levelColor) = when (entry.level) { - LogEntry.LEVEL_DEBUG -> "DEBUG" to ANSI_WHITE - LogEntry.LEVEL_INFO -> "INFO" to ANSI_CYAN - LogEntry.LEVEL_WARN -> "WARN" to ANSI_YELLOW - LogEntry.LEVEL_ERROR -> "ERROR" to ANSI_RED - else -> "UNKNOWN" to ANSI_WHITE - } - val timestamp = DateFormat.format("HH:mm:ss", Date(entry.timestamp)).toString() - val message = buildString { - append(levelColor).append(levelName).append(ANSI_RESET) - append("[").append(timestamp).append("] ") - append("[").append(entry.source).append("]: ") - append(entry.message) - if (!entry.stackTrace.isNullOrEmpty()) { - append("\n").append(entry.stackTrace) - } - } - return ProcessedLogEntry( - id = logIdGenerator.incrementAndGet(), - entry = LogEntryData(level, AnsiColorUtils.stripAnsi(message)), - annotatedString = AnsiColorUtils.ansiToAnnotatedString(message), - ) - } - - private fun buildErrorMessage(result: HookErrorClient.Result): String { - val message = when (result.failure) { - HookErrorClient.Failure.SERVICE_UNAVAILABLE -> - "Connectivity service unavailable. Reboot or activate LSPosed module." - HookErrorClient.Failure.TRANSACTION_FAILED -> - "Hook transaction rejected. Reboot to load LSPosed module." - HookErrorClient.Failure.REMOTE_ERROR -> - "Remote error while reading logs." - HookErrorClient.Failure.PROTOCOL_ERROR -> - "Log protocol mismatch. Reboot to update LSPosed module." - null -> "Unknown error." - } - val detail = result.detail?.takeIf { it.isNotBlank() } - return if (detail != null) "$message\n$detail" else message - } - - override fun updateServiceStatus(status: Status) { - _uiState.update { it.copy(serviceStatus = status) } - } - - override fun togglePause() { - _uiState.update { it.copy(isPaused = false) } - } - - override fun requestClearLogs() { - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/LogModels.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/LogModels.kt deleted file mode 100644 index aad3eaffe1..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/LogModels.kt +++ /dev/null @@ -1,36 +0,0 @@ -package io.nekohasekai.sfa.compose.screen.log - -import androidx.compose.ui.text.AnnotatedString -import io.nekohasekai.sfa.constant.Status - -data class LogEntryData(val level: LogLevel, val message: String) - -data class ProcessedLogEntry(val id: Long, val entry: LogEntryData, val annotatedString: AnnotatedString) - -enum class LogLevel(val label: String, val priority: Int) { - Default("Default", 7), - - PANIC("Panic", 0), - FATAL("Fatal", 1), - ERROR("Error", 2), - WARNING("Warn", 3), - INFO("Info", 4), - DEBUG("Debug", 5), - TRACE("Trace", 6), -} - -data class LogUiState( - val logs: List = emptyList(), - val isConnected: Boolean = false, - val serviceStatus: Status = Status.Stopped, - val isPaused: Boolean = false, - val searchQuery: String = "", - val isSearchActive: Boolean = false, - val defaultLogLevel: LogLevel = LogLevel.Default, - val filterLogLevel: LogLevel = LogLevel.Default, - val isOptionsMenuOpen: Boolean = false, - val isSelectionMode: Boolean = false, - val selectedLogIndices: Set = emptySet(), - val errorTitle: String? = null, - val errorMessage: String? = null, -) diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/LogScreen.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/LogScreen.kt deleted file mode 100644 index e4ab1f49ba..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/LogScreen.kt +++ /dev/null @@ -1,949 +0,0 @@ -package io.nekohasekai.sfa.compose.screen.log - -import android.content.ClipData -import android.content.Intent -import android.os.Build -import android.widget.Toast -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.tween -import androidx.compose.animation.expandVertically -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.scaleIn -import androidx.compose.animation.scaleOut -import androidx.compose.animation.shrinkVertically -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.interaction.DragInteraction -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.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.CheckBox -import androidx.compose.material.icons.filled.CheckBoxOutlineBlank -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.ContentCopy -import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.ExpandLess -import androidx.compose.material.icons.filled.ExpandMore -import androidx.compose.material.icons.filled.FilterList -import androidx.compose.material.icons.filled.KeyboardArrowDown -import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material.icons.filled.Pause -import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material.icons.filled.RadioButtonChecked -import androidx.compose.material.icons.filled.RadioButtonUnchecked -import androidx.compose.material.icons.filled.Save -import androidx.compose.material.icons.filled.Search -import androidx.compose.material.icons.filled.Share -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FloatingActionButton -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.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.derivedStateOf -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.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.core.content.FileProvider -import androidx.lifecycle.viewmodel.compose.viewModel -import io.nekohasekai.sfa.Application -import io.nekohasekai.sfa.R -import io.nekohasekai.sfa.compat.WindowSizeClassCompat -import io.nekohasekai.sfa.compat.isWidthAtLeastBreakpointCompat -import io.nekohasekai.sfa.compose.topbar.OverrideTopBar -import io.nekohasekai.sfa.constant.Status -import java.io.File -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun LogScreen( - serviceStatus: Status = Status.Stopped, - showStartFab: Boolean = false, - showStatusBar: Boolean = false, - title: String? = null, - viewModel: LogViewerViewModel? = null, - showPause: Boolean = true, - showClear: Boolean = true, - showStatusInfo: Boolean = true, - emptyMessage: String? = null, - saveFilePrefix: String = "logs", - onBack: (() -> Unit)? = null, -) { - val resolvedViewModel = viewModel ?: viewModel() - val uiState by resolvedViewModel.uiState.collectAsState() - val context = LocalContext.current - val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass - val isTablet = windowSizeClass.isWidthAtLeastBreakpointCompat(WindowSizeClassCompat.WIDTH_DP_MEDIUM_LOWER_BOUND) - val listState = rememberLazyListState() - val coroutineScope = rememberCoroutineScope() - val resolvedTitle = title ?: stringResource(R.string.title_log) - val emptyStateMessage = emptyMessage ?: stringResource(R.string.privilege_settings_hook_logs_empty) - - OverrideTopBar { - TopAppBar( - title = { Text(resolvedTitle) }, - navigationIcon = { - if (onBack != null) { - IconButton(onClick = onBack) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.content_description_back), - ) - } - } - }, - actions = { - if (!uiState.isSelectionMode) { - if (showPause) { - IconButton(onClick = { resolvedViewModel.togglePause() }) { - Icon( - imageVector = - if (uiState.isPaused) { - Icons.Default.PlayArrow - } else { - Icons.Default.Pause - }, - contentDescription = - if (uiState.isPaused) { - stringResource(R.string.content_description_resume_logs) - } else { - stringResource(R.string.content_description_pause_logs) - }, - ) - } - } - - IconButton(onClick = { resolvedViewModel.toggleSearch() }) { - Icon( - imageVector = - if (uiState.isSearchActive) { - Icons.Default.ExpandLess - } else { - Icons.Default.Search - }, - contentDescription = - if (uiState.isSearchActive) { - stringResource(R.string.content_description_collapse_search) - } else { - stringResource(R.string.content_description_search_logs) - }, - tint = - if (uiState.isSearchActive) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurface - }, - ) - } - - IconButton(onClick = { resolvedViewModel.toggleOptionsMenu() }) { - Icon( - imageVector = Icons.Default.MoreVert, - contentDescription = stringResource(R.string.more_options), - tint = MaterialTheme.colorScheme.onSurface, - ) - } - } - }, - ) - } - - // Handle back press in selection mode - androidx.activity.compose.BackHandler(enabled = uiState.isSelectionMode) { - resolvedViewModel.clearSelection() - } - - // Track if user is at the bottom of the list - val isAtBottom by remember { - derivedStateOf { - val layoutInfo = listState.layoutInfo - val lastVisibleItem = layoutInfo.visibleItemsInfo.lastOrNull() - lastVisibleItem != null && lastVisibleItem.index == layoutInfo.totalItemsCount - 1 - } - } - - // Re-enable auto-scroll when user reaches bottom - LaunchedEffect(isAtBottom) { - if (isAtBottom) { - resolvedViewModel.setAutoScrollEnabled(true) - } - } - - // Detect user manual scroll to disable auto-scroll - LaunchedEffect(listState) { - var dragStartIndex: Int? = null - var dragStartOffset: Int? = null - - listState.interactionSource.interactions.collect { interaction -> - when (interaction) { - is DragInteraction.Start -> { - dragStartIndex = listState.firstVisibleItemIndex - dragStartOffset = listState.firstVisibleItemScrollOffset - } - is DragInteraction.Stop, is DragInteraction.Cancel -> { - if (dragStartIndex != null && dragStartOffset != null) { - val currentIndex = listState.firstVisibleItemIndex - val currentOffset = listState.firstVisibleItemScrollOffset - - val scrolledUp = - if (dragStartIndex != currentIndex) { - dragStartIndex!! > currentIndex - } else { - dragStartOffset!! > currentOffset - } - - if (scrolledUp) { - resolvedViewModel.setAutoScrollEnabled(false) - } - - dragStartIndex = null - dragStartOffset = null - } - } - } - } - } - - // Handle scroll to bottom requests from ViewModel - val scrollToBottomTrigger by resolvedViewModel.scrollToBottomTrigger.collectAsState() - LaunchedEffect(scrollToBottomTrigger) { - if (scrollToBottomTrigger > 0 && uiState.logs.isNotEmpty()) { - listState.animateScrollToItem(uiState.logs.size - 1) - } - } - - // Update service status in ViewModel - LaunchedEffect(serviceStatus) { - if (showStatusInfo) { - resolvedViewModel.updateServiceStatus(serviceStatus) - } - } - - Box( - modifier = Modifier.fillMaxSize(), - ) { - Column( - modifier = Modifier.fillMaxSize(), - ) { - // Show selection mode bar - if (uiState.isSelectionMode) { - Surface( - modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onPrimaryContainer, - shadowElevation = 2.dp, - ) { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 4.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - IconButton(onClick = { resolvedViewModel.clearSelection() }) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = stringResource(R.string.content_description_exit_selection_mode), - ) - } - Text( - text = - stringResource( - R.string.selected_count, - uiState.selectedLogIndices.size, - ), - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(start = 8.dp), - ) - } - Row { - IconButton( - onClick = { - val selectedText = resolvedViewModel.getSelectedLogsText() - if (selectedText.isNotEmpty()) { - val clipLabel = resolvedTitle - val clip = ClipData.newPlainText(clipLabel, selectedText) - Application.clipboard.setPrimaryClip(clip) - Toast.makeText( - context, - context.getString(R.string.copied_to_clipboard), - Toast.LENGTH_SHORT, - ).show() - resolvedViewModel.clearSelection() - } - }, - enabled = uiState.selectedLogIndices.isNotEmpty(), - ) { - Icon( - imageVector = Icons.Default.ContentCopy, - contentDescription = stringResource(R.string.content_description_copy_selected), - ) - } - } - } - } - } - - // Show active filter indicator - if (uiState.filterLogLevel != LogLevel.Default && !uiState.isSelectionMode) { - Surface( - modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onPrimaryContainer, - ) { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = - stringResource( - R.string.filter_label, - uiState.filterLogLevel.label, - ), - style = MaterialTheme.typography.bodySmall, - ) - TextButton( - onClick = { resolvedViewModel.setLogLevel(LogLevel.Default) }, - contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp), - modifier = Modifier.height(24.dp), - ) { - Text( - text = stringResource(R.string.clear_filter), - style = MaterialTheme.typography.bodySmall, - ) - } - } - } - } - - // Show search bar with animation - AnimatedVisibility( - visible = uiState.isSearchActive, - enter = - expandVertically( - animationSpec = tween(300), - ) + - fadeIn( - animationSpec = tween(300), - ), - exit = - shrinkVertically( - animationSpec = tween(300), - ) + - fadeOut( - animationSpec = tween(300), - ), - ) { - Surface( - modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.surface, - shadowElevation = 4.dp, - ) { - val focusRequester = remember { FocusRequester() } - val focusManager = LocalFocusManager.current - - LaunchedEffect(Unit) { - focusRequester.requestFocus() - } - - OutlinedTextField( - value = uiState.searchQuery, - onValueChange = { resolvedViewModel.updateSearchQuery(it) }, - modifier = - Modifier - .fillMaxWidth() - .padding(start = 16.dp, end = 16.dp, bottom = 12.dp) - .focusRequester(focusRequester), - placeholder = { Text(stringResource(R.string.search_logs_placeholder)) }, - leadingIcon = { - Icon( - imageVector = Icons.Default.Search, - contentDescription = stringResource(R.string.search), - ) - }, - trailingIcon = { - if (uiState.searchQuery.isNotEmpty()) { - IconButton(onClick = { resolvedViewModel.updateSearchQuery("") }) { - Icon( - imageVector = Icons.Default.Delete, - contentDescription = stringResource(R.string.content_description_clear_search), - ) - } - } - }, - singleLine = true, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), - keyboardActions = - KeyboardActions( - onSearch = { - focusManager.clearFocus() - }, - ), - ) - } - } - - if (uiState.errorMessage != null) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - text = uiState.errorTitle ?: "Error", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.error, - ) - Text( - text = uiState.errorMessage ?: "", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } else if (uiState.logs.isEmpty()) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - text = if (showStatusInfo) { - when (serviceStatus) { - Status.Started -> stringResource(R.string.status_started) - Status.Starting -> stringResource(R.string.status_starting) - Status.Stopping -> stringResource(R.string.status_stopping) - else -> stringResource(R.string.status_default) - } - } else { - emptyStateMessage - }, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } else { - // Log list - val bottomPadding = when { - showStartFab -> 88.dp - showStatusBar -> 74.dp - else -> 0.dp - } - LazyColumn( - state = listState, - modifier = Modifier.fillMaxSize(), - contentPadding = - PaddingValues( - start = 8.dp, - end = 8.dp, - top = 8.dp, - bottom = bottomPadding, - ), - verticalArrangement = Arrangement.spacedBy(2.dp), - ) { - itemsIndexed( - items = uiState.logs, - key = { _, log -> log.id }, - ) { index, log -> - LogItem( - annotatedString = log.annotatedString, - index = index, - isSelected = uiState.selectedLogIndices.contains(index), - isSelectionMode = uiState.isSelectionMode, - onLongClick = { - if (!uiState.isSelectionMode) { - resolvedViewModel.toggleSelectionMode() - resolvedViewModel.toggleLogSelection(index) - } - }, - onClick = { - if (uiState.isSelectionMode) { - resolvedViewModel.toggleLogSelection(index) - } - }, - ) - } - } - } - } // Close Column - - // Options Menu - Material 3 style - Box( - modifier = - Modifier - .align(Alignment.TopEnd) - .padding(end = 8.dp), - ) { - var expandedLogLevel by remember { mutableStateOf(false) } - var expandedSave by remember { mutableStateOf(false) } - - // File save launcher (must be outside DropdownMenu) - val saveFileLauncher = - rememberLauncherForActivityResult( - contract = ActivityResultContracts.CreateDocument("text/plain"), - onResult = { uri -> - uri?.let { - try { - context.contentResolver.openOutputStream(it)?.use { outputStream -> - val logsText = resolvedViewModel.getAllLogsText() - outputStream.write(logsText.toByteArray()) - outputStream.flush() - Toast.makeText( - context, - context.getString(R.string.success_logs_saved), - Toast.LENGTH_SHORT, - ).show() - } - } catch (e: Exception) { - Toast.makeText( - context, - context.getString(R.string.failed_save_logs, e.message), - Toast.LENGTH_SHORT, - ).show() - } - } - }, - ) - - DropdownMenu( - expanded = uiState.isOptionsMenuOpen, - onDismissRequest = { - resolvedViewModel.toggleOptionsMenu() - expandedLogLevel = false - expandedSave = false - }, - modifier = Modifier.widthIn(min = 200.dp), - ) { - // Log Level section with nested items - DropdownMenuItem( - text = { - Text( - text = stringResource(R.string.log_level), - style = MaterialTheme.typography.bodyLarge, - ) - }, - onClick = { expandedLogLevel = !expandedLogLevel }, - leadingIcon = { - Icon( - imageVector = Icons.Default.FilterList, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - }, - trailingIcon = { - Icon( - imageVector = - if (expandedLogLevel) { - Icons.Default.ExpandLess - } else { - Icons.Default.ExpandMore - }, - contentDescription = null, - ) - }, - ) - - // Show log levels inline when expanded - if (expandedLogLevel) { - LogLevel.entries.filter { it.priority > 1 }.forEach { level -> - DropdownMenuItem( - text = { - Text(text = level.label) - }, - onClick = { - resolvedViewModel.setLogLevel(level) - resolvedViewModel.toggleOptionsMenu() - expandedLogLevel = false - }, - leadingIcon = { - Icon( - imageVector = - if (uiState.filterLogLevel == level) { - Icons.Default.RadioButtonChecked - } else { - Icons.Default.RadioButtonUnchecked - }, - contentDescription = - if (uiState.filterLogLevel == level) { - stringResource(R.string.group_selected_title) - } else { - null - }, - tint = - if (uiState.filterLogLevel == level) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - modifier = Modifier.padding(start = 24.dp), - ) - }, - ) - } - - HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) - } - - // Save section with nested items - DropdownMenuItem( - text = { - Text( - text = stringResource(R.string.save), - style = MaterialTheme.typography.bodyLarge, - ) - }, - onClick = { expandedSave = !expandedSave }, - leadingIcon = { - Icon( - imageVector = Icons.Default.Save, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - }, - trailingIcon = { - Icon( - imageVector = - if (expandedSave) { - Icons.Default.ExpandLess - } else { - Icons.Default.ExpandMore - }, - contentDescription = null, - ) - }, - ) - - // Show save options inline when expanded - if (expandedSave) { - // Copy to Clipboard - DropdownMenuItem( - text = { - Text(text = stringResource(R.string.save_to_clipboard)) - }, - onClick = { - val logsText = resolvedViewModel.getAllLogsText() - if (logsText.isNotEmpty()) { - val clip = - ClipData.newPlainText(resolvedTitle, logsText) - Application.clipboard.setPrimaryClip(clip) - Toast.makeText( - context, - context.getString(R.string.logs_copied_to_clipboard), - Toast.LENGTH_SHORT, - ).show() - } else { - Toast.makeText( - context, - context.getString(R.string.no_logs_to_copy), - Toast.LENGTH_SHORT, - ).show() - } - resolvedViewModel.toggleOptionsMenu() - expandedSave = false - }, - leadingIcon = { - Icon( - imageVector = Icons.Default.ContentCopy, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(start = 24.dp), - ) - }, - ) - - // Save to File - DropdownMenuItem( - text = { - Text(text = stringResource(R.string.save_to_file)) - }, - onClick = { - val timestamp = - SimpleDateFormat( - "yyyyMMdd_HHmmss", - Locale.getDefault(), - ).format(Date()) - saveFileLauncher.launch("${saveFilePrefix}_$timestamp.txt") - resolvedViewModel.toggleOptionsMenu() - expandedSave = false - }, - leadingIcon = { - Icon( - imageVector = Icons.Default.Save, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(start = 24.dp), - ) - }, - ) - - // Share as File - DropdownMenuItem( - text = { - Text(text = stringResource(R.string.menu_share)) - }, - onClick = { - val logsText = resolvedViewModel.getAllLogsText() - if (logsText.isNotEmpty()) { - try { - val logsDir = - File(context.cacheDir, "logs").also { it.mkdirs() } - val timestamp = - SimpleDateFormat( - "yyyyMMdd_HHmmss", - Locale.getDefault(), - ).format(Date()) - val logFile = File(logsDir, "${saveFilePrefix}_$timestamp.txt") - logFile.writeText(logsText) - - val uri = - FileProvider.getUriForFile( - context, - "${context.packageName}.cache", - logFile, - ) - val shareIntent = - Intent(Intent.ACTION_SEND).apply { - type = "text/plain" - putExtra(Intent.EXTRA_STREAM, uri) - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - } - context.startActivity( - Intent.createChooser( - shareIntent, - context.getString(R.string.intent_share_logs), - ), - ) - } catch (e: Exception) { - Toast.makeText( - context, - context.getString(R.string.failed_share_logs, e.message), - Toast.LENGTH_SHORT, - ).show() - } - } else { - Toast.makeText( - context, - context.getString(R.string.no_logs_to_share), - Toast.LENGTH_SHORT, - ).show() - } - resolvedViewModel.toggleOptionsMenu() - expandedSave = false - }, - leadingIcon = { - Icon( - imageVector = Icons.Default.Share, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(start = 24.dp), - ) - }, - ) - - HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) - } - - if (showClear) { - DropdownMenuItem( - text = { - Text( - text = stringResource(R.string.clear_logs), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.error, - ) - }, - onClick = { - resolvedViewModel.requestClearLogs() - resolvedViewModel.toggleOptionsMenu() - }, - leadingIcon = { - Icon( - imageVector = Icons.Default.Delete, - contentDescription = null, - tint = MaterialTheme.colorScheme.error, - ) - }, - ) - } - } - } - - // FABs - Hide during selection mode - val padFabVisible = isTablet && (showStartFab || showStatusBar) - val fabBottomPadding = when { - padFabVisible -> 20.dp + 64.dp + 16.dp - showStartFab -> 88.dp - showStatusBar -> 74.dp - else -> 16.dp - } - val fabEndPadding = if (isTablet) 20.dp else 16.dp - Column( - modifier = - Modifier - .align(Alignment.BottomEnd) - .padding(bottom = fabBottomPadding, end = fabEndPadding, top = 16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - // Scroll to bottom FAB - // Use fade animation on API 23 to avoid OpenGLRenderer crash with scale transforms - AnimatedVisibility( - visible = !isAtBottom && !uiState.isSelectionMode && uiState.logs.isNotEmpty(), - enter = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) scaleIn() else fadeIn(), - exit = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) scaleOut() else fadeOut(), - ) { - FloatingActionButton( - onClick = { resolvedViewModel.scrollToBottom() }, - containerColor = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onPrimaryContainer, - ) { - Icon( - imageVector = Icons.Default.KeyboardArrowDown, - contentDescription = stringResource(R.string.content_description_scroll_to_bottom), - ) - } - } - } - } // Close Box that contains Column, Options Menu and FAB -} - -@OptIn(ExperimentalFoundationApi::class) -@Composable -fun LogItem( - annotatedString: androidx.compose.ui.text.AnnotatedString, - index: Int, - isSelected: Boolean, - isSelectionMode: Boolean, - onLongClick: () -> Unit, - onClick: () -> Unit, -) { - Card( - modifier = - Modifier - .fillMaxWidth() - .combinedClickable( - onClick = onClick, - onLongClick = onLongClick, - ), - shape = RoundedCornerShape(4.dp), - colors = - CardDefaults.cardColors( - containerColor = - if (isSelected) { - MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) - } else { - MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) - }, - ), - border = - if (isSelected) { - CardDefaults.outlinedCardBorder().copy( - width = 2.dp, - brush = - androidx.compose.ui.graphics.SolidColor( - MaterialTheme.colorScheme.primary.copy(alpha = 0.5f), - ), - ) - } else { - null - }, - ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - if (isSelectionMode) { - Icon( - imageVector = if (isSelected) Icons.Default.CheckBox else Icons.Default.CheckBoxOutlineBlank, - contentDescription = - if (isSelected) { - stringResource(R.string.group_selected_title) - } else { - stringResource( - R.string.not_selected, - ) - }, - modifier = Modifier.padding(start = 12.dp, end = 4.dp), - tint = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Text( - text = annotatedString, - modifier = - Modifier - .weight(1f) - .padding( - start = if (isSelectionMode) 4.dp else 12.dp, - end = 12.dp, - top = 8.dp, - bottom = 8.dp, - ), - fontSize = 13.sp, - fontFamily = FontFamily.Monospace, - lineHeight = 18.sp, - ) - } - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/LogViewModel.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/LogViewModel.kt deleted file mode 100644 index 601a51d91d..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/LogViewModel.kt +++ /dev/null @@ -1,152 +0,0 @@ -package io.nekohasekai.sfa.compose.screen.log - -import androidx.lifecycle.viewModelScope -import io.nekohasekai.libbox.Libbox -import io.nekohasekai.libbox.LogEntry -import io.nekohasekai.sfa.compose.util.AnsiColorUtils -import io.nekohasekai.sfa.constant.Status -import io.nekohasekai.sfa.utils.AppLifecycleObserver -import io.nekohasekai.sfa.utils.CommandClient -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.util.LinkedList - -class LogViewModel : - BaseLogViewModel(), - CommandClient.Handler { - companion object { - private val maxLines = 3000 - } - - private val bufferedLogs = LinkedList() - private val commandClient = - CommandClient( - scope = viewModelScope, - connectionType = CommandClient.ConnectionType.Log, - handler = this, - ) - private var lastServiceStatus: Status = Status.Stopped - - init { - viewModelScope.launch { - AppLifecycleObserver.isForeground.collect { foreground -> - if (lastServiceStatus != Status.Started) return@collect - if (foreground) { - commandClient.connect() - } else { - commandClient.disconnect() - } - } - } - } - - private fun processLogEntry(entry: LogEntry): ProcessedLogEntry { - val level = LogLevel.entries.find { it.priority == entry.level } ?: LogLevel.Default - return ProcessedLogEntry( - id = logIdGenerator.incrementAndGet(), - entry = LogEntryData(level = level, message = entry.message), - annotatedString = AnsiColorUtils.ansiToAnnotatedString(entry.message), - ) - } - - override fun updateServiceStatus(status: Status) { - lastServiceStatus = status - _uiState.update { it.copy(serviceStatus = status) } - - when (status) { - Status.Started -> { - if (AppLifecycleObserver.isForeground.value) { - commandClient.connect() - } - } - - Status.Stopped, Status.Stopping -> { - commandClient.disconnect() - _uiState.update { it.copy(isConnected = false) } - } - - else -> {} - } - } - - override fun onConnected() { - _uiState.update { it.copy(isConnected = true) } - } - - override fun onDisconnected() { - _uiState.update { it.copy(isConnected = false) } - } - - override fun setDefaultLogLevel(level: Int) { - val logLevel = LogLevel.entries.find { it.priority == level } ?: error("Unknown log level: $level") - _uiState.update { it.copy(defaultLogLevel = logLevel) } - updateDisplayedLogs() - } - - override fun clearLogs() { - allLogs.clear() - bufferedLogs.clear() - _uiState.update { it.copy(isPaused = false) } - updateDisplayedLogs() - } - - override fun requestClearLogs() { - viewModelScope.launch { - withContext(Dispatchers.IO) { - runCatching { - Libbox.newStandaloneCommandClient().clearLogs() - } - } - } - } - - override fun appendLogs(message: List) { - val processedLogs = message.map { processLogEntry(it) } - if (_uiState.value.isPaused) { - bufferedLogs.addAll(processedLogs) - } else { - val totalSize = allLogs.size + processedLogs.size - val removeCount = (totalSize - maxLines).coerceAtLeast(0) - - if (removeCount > 0) { - repeat(removeCount) { - allLogs.removeFirst() - } - } - - allLogs.addAll(processedLogs) - updateDisplayedLogs() - - if (_autoScrollEnabled.value && !_uiState.value.isPaused && !_uiState.value.isSearchActive) { - scrollToBottom() - } - } - } - - override fun togglePause() { - val currentState = _uiState.value - if (currentState.isPaused && bufferedLogs.isNotEmpty()) { - val totalSize = allLogs.size + bufferedLogs.size - val removeCount = (totalSize - maxLines).coerceAtLeast(0) - - if (removeCount > 0) { - repeat(removeCount) { - allLogs.removeFirst() - } - } - - allLogs.addAll(bufferedLogs) - bufferedLogs.clear() - } - - _uiState.update { it.copy(isPaused = !it.isPaused) } - updateDisplayedLogs() - } - - override fun onCleared() { - super.onCleared() - commandClient.disconnect() - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/LogViewerViewModel.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/LogViewerViewModel.kt deleted file mode 100644 index e6be04e7ea..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/LogViewerViewModel.kt +++ /dev/null @@ -1,25 +0,0 @@ -package io.nekohasekai.sfa.compose.screen.log - -import io.nekohasekai.sfa.constant.Status -import kotlinx.coroutines.flow.StateFlow - -interface LogViewerViewModel { - val uiState: StateFlow - val scrollToBottomTrigger: StateFlow - val isAtBottom: StateFlow - - fun updateServiceStatus(status: Status) - fun togglePause() - fun toggleSearch() - fun toggleOptionsMenu() - fun updateSearchQuery(query: String) - fun setLogLevel(level: LogLevel) - fun setAutoScrollEnabled(enabled: Boolean) - fun scrollToBottom() - fun toggleSelectionMode() - fun toggleLogSelection(index: Int) - fun clearSelection() - fun getSelectedLogsText(): String - fun getAllLogsText(): String - fun requestClearLogs() -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/privilegesettings/PrivilegeSettingsManageScreen.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/privilegesettings/PrivilegeSettingsManageScreen.kt deleted file mode 100644 index 8734b431ca..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/privilegesettings/PrivilegeSettingsManageScreen.kt +++ /dev/null @@ -1,847 +0,0 @@ -package io.nekohasekai.sfa.compose.screen.privilegesettings - -import android.content.pm.PackageManager -import android.os.Build -import android.widget.Toast -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.expandVertically -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.shrinkVertically -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.Clear -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.ContentPaste -import androidx.compose.material.icons.filled.FilterList -import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material.icons.filled.Search -import androidx.compose.material.icons.filled.SelectAll -import androidx.compose.material.icons.filled.Sort -import androidx.compose.material3.DropdownMenu -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.LinearProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -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.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import io.nekohasekai.sfa.R -import io.nekohasekai.sfa.compose.shared.AppSelectionCard -import io.nekohasekai.sfa.compose.shared.PackageCache -import io.nekohasekai.sfa.compose.shared.SortMode -import io.nekohasekai.sfa.compose.shared.buildDisplayPackages -import io.nekohasekai.sfa.compose.topbar.OverrideTopBar -import io.nekohasekai.sfa.database.Settings -import io.nekohasekai.sfa.ktx.clipboardText -import io.nekohasekai.sfa.utils.PrivilegeSettingsClient -import io.nekohasekai.sfa.vendor.PackageQueryManager -import io.nekohasekai.sfa.vendor.PrivilegedAccessRequiredException -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.util.Locale - -private data class LoadResult(val packages: List, val selectedUids: Set) - -private const val VPN_SERVICE_PERMISSION = "android.permission.BIND_VPN_SERVICE" - -private val managementPermissions = - setOf( - "android.permission.CONTROL_VPN", - "android.permission.CONTROL_ALWAYS_ON_VPN", - "android.permission.MANAGE_VPN", - "android.permission.NETWORK_SETTINGS", - "android.permission.NETWORK_STACK", - "android.permission.MAINLINE_NETWORK_STACK", - "android.permission.CONNECTIVITY_INTERNAL", - "android.permission.NETWORK_MANAGEMENT", - "android.permission.TETHER_PRIVILEGED", - "android.permission.MANAGE_NETWORK_POLICY", - ) - -private enum class RiskCategory { - NONE, - VPN_APP, - MANAGEMENT_APP, - BOTH, -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun PrivilegeSettingsManageScreen(onBack: () -> Unit) { - val context = LocalContext.current - val focusManager = LocalFocusManager.current - val coroutineScope = rememberCoroutineScope() - - var sortMode by remember { mutableStateOf(SortMode.NAME) } - var sortReverse by remember { mutableStateOf(false) } - var hideSystemApps by remember { mutableStateOf(false) } - var hideOfflineApps by remember { mutableStateOf(true) } - var hideDisabledApps by remember { mutableStateOf(true) } - - var packages by remember { mutableStateOf>(emptyList()) } - var displayPackages by remember { mutableStateOf>(emptyList()) } - var currentPackages by remember { mutableStateOf>(emptyList()) } - var selectedUids by remember { mutableStateOf>(emptySet()) } - var isLoading by remember { mutableStateOf(true) } - - var isSearchActive by remember { mutableStateOf(false) } - var searchQuery by remember { mutableStateOf("") } - var riskyWarningMessage by remember { mutableStateOf(null) } - var syncErrorMessage by remember { mutableStateOf(null) } - - fun getRiskCategory(packageCache: PackageCache): RiskCategory { - val permissions = packageCache.info.requestedPermissions ?: emptyArray() - val hasManagement = permissions.any { it in managementPermissions } - val isSelf = packageCache.packageName == context.packageName - val hasVpnService = - !isSelf && - ( - permissions.any { it == VPN_SERVICE_PERMISSION } || - packageCache.info.services?.any { it.permission == VPN_SERVICE_PERMISSION } == true - ) - return when { - hasManagement && hasVpnService -> RiskCategory.BOTH - hasManagement -> RiskCategory.MANAGEMENT_APP - hasVpnService -> RiskCategory.VPN_APP - else -> RiskCategory.NONE - } - } - - fun buildPackageList(newUids: Set): Set = newUids.mapNotNull { uid -> - packages.find { it.uid == uid }?.packageName - }.toSet() - - fun updateCurrentPackages(filterQuery: String) { - currentPackages = - if (filterQuery.isEmpty()) { - displayPackages - } else { - displayPackages.filter { - it.applicationLabel.contains(filterQuery, ignoreCase = true) || - it.packageName.contains(filterQuery, ignoreCase = true) || - it.uid.toString().contains(filterQuery) - } - } - } - - fun applyFilter() { - displayPackages = - buildDisplayPackages( - packages = packages, - selectedUids = selectedUids, - selectedFirst = true, - hideSystemApps = hideSystemApps, - hideOfflineApps = hideOfflineApps, - hideDisabledApps = hideDisabledApps, - sortMode = sortMode, - sortReverse = sortReverse, - ) - currentPackages = displayPackages - } - - fun saveSelectedApplications(newUids: Set) { - coroutineScope.launch { - val failure = - withContext(Dispatchers.IO) { - Settings.privilegeSettingsList = buildPackageList(newUids) - PrivilegeSettingsClient.sync() - } - if (failure != null) { - syncErrorMessage = failure.message ?: failure.toString() - } - } - } - - fun warnIfRiskySelected(newUids: Set) { - val addedUids = newUids - selectedUids - if (addedUids.isEmpty()) return - val addedApps = packages.filter { it.uid in addedUids } - val vpnUids = - addedApps - .filter { getRiskCategory(it) == RiskCategory.VPN_APP || getRiskCategory(it) == RiskCategory.BOTH } - .map { it.uid } - .toSet() - val managementUids = - addedApps - .filter { getRiskCategory(it) == RiskCategory.MANAGEMENT_APP || getRiskCategory(it) == RiskCategory.BOTH } - .map { it.uid } - .toSet() - val vpnApps = packages.filter { it.uid in vpnUids }.distinctBy { it.packageName } - val managementApps = packages.filter { it.uid in managementUids }.distinctBy { it.packageName } - if (vpnApps.isEmpty() && managementApps.isEmpty()) return - - val listSeparator = if (Locale.getDefault().language == "zh") "、" else ", " - val messages = ArrayList(2) - if (vpnApps.isNotEmpty()) { - val labelList = vpnApps.map { it.applicationLabel }.distinct().sorted() - val labels = labelList.joinToString(listSeparator) - messages += - if (labelList.size == 1) { - context.getString( - R.string.privilege_settings_risky_vpn_message_single, - labels, - ) - } else { - context.getString( - R.string.privilege_settings_risky_vpn_message_multi, - labels, - ) - } - } - if (managementApps.isNotEmpty()) { - val labelList = managementApps.map { it.applicationLabel }.distinct().sorted() - val labels = labelList.joinToString(listSeparator) - messages += - if (labelList.size == 1) { - context.getString( - R.string.privilege_settings_risky_management_message_single, - labels, - ) - } else { - context.getString( - R.string.privilege_settings_risky_management_message_multi, - labels, - ) - } - } - riskyWarningMessage = messages.joinToString("\n") - } - - fun postSaveSelectedApplications(newUids: Set, warnRisky: Boolean = true) { - if (warnRisky) { - warnIfRiskySelected(newUids) - } - selectedUids = newUids - saveSelectedApplications(newUids) - } - - fun toggleSelection(packageCache: PackageCache, selected: Boolean) { - val newSelected = - if (selected) { - selectedUids + packageCache.uid - } else { - selectedUids - packageCache.uid - } - if (newSelected == selectedUids) return - postSaveSelectedApplications(newSelected) - } - - LaunchedEffect(Unit) { - isLoading = true - val packageManagerFlags = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - PackageManager.GET_PERMISSIONS or PackageManager.MATCH_UNINSTALLED_PACKAGES - } else { - @Suppress("DEPRECATION") - PackageManager.GET_PERMISSIONS or PackageManager.GET_UNINSTALLED_PACKAGES - } - val loadResult = - withContext(Dispatchers.IO) { - try { - val installedPackages = PackageQueryManager.getInstalledPackages(packageManagerFlags, packageManagerFlags) - val packageManager = context.packageManager - val packageCaches = - installedPackages.mapNotNull { packageInfo -> - val appInfo = packageInfo.applicationInfo ?: return@mapNotNull null - PackageCache(packageInfo, appInfo, packageManager) - } - val selectedPackageNames = Settings.privilegeSettingsList.toMutableSet() - val selectedUidSet = - packageCaches.mapNotNull { packageCache -> - if (selectedPackageNames.contains(packageCache.packageName)) { - packageCache.uid - } else { - null - } - }.toSet() - LoadResult(packageCaches, selectedUidSet) - } catch (_: PrivilegedAccessRequiredException) { - null - } - } - if (loadResult == null) { - Toast.makeText( - context, - R.string.privileged_access_required, - Toast.LENGTH_LONG, - ).show() - onBack() - return@LaunchedEffect - } - packages = loadResult.packages - selectedUids = loadResult.selectedUids - applyFilter() - updateCurrentPackages(searchQuery) - isLoading = false - } - - if (riskyWarningMessage != null) { - androidx.compose.material3.AlertDialog( - onDismissRequest = { riskyWarningMessage = null }, - title = { Text(stringResource(R.string.privilege_settings_risky_app_title)) }, - text = { Text(riskyWarningMessage ?: "") }, - confirmButton = { - androidx.compose.material3.TextButton( - onClick = { riskyWarningMessage = null }, - ) { - Text(stringResource(R.string.ok)) - } - }, - ) - } - if (syncErrorMessage != null) { - androidx.compose.material3.AlertDialog( - onDismissRequest = { syncErrorMessage = null }, - title = { Text(stringResource(R.string.error_title)) }, - text = { Text(syncErrorMessage ?: "") }, - confirmButton = { - androidx.compose.material3.TextButton( - onClick = { syncErrorMessage = null }, - ) { - Text(stringResource(R.string.ok)) - } - }, - ) - } - - OverrideTopBar { - TopAppBar( - title = { Text(stringResource(R.string.privilege_settings_hide_title)) }, - navigationIcon = { - IconButton(onClick = onBack) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.content_description_back), - ) - } - }, - actions = { - IconButton( - onClick = { - isSearchActive = !isSearchActive - if (!isSearchActive) { - searchQuery = "" - updateCurrentPackages("") - focusManager.clearFocus() - } - }, - ) { - Icon( - imageVector = if (isSearchActive) Icons.Default.Close else Icons.Default.Search, - contentDescription = stringResource(R.string.search), - ) - } - PrivilegeSettingsMenus( - sortMode = sortMode, - sortReverse = sortReverse, - hideSystemApps = hideSystemApps, - hideOfflineApps = hideOfflineApps, - hideDisabledApps = hideDisabledApps, - onSortModeChange = { mode -> - sortMode = mode - applyFilter() - }, - onSortReverseToggle = { - sortReverse = !sortReverse - applyFilter() - }, - onHideSystemAppsToggle = { - hideSystemApps = !hideSystemApps - applyFilter() - }, - onHideOfflineAppsToggle = { - hideOfflineApps = !hideOfflineApps - applyFilter() - }, - onHideDisabledAppsToggle = { - hideDisabledApps = !hideDisabledApps - applyFilter() - }, - onSelectAll = { - val newSelected = currentPackages.map { it.uid }.toSet() - postSaveSelectedApplications(newSelected) - }, - onDeselectAll = { - postSaveSelectedApplications(emptySet()) - }, - onImport = { - val packageNames = - clipboardText?.split("\n")?.distinct() - ?.takeIf { it.isNotEmpty() && it[0].isNotEmpty() } - if (packageNames.isNullOrEmpty()) { - Toast.makeText( - context, - R.string.toast_clipboard_empty, - Toast.LENGTH_SHORT, - ).show() - } else { - val newSelected = - packages.mapNotNull { packageCache -> - if (packageNames.contains(packageCache.packageName)) { - packageCache.uid - } else { - null - } - }.toSet() - postSaveSelectedApplications(newSelected) - Toast.makeText( - context, - R.string.toast_imported_from_clipboard, - Toast.LENGTH_SHORT, - ).show() - } - }, - onExport = { - val packageList = - packages.mapNotNull { packageCache -> - if (selectedUids.contains(packageCache.uid)) { - packageCache.packageName - } else { - null - } - } - clipboardText = packageList.joinToString("\n") - Toast.makeText( - context, - R.string.toast_copied_to_clipboard, - Toast.LENGTH_SHORT, - ).show() - }, - ) - }, - colors = - TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface, - titleContentColor = MaterialTheme.colorScheme.onSurface, - ), - ) - } - - Column( - modifier = Modifier.fillMaxSize(), - ) { - AnimatedVisibility( - visible = isLoading, - enter = fadeIn(), - exit = fadeOut(), - ) { - LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) - } - - Surface( - modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.surfaceContainerLow, - ) { - Text( - text = stringResource(R.string.privilege_settings_hide_description), - modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp), - style = MaterialTheme.typography.bodyMedium, - ) - } - - AnimatedVisibility( - visible = isSearchActive, - enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut(), - ) { - val focusRequester = remember { FocusRequester() } - - LaunchedEffect(isSearchActive) { - if (isSearchActive) { - focusRequester.requestFocus() - } - } - - OutlinedTextField( - value = searchQuery, - onValueChange = { - searchQuery = it - updateCurrentPackages(it) - }, - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp) - .focusRequester(focusRequester), - placeholder = { Text(stringResource(R.string.search)) }, - leadingIcon = { - Icon( - imageVector = Icons.Default.Search, - contentDescription = stringResource(R.string.search), - ) - }, - trailingIcon = { - if (searchQuery.isNotEmpty()) { - IconButton(onClick = { - searchQuery = "" - updateCurrentPackages("") - focusManager.clearFocus() - }) { - Icon( - imageVector = Icons.Default.Clear, - contentDescription = stringResource(R.string.content_description_clear_search), - ) - } - } - }, - singleLine = true, - ) - } - - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = - androidx.compose.foundation.layout.PaddingValues( - horizontal = 16.dp, - vertical = 12.dp, - ), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - items(currentPackages, key = { it.packageName }) { packageCache -> - AppSelectionCard( - packageCache = packageCache, - selected = selectedUids.contains(packageCache.uid), - onToggle = { selected -> toggleSelection(packageCache, selected) }, - onCopyLabel = { clipboardText = packageCache.applicationLabel }, - onCopyPackage = { clipboardText = packageCache.packageName }, - onCopyUid = { clipboardText = packageCache.uid.toString() }, - ) - } - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun PrivilegeSettingsMenus( - sortMode: SortMode, - sortReverse: Boolean, - hideSystemApps: Boolean, - hideOfflineApps: Boolean, - hideDisabledApps: Boolean, - onSortModeChange: (SortMode) -> Unit, - onSortReverseToggle: () -> Unit, - onHideSystemAppsToggle: () -> Unit, - onHideOfflineAppsToggle: () -> Unit, - onHideDisabledAppsToggle: () -> Unit, - onSelectAll: () -> Unit, - onDeselectAll: () -> Unit, - onImport: () -> Unit, - onExport: () -> Unit, -) { - var showMainMenu by remember { mutableStateOf(false) } - var showSortMenu by remember { mutableStateOf(false) } - var showFilterMenu by remember { mutableStateOf(false) } - var showSelectMenu by remember { mutableStateOf(false) } - var showBackupMenu by remember { mutableStateOf(false) } - - IconButton(onClick = { showMainMenu = true }) { - Icon(Icons.Default.MoreVert, contentDescription = null) - } - - DropdownMenu( - expanded = showMainMenu, - onDismissRequest = { - showMainMenu = false - showSortMenu = false - showFilterMenu = false - showSelectMenu = false - showBackupMenu = false - }, - ) { - DropdownMenuItem( - text = { Text(stringResource(R.string.per_app_proxy_sort_mode)) }, - onClick = { showSortMenu = !showSortMenu }, - leadingIcon = { - Icon( - imageVector = Icons.Default.Sort, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - }, - ) - if (showSortMenu) { - DropdownMenuItem( - text = { Text(stringResource(R.string.per_app_proxy_sort_mode_name)) }, - onClick = { - onSortModeChange(SortMode.NAME) - showMainMenu = false - showSortMenu = false - }, - leadingIcon = { - Icon( - imageVector = Icons.Default.Sort, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(start = 24.dp), - ) - }, - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.per_app_proxy_sort_mode_package_name)) }, - onClick = { - onSortModeChange(SortMode.PACKAGE_NAME) - showMainMenu = false - showSortMenu = false - }, - leadingIcon = { - Icon( - imageVector = Icons.Default.Sort, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(start = 24.dp), - ) - }, - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.per_app_proxy_sort_mode_uid)) }, - onClick = { - onSortModeChange(SortMode.UID) - showMainMenu = false - showSortMenu = false - }, - leadingIcon = { - Icon( - imageVector = Icons.Default.Sort, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(start = 24.dp), - ) - }, - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.per_app_proxy_sort_mode_install_time)) }, - onClick = { - onSortModeChange(SortMode.INSTALL_TIME) - showMainMenu = false - showSortMenu = false - }, - leadingIcon = { - Icon( - imageVector = Icons.Default.Sort, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(start = 24.dp), - ) - }, - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.per_app_proxy_sort_mode_update_time)) }, - onClick = { - onSortModeChange(SortMode.UPDATE_TIME) - showMainMenu = false - showSortMenu = false - }, - leadingIcon = { - Icon( - imageVector = Icons.Default.Sort, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(start = 24.dp), - ) - }, - ) - HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) - DropdownMenuItem( - text = { Text(stringResource(R.string.per_app_proxy_sort_mode_reverse)) }, - onClick = { - onSortReverseToggle() - showMainMenu = false - showSortMenu = false - }, - leadingIcon = { - Icon( - imageVector = Icons.Default.Sort, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(start = 24.dp), - ) - }, - ) - } - - DropdownMenuItem( - text = { Text(stringResource(R.string.per_app_proxy_filter)) }, - onClick = { showFilterMenu = !showFilterMenu }, - leadingIcon = { - Icon( - imageVector = Icons.Default.FilterList, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - }, - ) - if (showFilterMenu) { - DropdownMenuItem( - text = { Text(stringResource(R.string.per_app_proxy_hide_system_apps)) }, - onClick = { - onHideSystemAppsToggle() - showMainMenu = false - showFilterMenu = false - }, - leadingIcon = { - Icon( - imageVector = Icons.Default.FilterList, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(start = 24.dp), - ) - }, - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.per_app_proxy_hide_offline_apps)) }, - onClick = { - onHideOfflineAppsToggle() - showMainMenu = false - showFilterMenu = false - }, - leadingIcon = { - Icon( - imageVector = Icons.Default.FilterList, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(start = 24.dp), - ) - }, - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.per_app_proxy_hide_disabled_apps)) }, - onClick = { - onHideDisabledAppsToggle() - showMainMenu = false - showFilterMenu = false - }, - leadingIcon = { - Icon( - imageVector = Icons.Default.FilterList, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(start = 24.dp), - ) - }, - ) - HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) - } - - DropdownMenuItem( - text = { Text(stringResource(R.string.per_app_proxy_select)) }, - onClick = { showSelectMenu = !showSelectMenu }, - leadingIcon = { - Icon( - imageVector = Icons.Default.SelectAll, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - }, - ) - if (showSelectMenu) { - DropdownMenuItem( - text = { Text(stringResource(R.string.per_app_proxy_select_all)) }, - onClick = { - onSelectAll() - showMainMenu = false - showSelectMenu = false - }, - leadingIcon = { - Icon( - imageVector = Icons.Default.SelectAll, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(start = 24.dp), - ) - }, - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.action_deselect)) }, - onClick = { - onDeselectAll() - showMainMenu = false - showSelectMenu = false - }, - leadingIcon = { - Icon( - imageVector = Icons.Default.SelectAll, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(start = 24.dp), - ) - }, - ) - HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) - } - - DropdownMenuItem( - text = { Text(stringResource(R.string.per_app_proxy_backup)) }, - onClick = { showBackupMenu = !showBackupMenu }, - leadingIcon = { - Icon( - imageVector = Icons.Default.ContentPaste, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - }, - ) - if (showBackupMenu) { - DropdownMenuItem( - text = { Text(stringResource(R.string.per_app_proxy_import)) }, - onClick = { - onImport() - showMainMenu = false - showBackupMenu = false - }, - leadingIcon = { - Icon( - imageVector = Icons.Default.ContentPaste, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(start = 24.dp), - ) - }, - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.per_app_proxy_export)) }, - onClick = { - onExport() - showMainMenu = false - showBackupMenu = false - }, - leadingIcon = { - Icon( - imageVector = Icons.Default.ContentPaste, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(start = 24.dp), - ) - }, - ) - } - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileContentScreen.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileContentScreen.kt deleted file mode 100644 index 1c883d0feb..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileContentScreen.kt +++ /dev/null @@ -1,863 +0,0 @@ -package io.nekohasekai.sfa.compose.screen.profile - -import android.widget.Toast -import androidx.activity.compose.BackHandler -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.expandVertically -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.shrinkVertically -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically -import androidx.compose.foundation.background -import androidx.compose.foundation.horizontalScroll -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.imePadding -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.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.automirrored.filled.Redo -import androidx.compose.material.icons.automirrored.filled.Undo -import androidx.compose.material.icons.filled.ArrowDownward -import androidx.compose.material.icons.filled.ArrowUpward -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Code -import androidx.compose.material.icons.filled.Error -import androidx.compose.material.icons.filled.ExpandLess -import androidx.compose.material.icons.filled.Save -import androidx.compose.material.icons.filled.Search -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.ExperimentalMaterial3Api -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.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.VerticalDivider -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.ExperimentalComposeUiApi -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clipToBounds -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.input.key.KeyEventType -import androidx.compose.ui.input.key.isCtrlPressed -import androidx.compose.ui.input.key.isMetaPressed -import androidx.compose.ui.input.key.isShiftPressed -import androidx.compose.ui.input.key.key -import androidx.compose.ui.input.key.onPreviewKeyEvent -import androidx.compose.ui.input.key.type -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalFocusManager -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.viewinterop.AndroidView -import androidx.lifecycle.viewmodel.compose.viewModel -import com.blacksquircle.ui.language.json.JsonLanguage -import io.nekohasekai.sfa.R -import io.nekohasekai.sfa.compose.topbar.OverrideTopBar -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch - -@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class) -@Composable -fun EditProfileContentScreen( - profileId: Long, - onNavigateBack: () -> Unit, - modifier: Modifier = Modifier, - profileName: String = "", - isReadOnly: Boolean = false, -) { - val viewModel: EditProfileContentViewModel = - viewModel( - factory = EditProfileContentViewModel.Factory(profileId, profileName, isReadOnly), - ) - val uiState by viewModel.uiState.collectAsState() - val context = LocalContext.current - var showUnsavedChangesDialog by remember { mutableStateOf(false) } - val searchFocusRequester = remember { FocusRequester() } - val focusManager = LocalFocusManager.current - val coroutineScope = rememberCoroutineScope() - - // Handle error messages - LaunchedEffect(uiState.errorMessage) { - uiState.errorMessage?.let { message -> - Toast.makeText(context, message, Toast.LENGTH_LONG).show() - viewModel.clearError() - } - } - - // Focus search field when search bar is shown - LaunchedEffect(uiState.showSearchBar) { - if (uiState.showSearchBar) { - searchFocusRequester.requestFocus() - } - } - - // Handle save success message - LaunchedEffect(uiState.showSaveSuccessMessage) { - if (uiState.showSaveSuccessMessage) { - Toast.makeText( - context, - context.getString(R.string.success_configuration_saved), - Toast.LENGTH_SHORT, - ).show() - viewModel.clearSaveSuccessMessage() - } - } - - // Handle back press when there are unsaved changes (not in read-only mode) - BackHandler(enabled = uiState.hasUnsavedChanges && !uiState.isReadOnly) { - showUnsavedChangesDialog = true - } - - OverrideTopBar { - TopAppBar( - title = { - Column { - Text( - if (uiState.isReadOnly) { - stringResource(R.string.view_configuration) - } else { - stringResource(R.string.title_edit_configuration) - }, - ) - if (uiState.profileName.isNotEmpty()) { - Text( - text = uiState.profileName, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - }, - navigationIcon = { - IconButton( - onClick = { - if (uiState.hasUnsavedChanges && !uiState.isReadOnly) { - showUnsavedChangesDialog = true - } else { - onNavigateBack() - } - }, - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.content_description_back), - ) - } - }, - actions = { - // Search/Collapse button (Ctrl/Cmd+F) - IconButton( - onClick = { viewModel.toggleSearchBar() }, - ) { - Icon( - imageVector = if (uiState.showSearchBar) Icons.Default.ExpandLess else Icons.Default.Search, - contentDescription = - if (uiState.showSearchBar) { - stringResource(R.string.content_description_collapse_search) - } else { - stringResource(R.string.search) - }, - tint = - if (uiState.showSearchBar) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurface - }, - ) - } - - // Save button (only show if not read-only) (Ctrl/Cmd+S) - if (!uiState.isReadOnly) { - IconButton( - onClick = { viewModel.saveConfiguration() }, - enabled = uiState.hasUnsavedChanges && !uiState.isLoading, - ) { - Icon( - imageVector = Icons.Default.Save, - contentDescription = stringResource(R.string.save), - tint = - if (uiState.hasUnsavedChanges) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) - }, - ) - } - } - }, - colors = - TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface, - ), - ) - } - - Column( - modifier = - modifier - .fillMaxSize() - .onPreviewKeyEvent { event -> - if (event.type == KeyEventType.KeyDown) { - // Support both Ctrl (Windows/Linux) and Cmd (macOS) - val modifierPressed = event.isCtrlPressed || event.isMetaPressed - - when { - // Ctrl/Cmd+Z - Undo - modifierPressed && event.key == Key.Z && !event.isShiftPressed && !uiState.isReadOnly -> { - viewModel.undo() - true - } - // Ctrl/Cmd+Shift+Z or Ctrl/Cmd+Y - Redo - ( - modifierPressed && - event.isShiftPressed && - event.key == Key.Z || - modifierPressed && - event.key == Key.Y - ) && - !uiState.isReadOnly -> { - viewModel.redo() - true - } - // Ctrl/Cmd+S - Save - modifierPressed && event.key == Key.S && !uiState.isReadOnly -> { - if (uiState.hasUnsavedChanges && !uiState.isLoading) { - viewModel.saveConfiguration() - } - true - } - // Ctrl/Cmd+F - Search - modifierPressed && event.key == Key.F -> { - viewModel.toggleSearchBar() - true - } - // Ctrl/Cmd+A - Select All - modifierPressed && event.key == Key.A -> { - viewModel.selectAll() - true - } - // Ctrl/Cmd+X - Cut (only in edit mode) - modifierPressed && event.key == Key.X && !uiState.isReadOnly -> { - viewModel.cut() - true - } - // Ctrl/Cmd+C - Copy - modifierPressed && event.key == Key.C -> { - viewModel.copy() - true - } - // Ctrl/Cmd+V - Paste (only in edit mode) - modifierPressed && event.key == Key.V && !uiState.isReadOnly -> { - viewModel.paste() - true - } - // Escape - Close search bar if open - event.key == Key.Escape && uiState.showSearchBar -> { - viewModel.toggleSearchBar() - true - } - // F3 or Ctrl/Cmd+G - Find next (when search is active) - (event.key == Key.F3 || (modifierPressed && event.key == Key.G && !event.isShiftPressed)) && - uiState.searchQuery.isNotEmpty() -> { - viewModel.findNext() - viewModel.focusEditor() - true - } - // Shift+F3 or Ctrl/Cmd+Shift+G - Find previous (when search is active) - ( - (event.isShiftPressed && event.key == Key.F3) || - (modifierPressed && event.isShiftPressed && event.key == Key.G) - ) && - uiState.searchQuery.isNotEmpty() -> { - viewModel.findPrevious() - viewModel.focusEditor() - true - } - - else -> false - } - } else { - false - } - }, - ) { - // Search bar (appears at top when activated) - AnimatedVisibility( - visible = uiState.showSearchBar, - enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(), - exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut(), - ) { - Surface( - modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.surfaceContainer, - tonalElevation = 2.dp, - ) { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(start = 12.dp, end = 12.dp, bottom = 12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - OutlinedTextField( - value = uiState.searchQuery, - onValueChange = { viewModel.updateSearchQuery(it) }, - modifier = - Modifier - .weight(1f) - .focusRequester(searchFocusRequester) - .onPreviewKeyEvent { event -> - if (event.key == Key.Enter && event.type == KeyEventType.KeyDown) { - coroutineScope.launch { - // Clear focus from search field first - focusManager.clearFocus() - // Small delay to let UI update - delay(100) - // Then focus editor with current search result selection - viewModel.focusEditorWithCurrentSearchResult() - } - true - } else { - false - } - }, - label = { Text(stringResource(R.string.search)) }, - placeholder = { Text(stringResource(R.string.search_placeholder)) }, - singleLine = true, - leadingIcon = { - Icon( - imageVector = Icons.Default.Search, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - }, - trailingIcon = { - if (uiState.searchQuery.isNotEmpty()) { - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = - if (uiState.searchResultCount > 0) { - "${uiState.currentSearchIndex}/${uiState.searchResultCount}" - } else { - "0/0" - }, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(end = 4.dp), - ) - IconButton( - onClick = { - // Focus editor with current selection before clearing search - viewModel.focusEditorWithCurrentSearchResult() - viewModel.updateSearchQuery("") - focusManager.clearFocus() - }, - modifier = Modifier.size(24.dp), - ) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = stringResource(R.string.clear), - modifier = Modifier.size(18.dp), - ) - } - } - } - }, - ) - - // Only show navigation buttons when there are search results - if (uiState.searchQuery.isNotEmpty() && uiState.searchResultCount > 0) { - Spacer(modifier = Modifier.width(8.dp)) - - IconButton( - onClick = { - viewModel.findPrevious() - viewModel.focusEditor() - }, - ) { - Icon( - imageVector = Icons.Default.ArrowUpward, - contentDescription = stringResource(R.string.previous), - tint = MaterialTheme.colorScheme.primary, - ) - } - - IconButton( - onClick = { - viewModel.findNext() - viewModel.focusEditor() - }, - ) { - Icon( - imageVector = Icons.Default.ArrowDownward, - contentDescription = stringResource(R.string.next), - tint = MaterialTheme.colorScheme.primary, - ) - } - } - } - } - } - - // Editor in a Box with floating elements - Box( - modifier = - Modifier - .fillMaxSize() - .clipToBounds() - .weight(1f), - ) { - // Editor - AndroidView( - factory = { context -> - ManualScrollTextProcessor(context).apply { - language = JsonLanguage() - setTextSize(14f) - setPadding(16, 16, 16, if (uiState.isReadOnly) 16 else 120) // Less padding for read-only - typeface = android.graphics.Typeface.MONOSPACE - setBackgroundColor( - androidx.core.content.ContextCompat.getColor(context, android.R.color.transparent), - ) - // Set up the editor with read-only state - this handles all configuration - viewModel.setEditor(this, uiState.isReadOnly) - } - }, - update = { textProcessor -> - // Re-apply configuration when read-only state changes - viewModel.setEditor(textProcessor, uiState.isReadOnly) - }, - modifier = - Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background), - ) - - // Simple loading indicator at the top - if (uiState.isLoading) { - LinearProgressIndicator( - modifier = - Modifier - .fillMaxWidth() - .align(Alignment.TopCenter), - ) - } - - // Floating bottom editor bar with error banner (only show if not read-only) - if (!uiState.isReadOnly) { - Column( - modifier = - Modifier - .align(Alignment.BottomCenter) - .fillMaxWidth() - .imePadding(), - ) { - // Configuration error banner (appears above the symbol bar) - AnimatedVisibility( - visible = uiState.configurationError != null, - enter = slideInVertically { it } + fadeIn(), - exit = slideOutVertically { it } + fadeOut(), - ) { - Surface( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp) - .padding(bottom = 2.dp), - shape = RoundedCornerShape(12.dp), - tonalElevation = 6.dp, - shadowElevation = 4.dp, - color = MaterialTheme.colorScheme.errorContainer, - ) { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 4.dp), - // Match symbol bar padding - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = Icons.Default.Error, - contentDescription = null, - tint = MaterialTheme.colorScheme.onErrorContainer, - modifier = Modifier.size(20.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = uiState.configurationError ?: "", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onErrorContainer, - modifier = Modifier.weight(1f), - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - IconButton( - onClick = { viewModel.dismissConfigurationError() }, - ) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = stringResource(R.string.dismiss), - tint = MaterialTheme.colorScheme.onErrorContainer, - modifier = Modifier.size(20.dp), - ) - } - } - } - } - - // Symbol input bar - Surface( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 8.dp), - shape = RoundedCornerShape(12.dp), - tonalElevation = 6.dp, - shadowElevation = 4.dp, - color = MaterialTheme.colorScheme.surface, - ) { - Row( - modifier = - Modifier - .fillMaxWidth() - .horizontalScroll(rememberScrollState()) - .padding(horizontal = 8.dp, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - // Undo button with text - TextButton( - onClick = { viewModel.undo() }, - enabled = uiState.canUndo, - modifier = Modifier.padding(end = 4.dp), - ) { - Icon( - imageVector = Icons.AutoMirrored.Default.Undo, - contentDescription = null, - modifier = Modifier.size(18.dp), - tint = - if (uiState.canUndo) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) - }, - ) - Spacer(modifier = Modifier.width(4.dp)) - Text( - text = stringResource(R.string.menu_undo), - style = MaterialTheme.typography.labelLarge, - color = - if (uiState.canUndo) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) - }, - ) - } - - // Redo button with text - TextButton( - onClick = { viewModel.redo() }, - enabled = uiState.canRedo, - modifier = Modifier.padding(end = 4.dp), - ) { - Icon( - imageVector = Icons.AutoMirrored.Default.Redo, - contentDescription = null, - modifier = Modifier.size(18.dp), - tint = - if (uiState.canRedo) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) - }, - ) - Spacer(modifier = Modifier.width(4.dp)) - Text( - text = stringResource(R.string.menu_redo), - style = MaterialTheme.typography.labelLarge, - color = - if (uiState.canRedo) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) - }, - ) - } - - // Format button with text - TextButton( - onClick = { viewModel.formatConfiguration() }, - modifier = Modifier.padding(end = 8.dp), - ) { - Icon( - imageVector = Icons.Default.Code, - contentDescription = null, - modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme.primary, - ) - Spacer(modifier = Modifier.width(4.dp)) - Text( - text = stringResource(R.string.menu_format), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary, - ) - } - - VerticalDivider( - modifier = - Modifier - .height(24.dp) - .padding(horizontal = 8.dp), - ) - - // Symbols ranked by frequency of use in JSON - - // Most common - quotes and colon (used for every key-value pair) - TextButton( - onClick = { viewModel.insertSymbol("\"") }, - modifier = - Modifier - .padding(0.dp) - .height(36.dp) - .width(36.dp), - shape = RoundedCornerShape(4.dp), - contentPadding = PaddingValues(0.dp), - ) { - Text( - text = "\"", - fontWeight = FontWeight.Bold, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.primary, - ) - } - - TextButton( - onClick = { viewModel.insertSymbol(":") }, - modifier = - Modifier - .padding(0.dp) - .height(36.dp) - .width(36.dp), - shape = RoundedCornerShape(4.dp), - contentPadding = PaddingValues(0.dp), - ) { - Text( - text = ":", - fontWeight = FontWeight.Bold, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.primary, - ) - } - - TextButton( - onClick = { viewModel.insertSymbol(",") }, - modifier = - Modifier - .padding(0.dp) - .height(36.dp) - .width(36.dp), - shape = RoundedCornerShape(4.dp), - contentPadding = PaddingValues(0.dp), - ) { - Text( - text = ",", - fontWeight = FontWeight.Bold, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.primary, - ) - } - - Spacer(modifier = Modifier.width(4.dp)) - - // Object brackets (very common) - TextButton( - onClick = { viewModel.insertSymbol("{") }, - modifier = - Modifier - .padding(0.dp) - .height(36.dp) - .width(36.dp), - shape = RoundedCornerShape(4.dp), - contentPadding = PaddingValues(0.dp), - ) { - Text( - text = "{", - fontWeight = FontWeight.Bold, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.primary, - ) - } - - TextButton( - onClick = { viewModel.insertSymbol("}") }, - modifier = - Modifier - .padding(0.dp) - .height(36.dp) - .width(36.dp), - shape = RoundedCornerShape(4.dp), - contentPadding = PaddingValues(0.dp), - ) { - Text( - text = "}", - fontWeight = FontWeight.Bold, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.primary, - ) - } - - Spacer(modifier = Modifier.width(4.dp)) - - // Array brackets (common) - TextButton( - onClick = { viewModel.insertSymbol("[") }, - modifier = - Modifier - .padding(0.dp) - .height(36.dp) - .width(36.dp), - shape = RoundedCornerShape(4.dp), - contentPadding = PaddingValues(0.dp), - ) { - Text( - text = "[", - fontWeight = FontWeight.Bold, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.primary, - ) - } - - TextButton( - onClick = { viewModel.insertSymbol("]") }, - modifier = - Modifier - .padding(0.dp) - .height(36.dp) - .width(36.dp), - shape = RoundedCornerShape(4.dp), - contentPadding = PaddingValues(0.dp), - ) { - Text( - text = "]", - fontWeight = FontWeight.Bold, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.primary, - ) - } - - Spacer(modifier = Modifier.width(4.dp)) - - // Common values - using same TextButton style for keywords - listOf("true", "false").forEach { text -> - TextButton( - onClick = { viewModel.insertSymbol(text) }, - modifier = - Modifier - .padding(0.dp) - .height(36.dp), - shape = RoundedCornerShape(4.dp), - contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp), - ) { - Text( - text = text, - fontWeight = FontWeight.Medium, - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary, - ) - } - } - - Spacer(modifier = Modifier.width(4.dp)) - - // Less common symbols - same TextButton style - listOf("-", "_", "/", "\\", "(", ")", "@", "#", "$", "%", "&", "*").forEach { symbol -> - TextButton( - onClick = { viewModel.insertSymbol(symbol) }, - modifier = - Modifier - .padding(0.dp) - .height(36.dp) - .width(36.dp), - shape = RoundedCornerShape(4.dp), - contentPadding = PaddingValues(0.dp), - ) { - Text( - text = symbol, - fontWeight = FontWeight.Medium, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - - // End padding for scroll - Spacer(modifier = Modifier.width(8.dp)) - } - } - } - } - } - } - // Unsaved changes dialog - if (showUnsavedChangesDialog) { - AlertDialog( - onDismissRequest = { showUnsavedChangesDialog = false }, - title = { Text(stringResource(R.string.unsaved_changes)) }, - text = { Text(stringResource(R.string.unsaved_changes_message)) }, - confirmButton = { - TextButton( - onClick = { - showUnsavedChangesDialog = false - onNavigateBack() - }, - ) { - Text(stringResource(R.string.discard), color = MaterialTheme.colorScheme.error) - } - }, - dismissButton = { - TextButton( - onClick = { showUnsavedChangesDialog = false }, - ) { - Text(stringResource(R.string.cancel)) - } - }, - ) - } - - // Initial loading - LaunchedEffect(profileId) { - viewModel.loadConfiguration() - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileContentViewModel.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileContentViewModel.kt deleted file mode 100644 index 1c19a9ba24..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileContentViewModel.kt +++ /dev/null @@ -1,598 +0,0 @@ -package io.nekohasekai.sfa.compose.screen.profile - -import androidx.core.widget.addTextChangedListener -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.viewModelScope -import io.nekohasekai.libbox.Libbox -import io.nekohasekai.sfa.database.Profile -import io.nekohasekai.sfa.database.ProfileManager -import io.nekohasekai.sfa.ktx.unwrap -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.update -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.io.File - -data class EditProfileContentUiState( - val isLoading: Boolean = false, - val content: String = "", - val originalContent: String = "", - val hasUnsavedChanges: Boolean = false, - val canUndo: Boolean = false, - val canRedo: Boolean = false, - val showSaveSuccessMessage: Boolean = false, - val errorMessage: String? = null, - val configurationError: String? = null, - val isCheckingConfig: Boolean = false, - val showSearchBar: Boolean = false, - val searchQuery: String = "", - val searchResultCount: Int = 0, - val currentSearchIndex: Int = 0, - val isReadOnly: Boolean = false, // Add read-only flag - val profileName: String = "", // Add profile name -) - -class EditProfileContentViewModel(private val profileId: Long, initialProfileName: String = "", initialIsReadOnly: Boolean = false) : ViewModel() { - private val _uiState = - MutableStateFlow( - EditProfileContentUiState( - profileName = initialProfileName, - isReadOnly = initialIsReadOnly, - ), - ) - val uiState: StateFlow = _uiState.asStateFlow() - - private var profile: Profile? = null - private var editor: ManualScrollTextProcessor? = null - private var configCheckJob: Job? = null - - fun setEditor(textProcessor: ManualScrollTextProcessor, isReadOnly: Boolean = false) { - val isNewEditor = editor != textProcessor - editor = textProcessor - textProcessor.resumeAutoScroll() - - // Always keep these for scrolling, focus, and selection - textProcessor.isEnabled = true - textProcessor.isFocusable = true - textProcessor.isFocusableInTouchMode = true - - // Allow text selection for copying - textProcessor.setTextIsSelectable(true) - - // Multi-line configuration - textProcessor.setSingleLine(false) - textProcessor.maxLines = Integer.MAX_VALUE - textProcessor.inputType = android.text.InputType.TYPE_CLASS_TEXT or - android.text.InputType.TYPE_TEXT_FLAG_MULTI_LINE or - android.text.InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS - textProcessor.isCursorVisible = true - - if (isReadOnly) { - // Use a custom OnKeyListener that blocks all key input - textProcessor.setOnKeyListener { _, _, _ -> true } // Return true to consume all key events - // Enable long click for selection - textProcessor.isLongClickable = true - - // Customize text selection to remove Cut and Paste options - textProcessor.customSelectionActionModeCallback = - object : android.view.ActionMode.Callback { - override fun onCreateActionMode(mode: android.view.ActionMode?, menu: android.view.Menu?): Boolean { - // Allow the action mode to be created - return true - } - - override fun onPrepareActionMode(mode: android.view.ActionMode?, menu: android.view.Menu?): Boolean { - // Remove editing-related menu items, keep only Copy and Select All - menu?.let { m -> - // Remove all editing-related items - m.removeItem(android.R.id.cut) - m.removeItem(android.R.id.paste) - m.removeItem(android.R.id.pasteAsPlainText) - m.removeItem(android.R.id.replaceText) - m.removeItem(android.R.id.undo) - m.removeItem(android.R.id.redo) - m.removeItem(android.R.id.autofill) - m.removeItem(android.R.id.textAssist) - } - return true - } - - override fun onActionItemClicked(mode: android.view.ActionMode?, item: android.view.MenuItem?): Boolean { - // Let the default implementation handle allowed actions (copy, select all) - return false - } - - override fun onDestroyActionMode(mode: android.view.ActionMode?) { - // No special cleanup needed - } - } - } else { - // For editable mode, remove the blocking listener - textProcessor.setOnKeyListener(null) - // Remove the custom selection callback to allow all text operations - textProcessor.customSelectionActionModeCallback = null - - // Only add text change listener for new editors in editable mode - if (isNewEditor) { - textProcessor.addTextChangedListener { editable -> - val currentText = editable?.toString() ?: "" - _uiState.update { state -> - state.copy( - content = currentText, - canUndo = textProcessor.canUndo(), - canRedo = textProcessor.canRedo(), - hasUnsavedChanges = currentText != state.originalContent, - ) - } - - // Schedule background configuration check - scheduleConfigurationCheck(currentText) - } - } - } - } - - private fun scheduleConfigurationCheck(content: String) { - // Cancel previous check - configCheckJob?.cancel() - - // Clear error immediately when user is typing - _uiState.update { it.copy(configurationError = null) } - - // Schedule new check after 2 seconds of inactivity - configCheckJob = - viewModelScope.launch { - delay(2000) // Wait 2 seconds - - // Check configuration in background - checkConfigurationInBackground(content) - } - } - - private suspend fun checkConfigurationInBackground(content: String) { - if (content.isBlank()) { - // Don't check empty content - return - } - - withContext(Dispatchers.IO) { - try { - _uiState.update { it.copy(isCheckingConfig = true) } - - // Check configuration - Libbox.checkConfig(content) - - // Configuration is valid, clear any error - _uiState.update { - it.copy( - configurationError = null, - isCheckingConfig = false, - ) - } - } catch (e: Exception) { - // Configuration has errors, show them - _uiState.update { - it.copy( - configurationError = e.message ?: "Invalid configuration", - isCheckingConfig = false, - ) - } - } - } - } - - fun loadConfiguration() { - viewModelScope.launch(Dispatchers.IO) { - _uiState.update { it.copy(isLoading = true) } - - try { - val loadedProfile = - ProfileManager.get(profileId) - ?: throw IllegalArgumentException("Profile not found") - profile = loadedProfile - - // Just load the content, we already have profile metadata from Intent - val content = File(loadedProfile.typed.path).readText() - - withContext(Dispatchers.Main) { - editor?.let { - it.resumeAutoScroll() - it.setTextContent(content) - } - _uiState.update { - it.copy( - content = content, - originalContent = content, - hasUnsavedChanges = false, - isLoading = false, - // Keep profileName and isReadOnly from initial state - no need to update - ) - } - } - } catch (e: Exception) { - _uiState.update { - it.copy( - isLoading = false, - errorMessage = e.message ?: "Failed to load configuration", - ) - } - } - } - } - - fun saveConfiguration() { - viewModelScope.launch(Dispatchers.IO) { - _uiState.update { it.copy(isLoading = true) } - - try { - val currentContent = - withContext(Dispatchers.Main) { - editor?.text?.toString() ?: "" - } - - // Save to file without validation - profile?.let { p -> - File(p.typed.path).writeText(currentContent) - } - - _uiState.update { - it.copy( - isLoading = false, - originalContent = currentContent, - hasUnsavedChanges = false, - showSaveSuccessMessage = true, - ) - } - - // Hide success message after delay - delay(2000) - _uiState.update { it.copy(showSaveSuccessMessage = false) } - } catch (e: Exception) { - _uiState.update { - it.copy( - isLoading = false, - errorMessage = e.message ?: "Save failed", - ) - } - } - } - } - - fun formatConfiguration() { - viewModelScope.launch(Dispatchers.IO) { - _uiState.update { it.copy(isLoading = true) } - - try { - val currentContent = - withContext(Dispatchers.Main) { - editor?.text?.toString() ?: "" - } - val formatted = Libbox.formatConfig(currentContent).unwrap - - if (formatted != currentContent) { - withContext(Dispatchers.Main) { - editor?.let { - it.resumeAutoScroll() - it.setTextContent(formatted) - } - } - // Note: hasUnsavedChanges will be updated by the text change listener - } - - _uiState.update { it.copy(isLoading = false) } - } catch (e: Exception) { - _uiState.update { - it.copy( - isLoading = false, - errorMessage = e.message ?: "Format failed", - ) - } - } - } - } - - fun undo() { - editor?.let { - if (it.canUndo()) { - it.resumeAutoScroll() - it.undo() - _uiState.update { state -> - state.copy( - canUndo = it.canUndo(), - canRedo = it.canRedo(), - ) - } - } - } - } - - fun redo() { - editor?.let { - if (it.canRedo()) { - it.resumeAutoScroll() - it.redo() - _uiState.update { state -> - state.copy( - canUndo = it.canUndo(), - canRedo = it.canRedo(), - ) - } - } - } - } - - fun clearError() { - _uiState.update { it.copy(errorMessage = null) } - } - - fun clearSaveSuccessMessage() { - _uiState.update { it.copy(showSaveSuccessMessage = false) } - } - - fun dismissConfigurationError() { - _uiState.update { it.copy(configurationError = null) } - } - - fun toggleSearchBar() { - _uiState.update { - val newShowSearchBar = !it.showSearchBar - it.copy( - showSearchBar = newShowSearchBar, - searchQuery = "", - searchResultCount = 0, - currentSearchIndex = 0, - ) - } - } - - fun updateSearchQuery(query: String) { - _uiState.update { it.copy(searchQuery = query) } - if (query.isNotEmpty()) { - performSearch(query) - } else { - _uiState.update { - it.copy( - searchResultCount = 0, - currentSearchIndex = 0, - ) - } - } - } - - private fun performSearch(query: String) { - editor?.let { textProcessor -> - val text = textProcessor.text?.toString() ?: "" - if (text.isEmpty() || query.isEmpty()) { - _uiState.update { - it.copy( - searchResultCount = 0, - currentSearchIndex = 0, - ) - } - return - } - - val matches = mutableListOf() - var index = text.indexOf(query, ignoreCase = true) - while (index != -1) { - matches.add(index) - index = text.indexOf(query, index + 1, ignoreCase = true) - } - - _uiState.update { - it.copy( - searchResultCount = matches.size, - currentSearchIndex = if (matches.isNotEmpty()) 1 else 0, - ) - } - - // Highlight first match - if (matches.isNotEmpty()) { - val firstMatch = matches[0] - textProcessor.resumeAutoScroll() - textProcessor.setSelection(firstMatch, firstMatch + query.length) - } - } - } - - fun findNext() { - val state = _uiState.value - if (state.searchResultCount == 0 || state.searchQuery.isEmpty()) return - - editor?.let { textProcessor -> - val text = textProcessor.text?.toString() ?: "" - val currentPosition = textProcessor.selectionEnd - - var nextIndex = text.indexOf(state.searchQuery, currentPosition, ignoreCase = true) - if (nextIndex == -1) { - // Wrap around to beginning - nextIndex = text.indexOf(state.searchQuery, 0, ignoreCase = true) - } - - if (nextIndex != -1) { - textProcessor.resumeAutoScroll() - textProcessor.setSelection(nextIndex, nextIndex + state.searchQuery.length) - - // Update current index - val matches = mutableListOf() - var index = text.indexOf(state.searchQuery, ignoreCase = true) - var currentMatchIndex = 0 - var counter = 0 - while (index != -1) { - if (index == nextIndex) { - currentMatchIndex = counter + 1 - } - matches.add(index) - counter++ - index = text.indexOf(state.searchQuery, index + 1, ignoreCase = true) - } - - _uiState.update { - it.copy(currentSearchIndex = currentMatchIndex) - } - } - } - } - - fun findPrevious() { - val state = _uiState.value - if (state.searchResultCount == 0 || state.searchQuery.isEmpty()) return - - editor?.let { textProcessor -> - val text = textProcessor.text?.toString() ?: "" - val currentPosition = textProcessor.selectionStart - - var prevIndex = text.lastIndexOf(state.searchQuery, currentPosition - 1, ignoreCase = true) - if (prevIndex == -1) { - // Wrap around to end - prevIndex = text.lastIndexOf(state.searchQuery, ignoreCase = true) - } - - if (prevIndex != -1) { - textProcessor.resumeAutoScroll() - textProcessor.setSelection(prevIndex, prevIndex + state.searchQuery.length) - - // Update current index - val matches = mutableListOf() - var index = text.indexOf(state.searchQuery, ignoreCase = true) - var currentMatchIndex = 0 - var counter = 0 - while (index != -1) { - if (index == prevIndex) { - currentMatchIndex = counter + 1 - } - matches.add(index) - counter++ - index = text.indexOf(state.searchQuery, index + 1, ignoreCase = true) - } - - _uiState.update { - it.copy(currentSearchIndex = currentMatchIndex) - } - } - } - } - - fun insertSymbol(symbol: String) { - editor?.let { textProcessor -> - val start = textProcessor.selectionStart - val end = textProcessor.selectionEnd - val text = textProcessor.text - - if (text != null) { - val newText = - StringBuilder(text) - .replace(start, end, symbol) - .toString() - - textProcessor.resumeAutoScroll() - textProcessor.setTextContent(newText) - // Place cursor after the inserted symbol - textProcessor.setSelection(start + symbol.length) - } - } - } - - fun focusEditor() { - editor?.let { textProcessor -> - // Ensure the editor is focusable - textProcessor.isFocusable = true - textProcessor.isFocusableInTouchMode = true - textProcessor.resumeAutoScroll() - textProcessor.requestFocus() - - // Keep the current selection if there's a search active - if (_uiState.value.searchQuery.isNotEmpty() && _uiState.value.searchResultCount > 0) { - // Selection is already set by search, just request focus - textProcessor.requestFocus() - } else if (!_uiState.value.isReadOnly) { - // No search active and not read-only, place cursor at current position - val currentPosition = textProcessor.selectionEnd - textProcessor.setSelection(currentPosition) - } - } - } - - fun focusEditorWithCurrentSearchResult() { - editor?.let { textProcessor -> - // Ensure the editor is focusable - textProcessor.isFocusable = true - textProcessor.isFocusableInTouchMode = true - textProcessor.resumeAutoScroll() - - val state = _uiState.value - if (state.searchQuery.isNotEmpty() && state.searchResultCount > 0) { - // Make sure current search result is selected - val text = textProcessor.text?.toString() ?: "" - val currentSelection = textProcessor.selectionStart - - // Find which match is currently selected or find the nearest one - var matchIndex = text.indexOf(state.searchQuery, currentSelection, ignoreCase = true) - if (matchIndex == -1 && currentSelection > 0) { - // Try from the beginning if no match found after cursor - matchIndex = text.indexOf(state.searchQuery, 0, ignoreCase = true) - } - - if (matchIndex != -1) { - textProcessor.setSelection(matchIndex, matchIndex + state.searchQuery.length) - } - } - textProcessor.requestFocus() - } - } - - fun selectAll() { - editor?.let { textProcessor -> - val text = textProcessor.text?.toString() ?: "" - if (text.isNotEmpty()) { - textProcessor.resumeAutoScroll() - textProcessor.setSelection(0, text.length) - textProcessor.requestFocus() - } - } - } - - fun cut() { - editor?.let { textProcessor -> - if (textProcessor.hasSelection()) { - textProcessor.onTextContextMenuItem(android.R.id.cut) - } - } - } - - fun copy() { - editor?.let { textProcessor -> - if (textProcessor.hasSelection()) { - textProcessor.onTextContextMenuItem(android.R.id.copy) - } - } - } - - fun paste() { - editor?.let { textProcessor -> - if (!_uiState.value.isReadOnly) { - textProcessor.onTextContextMenuItem(android.R.id.paste) - } - } - } - - class Factory( - private val profileId: Long, - private val initialProfileName: String = "", - private val initialIsReadOnly: Boolean = false, - ) : ViewModelProvider.Factory { - @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T { - if (modelClass.isAssignableFrom(EditProfileContentViewModel::class.java)) { - return EditProfileContentViewModel(profileId, initialProfileName, initialIsReadOnly) as T - } - throw IllegalArgumentException("Unknown ViewModel class") - } - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileRoute.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileRoute.kt deleted file mode 100644 index b617b4345d..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileRoute.kt +++ /dev/null @@ -1,181 +0,0 @@ -package io.nekohasekai.sfa.compose.screen.profile - -import androidx.compose.animation.AnimatedContentTransitionScope -import androidx.compose.animation.core.tween -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.Modifier -import androidx.lifecycle.viewmodel.compose.viewModel -import androidx.navigation.NavType -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.composable -import androidx.navigation.compose.rememberNavController -import androidx.navigation.navArgument - -@Composable -fun EditProfileRoute(profileId: Long, onNavigateBack: () -> Unit, modifier: Modifier = Modifier) { - if (profileId == -1L) { - LaunchedEffect(Unit) { - onNavigateBack() - } - return - } - - val navController = rememberNavController() - val sharedViewModel: EditProfileViewModel = viewModel() - - LaunchedEffect(profileId) { - sharedViewModel.loadProfile(profileId) - } - - NavHost( - navController = navController, - startDestination = "edit_profile", - modifier = modifier, - ) { - composable( - route = "edit_profile", - enterTransition = { - slideIntoContainer( - AnimatedContentTransitionScope.SlideDirection.Left, - animationSpec = tween(300), - ) - }, - exitTransition = { - slideOutOfContainer( - AnimatedContentTransitionScope.SlideDirection.Left, - animationSpec = tween(300), - ) - }, - popEnterTransition = { - slideIntoContainer( - AnimatedContentTransitionScope.SlideDirection.Right, - animationSpec = tween(300), - ) - }, - popExitTransition = { - slideOutOfContainer( - AnimatedContentTransitionScope.SlideDirection.Right, - animationSpec = tween(300), - ) - }, - ) { - EditProfileScreen( - profileId = profileId, - onNavigateBack = onNavigateBack, - onNavigateToIconSelection = { currentIconId -> - navController.navigate("icon_selection/${currentIconId ?: "null"}") { - launchSingleTop = true - } - }, - onNavigateToEditContent = { profileName, isReadOnly -> - navController.navigate("edit_content/$profileName/$isReadOnly") { - launchSingleTop = true - } - }, - viewModel = sharedViewModel, - ) - } - - composable( - route = "icon_selection/{currentIconId}", - arguments = - listOf( - navArgument("currentIconId") { - type = NavType.StringType - nullable = true - }, - ), - enterTransition = { - slideIntoContainer( - AnimatedContentTransitionScope.SlideDirection.Left, - animationSpec = tween(300), - ) - }, - exitTransition = { - slideOutOfContainer( - AnimatedContentTransitionScope.SlideDirection.Left, - animationSpec = tween(300), - ) - }, - popEnterTransition = { - slideIntoContainer( - AnimatedContentTransitionScope.SlideDirection.Right, - animationSpec = tween(300), - ) - }, - popExitTransition = { - slideOutOfContainer( - AnimatedContentTransitionScope.SlideDirection.Right, - animationSpec = tween(300), - ) - }, - ) { backStackEntry -> - val currentIconId = - backStackEntry.arguments?.getString("currentIconId") - ?.takeIf { it != "null" } - - IconSelectionScreen( - currentIconId = currentIconId, - onIconSelected = { iconId -> - sharedViewModel.updateIcon(iconId) - navController.popBackStack("edit_profile", inclusive = false) - }, - onNavigateBack = { - navController.popBackStack("edit_profile", inclusive = false) - }, - ) - } - - composable( - route = "edit_content/{profileName}/{isReadOnly}", - arguments = - listOf( - navArgument("profileName") { - type = NavType.StringType - defaultValue = "" - }, - navArgument("isReadOnly") { - type = NavType.BoolType - defaultValue = false - }, - ), - enterTransition = { - slideIntoContainer( - AnimatedContentTransitionScope.SlideDirection.Left, - animationSpec = tween(300), - ) - }, - exitTransition = { - slideOutOfContainer( - AnimatedContentTransitionScope.SlideDirection.Left, - animationSpec = tween(300), - ) - }, - popEnterTransition = { - slideIntoContainer( - AnimatedContentTransitionScope.SlideDirection.Right, - animationSpec = tween(300), - ) - }, - popExitTransition = { - slideOutOfContainer( - AnimatedContentTransitionScope.SlideDirection.Right, - animationSpec = tween(300), - ) - }, - ) { backStackEntry -> - val profileName = backStackEntry.arguments?.getString("profileName") ?: "" - val isReadOnly = backStackEntry.arguments?.getBoolean("isReadOnly") ?: false - - EditProfileContentScreen( - profileId = profileId, - onNavigateBack = { - navController.popBackStack("edit_profile", inclusive = false) - }, - profileName = profileName, - isReadOnly = isReadOnly, - ) - } - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileScreen.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileScreen.kt deleted file mode 100644 index 84af0e049e..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileScreen.kt +++ /dev/null @@ -1,563 +0,0 @@ -package io.nekohasekai.sfa.compose.screen.profile - -import androidx.activity.compose.BackHandler -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.expandVertically -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.shrinkVertically -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.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.layout.width -import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.automirrored.filled.InsertDriveFile -import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.CloudDownload -import androidx.compose.material.icons.filled.Code -import androidx.compose.material.icons.filled.Save -import androidx.compose.material.icons.filled.Update -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -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.LinearProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Surface -import androidx.compose.material3.Switch -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar -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.draw.clip -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewmodel.compose.viewModel -import io.nekohasekai.sfa.R -import io.nekohasekai.sfa.compose.base.SelectableMessageDialog -import io.nekohasekai.sfa.compose.topbar.OverrideTopBar -import io.nekohasekai.sfa.compose.util.ProfileIcons -import io.nekohasekai.sfa.compose.util.RelativeTimeFormatter -import io.nekohasekai.sfa.compose.util.icons.MaterialIconsLibrary -import io.nekohasekai.sfa.database.TypedProfile - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun EditProfileScreen( - profileId: Long, - onNavigateBack: () -> Unit, - onNavigateToIconSelection: (currentIconId: String?) -> Unit = {}, - onNavigateToEditContent: (profileName: String, isReadOnly: Boolean) -> Unit = { _, _ -> }, - viewModel: EditProfileViewModel = viewModel(), -) { - val uiState by viewModel.uiState.collectAsStateWithLifecycle() - val context = LocalContext.current - - // Clear success indicator after delay - LaunchedEffect(uiState.showUpdateSuccess) { - if (uiState.showUpdateSuccess) { - kotlinx.coroutines.delay(1500) - viewModel.clearUpdateSuccess() - } - } - - // Dialog states - var showErrorDialog by remember { mutableStateOf(false) } - var showUnsavedChangesDialog by remember { mutableStateOf(false) } - - // Launch icon selection screen when needed - if (uiState.showIconDialog) { - LaunchedEffect(Unit) { - viewModel.hideIconDialog() - onNavigateToIconSelection(uiState.icon) - } - } - - // Show error dialog when there's an error message - LaunchedEffect(uiState.errorMessage) { - if (uiState.errorMessage != null) { - showErrorDialog = true - } - } - - // Error dialog - if (showErrorDialog) { - SelectableMessageDialog( - title = stringResource(R.string.error_title), - message = uiState.errorMessage ?: "", - onDismiss = { - showErrorDialog = false - viewModel.clearError() - }, - ) - } - - // Unsaved changes dialog - if (showUnsavedChangesDialog) { - AlertDialog( - onDismissRequest = { showUnsavedChangesDialog = false }, - title = { Text(stringResource(R.string.unsaved_changes)) }, - text = { Text(stringResource(R.string.unsaved_changes_message)) }, - confirmButton = { - TextButton( - onClick = { - showUnsavedChangesDialog = false - onNavigateBack() - }, - ) { - Text( - stringResource(R.string.discard), - color = MaterialTheme.colorScheme.error, - ) - } - }, - dismissButton = { - TextButton( - onClick = { showUnsavedChangesDialog = false }, - ) { - Text(stringResource(android.R.string.cancel)) - } - }, - ) - } - - // Handle back navigation - val handleBack = { - if (uiState.hasChanges) { - showUnsavedChangesDialog = true - } else { - onNavigateBack() - } - } - - // Intercept system back button - BackHandler(enabled = uiState.hasChanges) { - showUnsavedChangesDialog = true - } - - OverrideTopBar { - TopAppBar( - title = { Text(stringResource(R.string.title_edit_profile)) }, - navigationIcon = { - IconButton(onClick = handleBack) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.content_description_back), - ) - } - }, - colors = - TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface, - ), - ) - } - - val bottomInset = - with(LocalDensity.current) { - WindowInsets.navigationBars.getBottom(this).toDp() - } - val bottomBarPadding = - if (uiState.hasChanges) { - 88.dp + bottomInset - } else { - 0.dp - } - - Box( - modifier = Modifier.fillMaxSize(), - ) { - // Progress indicator at top (only for initial loading) - if (uiState.isLoading) { - LinearProgressIndicator( - modifier = Modifier.fillMaxWidth(), - ) - } - - if (!uiState.isLoading) { - Column( - modifier = - Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(16.dp) - .padding(bottom = bottomBarPadding), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - // Basic Information Card - Card( - modifier = Modifier.fillMaxWidth(), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), - ), - ) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Text( - text = stringResource(R.string.basic_information), - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.primary, - ) - - OutlinedTextField( - value = uiState.name, - onValueChange = viewModel::updateName, - label = { Text(stringResource(R.string.profile_name)) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - ) - - HorizontalDivider( - modifier = Modifier.padding(vertical = 4.dp), - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - ) - - // Icon selection with Material You style - Text( - text = stringResource(R.string.icon), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(bottom = 4.dp), - ) - - Surface( - modifier = - Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .clickable { viewModel.showIconDialog() }, - color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), - shape = RoundedCornerShape(12.dp), - ) { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - // Display current icon - val currentIcon = - ProfileIcons.getIconById(uiState.icon) - ?: Icons.AutoMirrored.Filled.InsertDriveFile - - Icon( - imageVector = currentIcon, - contentDescription = stringResource(R.string.profile_icon), - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.primary, - ) - - Text( - text = - uiState.icon?.let { iconId -> - MaterialIconsLibrary.getAllIcons() - .find { it.id == iconId }?.label - } ?: stringResource(R.string.default_text), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.weight(1f), - ) - - Icon( - imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, - contentDescription = stringResource(R.string.select_icon), - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - } - - // Remote Profile Options - if (uiState.profileType == TypedProfile.Type.Remote) { - Card( - modifier = Modifier.fillMaxWidth(), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.3f), - ), - ) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Icon( - Icons.Default.CloudDownload, - contentDescription = null, - tint = MaterialTheme.colorScheme.tertiary, - modifier = Modifier.size(20.dp), - ) - Column( - verticalArrangement = Arrangement.spacedBy(2.dp), - ) { - Text( - text = stringResource(R.string.remote_configuration), - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.tertiary, - ) - uiState.lastUpdated?.let { lastUpdated -> - Text( - text = - stringResource( - R.string.last_updated_format, - RelativeTimeFormatter.format( - context, - lastUpdated, - ), - ), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - // Update button in top-right corner - IconButton( - onClick = { viewModel.updateRemoteProfile() }, - enabled = !uiState.isUpdating && !uiState.showUpdateSuccess, - ) { - when { - uiState.isUpdating -> { - CircularProgressIndicator( - modifier = Modifier.size(24.dp), - strokeWidth = 2.dp, - ) - } - uiState.showUpdateSuccess -> { - Icon( - Icons.Default.Check, - contentDescription = stringResource(R.string.success), - tint = MaterialTheme.colorScheme.primary, - ) - } - else -> { - Icon( - Icons.Default.Update, - contentDescription = stringResource(R.string.profile_update), - tint = MaterialTheme.colorScheme.tertiary, - ) - } - } - } - } - - OutlinedTextField( - value = uiState.remoteUrl, - onValueChange = viewModel::updateRemoteUrl, - label = { Text(stringResource(R.string.profile_url)) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - ) - - HorizontalDivider() - - // Auto Update Toggle - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text( - text = stringResource(R.string.profile_auto_update), - style = MaterialTheme.typography.bodyLarge, - ) - Switch( - checked = uiState.autoUpdate, - onCheckedChange = viewModel::updateAutoUpdate, - ) - } - - AnimatedVisibility(visible = uiState.autoUpdate) { - OutlinedTextField( - value = uiState.autoUpdateInterval.toString(), - onValueChange = viewModel::updateAutoUpdateInterval, - label = { Text(stringResource(R.string.profile_auto_update_interval)) }, - supportingText = { - uiState.autoUpdateIntervalError?.let { error -> - Text( - text = error, - color = MaterialTheme.colorScheme.error, - ) - } ?: Text(stringResource(R.string.profile_auto_update_interval_minimum_hint)) - }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - isError = uiState.autoUpdateIntervalError != null, - ) - } - } - } - } - - // Content Card (for both Local and Remote profiles) - placed at the end - Card( - modifier = Modifier.fillMaxWidth(), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.3f), - ), - ) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Icon( - Icons.AutoMirrored.Filled.InsertDriveFile, - contentDescription = null, - tint = MaterialTheme.colorScheme.secondary, - modifier = Modifier.size(20.dp), - ) - Text( - text = stringResource(R.string.content), - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.secondary, - ) - } - - // JSON Editor/Viewer option - Surface( - modifier = - Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .clickable { - onNavigateToEditContent( - uiState.name, - uiState.profileType == TypedProfile.Type.Remote, - ) - }, - color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), - shape = RoundedCornerShape(12.dp), - ) { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Icon( - imageVector = Icons.Default.Code, - contentDescription = null, - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.primary, - ) - Text( - text = - if (uiState.profileType == TypedProfile.Type.Remote) { - stringResource(R.string.json_viewer) - } else { - stringResource(R.string.json_editor) - }, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.weight(1f), - ) - Icon( - imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, - contentDescription = null, - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - } - } - } - AnimatedVisibility( - visible = uiState.hasChanges, - enter = fadeIn() + expandVertically(), - exit = fadeOut() + shrinkVertically(), - modifier = Modifier.align(Alignment.BottomCenter), - ) { - Surface( - modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.surface, - tonalElevation = 3.dp, - ) { - Box( - modifier = - Modifier - .fillMaxWidth() - .windowInsetsPadding(WindowInsets.navigationBars) - .padding(16.dp), - ) { - Button( - onClick = { viewModel.saveChanges() }, - modifier = Modifier.fillMaxWidth(), - enabled = !uiState.isSaving && uiState.autoUpdateIntervalError == null, - ) { - if (uiState.isSaving) { - CircularProgressIndicator( - modifier = Modifier.size(18.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onPrimary, - ) - } else { - Icon( - Icons.Default.Save, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) - Text(stringResource(R.string.save)) - } - } - } - } - } - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileViewModel.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileViewModel.kt deleted file mode 100644 index 57d8531e6a..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileViewModel.kt +++ /dev/null @@ -1,371 +0,0 @@ -package io.nekohasekai.sfa.compose.screen.profile - -import android.app.Application -import android.content.Context -import android.net.Uri -import android.widget.Toast -import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.viewModelScope -import io.nekohasekai.libbox.Libbox -import io.nekohasekai.sfa.R -import io.nekohasekai.sfa.bg.UpdateProfileWork -import io.nekohasekai.sfa.database.Profile -import io.nekohasekai.sfa.database.ProfileManager -import io.nekohasekai.sfa.database.Settings -import io.nekohasekai.sfa.database.TypedProfile -import io.nekohasekai.sfa.utils.HTTPClient -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 -import java.io.File -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale - -data class EditProfileUiState( - val profile: Profile? = null, - val name: String = "", - val icon: String? = null, - val profileType: TypedProfile.Type? = null, - val remoteUrl: String = "", - val autoUpdate: Boolean = false, - val autoUpdateInterval: Int = 60, - val lastUpdated: Date? = null, - // Original values for change detection - val originalName: String = "", - val originalIcon: String? = null, - val originalRemoteUrl: String = "", - val originalAutoUpdate: Boolean = false, - val originalAutoUpdateInterval: Int = 60, - // State flags - val hasChanges: Boolean = false, - val isLoading: Boolean = true, - val isUpdating: Boolean = false, - val showUpdateSuccess: Boolean = false, - val isSaving: Boolean = false, - val errorMessage: String? = null, - val autoUpdateIntervalError: String? = null, - val showIconDialog: Boolean = false, -) - -class EditProfileViewModel(application: Application) : AndroidViewModel(application) { - private val _uiState = MutableStateFlow(EditProfileUiState()) - val uiState: StateFlow = _uiState.asStateFlow() - - // Store the content to export when user selects a file location - var pendingExportContent: String? = null - var pendingExportFileName: String? = null - - fun loadProfile(profileId: Long) { - viewModelScope.launch(Dispatchers.IO) { - try { - val profile = ProfileManager.get(profileId) - if (profile == null) { - _uiState.update { - it.copy( - isLoading = false, - errorMessage = "Profile not found", - ) - } - return@launch - } - - val typedProfile = profile.typed - _uiState.update { - it.copy( - profile = profile, - name = profile.name, - originalName = profile.name, - icon = profile.icon, - originalIcon = profile.icon, - profileType = typedProfile.type, - remoteUrl = typedProfile.remoteURL, - originalRemoteUrl = typedProfile.remoteURL, - autoUpdate = typedProfile.autoUpdate, - originalAutoUpdate = typedProfile.autoUpdate, - autoUpdateInterval = typedProfile.autoUpdateInterval, - originalAutoUpdateInterval = typedProfile.autoUpdateInterval, - lastUpdated = typedProfile.lastUpdated, - isLoading = false, - ) - } - } catch (e: Exception) { - _uiState.update { - it.copy( - isLoading = false, - errorMessage = e.message, - ) - } - } - } - } - - fun updateName(name: String) { - _uiState.update { state -> - state.copy( - name = name, - hasChanges = - checkHasChanges( - state.copy(name = name), - ), - ) - } - } - - fun updateIcon(icon: String?) { - _uiState.update { state -> - state.copy( - icon = icon, - hasChanges = - checkHasChanges( - state.copy(icon = icon), - ), - ) - } - } - - fun showIconDialog() { - _uiState.update { it.copy(showIconDialog = true) } - } - - fun hideIconDialog() { - _uiState.update { it.copy(showIconDialog = false) } - } - - fun updateRemoteUrl(url: String) { - _uiState.update { state -> - state.copy( - remoteUrl = url, - hasChanges = - checkHasChanges( - state.copy(remoteUrl = url), - ), - ) - } - } - - fun updateAutoUpdate(enabled: Boolean) { - _uiState.update { state -> - state.copy( - autoUpdate = enabled, - hasChanges = - checkHasChanges( - state.copy(autoUpdate = enabled), - ), - ) - } - } - - fun updateAutoUpdateInterval(interval: String) { - val intValue = interval.toIntOrNull() ?: 60 - val error = - when { - interval.isBlank() -> getApplication().getString(R.string.profile_input_required) - intValue < 15 -> getApplication().getString(R.string.profile_auto_update_interval_minimum_hint) - else -> null - } - - _uiState.update { state -> - state.copy( - autoUpdateInterval = intValue, - autoUpdateIntervalError = error, - hasChanges = - if (error == null) { - checkHasChanges(state.copy(autoUpdateInterval = intValue)) - } else { - state.hasChanges - }, - ) - } - } - - private fun checkHasChanges(state: EditProfileUiState): Boolean = state.name != state.originalName || - state.icon != state.originalIcon || - state.remoteUrl != state.originalRemoteUrl || - state.autoUpdate != state.originalAutoUpdate || - state.autoUpdateInterval != state.originalAutoUpdateInterval - - fun saveChanges() { - val state = _uiState.value - val profile = state.profile ?: return - - if (state.autoUpdateIntervalError != null) { - return - } - - viewModelScope.launch(Dispatchers.IO) { - _uiState.update { it.copy(isSaving = true) } - - try { - // Update profile object - profile.name = state.name - profile.icon = state.icon - profile.typed.remoteURL = state.remoteUrl - - // Handle auto-update changes - val autoUpdateChanged = state.autoUpdate != state.originalAutoUpdate - profile.typed.autoUpdate = state.autoUpdate - profile.typed.autoUpdateInterval = state.autoUpdateInterval - - // Save to database - ProfileManager.update(profile) - - // Reconfigure updater if auto-update was enabled - if (autoUpdateChanged && state.autoUpdate) { - UpdateProfileWork.reconfigureUpdater() - } - - // Update UI state with new original values - _uiState.update { - it.copy( - originalName = state.name, - originalIcon = state.icon, - originalRemoteUrl = state.remoteUrl, - originalAutoUpdate = state.autoUpdate, - originalAutoUpdateInterval = state.autoUpdateInterval, - hasChanges = false, - isSaving = false, - ) - } - } catch (e: Exception) { - _uiState.update { - it.copy( - isSaving = false, - errorMessage = e.message, - ) - } - } - } - } - - fun updateRemoteProfile() { - val state = _uiState.value - val profile = state.profile ?: return - - if (profile.typed.type != TypedProfile.Type.Remote) return - - viewModelScope.launch(Dispatchers.IO) { - _uiState.update { it.copy(isUpdating = true) } - - try { - var selectedProfileUpdated = false - - // Fetch remote config - val content = HTTPClient().use { it.getString(profile.typed.remoteURL) } - Libbox.checkConfig(content) - - // Check if content changed - val file = File(profile.typed.path) - if (!file.exists() || file.readText() != content) { - file.writeText(content) - if (profile.id == Settings.selectedProfile) { - selectedProfileUpdated = true - } - } - - // Update last updated time - profile.typed.lastUpdated = Date() - ProfileManager.update(profile) - - // Update UI state with success indicator - _uiState.update { - it.copy( - lastUpdated = profile.typed.lastUpdated, - isUpdating = false, - showUpdateSuccess = true, - ) - } - - // Reload service if needed - if (selectedProfileUpdated) { - try { - Libbox.newStandaloneCommandClient().serviceReload() - } catch (e: Exception) { - // Service reload errors are not critical - } - } - } catch (e: Exception) { - _uiState.update { - it.copy( - isUpdating = false, - errorMessage = e.message, - ) - } - } - } - } - - fun clearError() { - _uiState.update { it.copy(errorMessage = null) } - } - - fun clearUpdateSuccess() { - _uiState.update { it.copy(showUpdateSuccess = false) } - } - - fun prepareExport(context: Context): String? { - val state = _uiState.value - val profile = state.profile ?: return null - - return try { - // Read the configuration file - val configFile = File(profile.typed.path) - if (!configFile.exists()) { - Toast.makeText(context, "Configuration file not found", Toast.LENGTH_SHORT).show() - return null - } - - val content = configFile.readText() - val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) - val fileName = "${profile.name.replace(" ", "_")}_$timestamp.json" - - // Store content for later when user picks location - pendingExportContent = content - pendingExportFileName = fileName - - fileName - } catch (e: Exception) { - Toast.makeText( - context, - context.getString( - io.nekohasekai.sfa.R.string.failed_read_configuration, - e.message, - ), - Toast.LENGTH_SHORT, - ).show() - null - } - } - - fun saveExportToUri(context: Context, uri: Uri) { - val content = pendingExportContent ?: return - - viewModelScope.launch(Dispatchers.IO) { - try { - context.contentResolver.openOutputStream(uri)?.use { outputStream -> - outputStream.write(content.toByteArray()) - } - - withContext(Dispatchers.Main) { - Toast.makeText( - context, - "Configuration exported successfully", - Toast.LENGTH_SHORT, - ).show() - } - } catch (e: Exception) { - withContext(Dispatchers.Main) { - Toast.makeText(context, "Export failed: ${e.message}", Toast.LENGTH_LONG).show() - } - } finally { - // Clear pending export data - pendingExportContent = null - pendingExportFileName = null - } - } - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/IconSelectionDialog.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/IconSelectionDialog.kt deleted file mode 100644 index cf507ae022..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/IconSelectionDialog.kt +++ /dev/null @@ -1,184 +0,0 @@ -package io.nekohasekai.sfa.compose.screen.profile - -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.aspectRatio -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.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.items -import androidx.compose.foundation.shape.RoundedCornerShape -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.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.draw.clip -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import io.nekohasekai.sfa.R -import io.nekohasekai.sfa.compose.util.ProfileIcon -import io.nekohasekai.sfa.compose.util.ProfileIcons - -@Composable -fun IconSelectionDialog(currentIconId: String?, onIconSelected: (String?) -> Unit, onDismiss: () -> Unit) { - Dialog(onDismissRequest = onDismiss) { - Card( - modifier = - Modifier - .fillMaxWidth() - .heightIn(max = 500.dp), - shape = RoundedCornerShape(16.dp), - ) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - Text( - text = stringResource(R.string.select_profile_icon), - style = MaterialTheme.typography.titleLarge, - modifier = Modifier.fillMaxWidth(), - ) - - LazyVerticalGrid( - columns = GridCells.Fixed(4), - verticalArrangement = Arrangement.spacedBy(8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = - Modifier - .fillMaxWidth() - .weight(1f), - ) { - // Add option to remove custom icon (use default) - item { - IconOption( - icon = null, - label = stringResource(R.string.default_text), - isSelected = currentIconId == null, - onClick = { - onIconSelected(null) - onDismiss() - }, - ) - } - - items(ProfileIcons.availableIcons) { profileIcon -> - IconOption( - icon = profileIcon, - label = profileIcon.label, - isSelected = currentIconId == profileIcon.id, - onClick = { - onIconSelected(profileIcon.id) - onDismiss() - }, - ) - } - } - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - ) { - TextButton(onClick = onDismiss) { - Text(stringResource(R.string.cancel)) - } - } - } - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun IconOption(icon: ProfileIcon?, label: String, isSelected: Boolean, onClick: () -> Unit) { - Card( - modifier = - Modifier - .fillMaxWidth() - .aspectRatio(1f) - .clip(RoundedCornerShape(8.dp)) - .clickable { onClick() }, - colors = - CardDefaults.cardColors( - containerColor = - if (isSelected) { - MaterialTheme.colorScheme.primaryContainer - } else { - MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) - }, - ), - border = - if (isSelected) { - CardDefaults.outlinedCardBorder() - } else { - null - }, - ) { - Column( - modifier = - Modifier - .fillMaxSize() - .padding(8.dp), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - if (icon != null) { - Icon( - imageVector = icon.icon, - contentDescription = label, - modifier = Modifier.size(28.dp), - tint = - if (isSelected) { - MaterialTheme.colorScheme.onPrimaryContainer - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - ) - } else { - // Default icon indicator - Text( - text = stringResource(R.string.auto), - style = MaterialTheme.typography.bodyMedium, - color = - if (isSelected) { - MaterialTheme.colorScheme.onPrimaryContainer - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - ) - } - - Spacer(modifier = Modifier.height(4.dp)) - - Text( - text = label, - style = MaterialTheme.typography.labelSmall, - color = - if (isSelected) { - MaterialTheme.colorScheme.onPrimaryContainer - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - textAlign = TextAlign.Center, - ) - } - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/IconSelectionScreen.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/IconSelectionScreen.kt deleted file mode 100644 index 9e37361103..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/IconSelectionScreen.kt +++ /dev/null @@ -1,622 +0,0 @@ -package io.nekohasekai.sfa.compose.screen.profile - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.tween -import androidx.compose.animation.expandVertically -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.shrinkVertically -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.WindowInsets -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.navigationBars -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.items -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.Clear -import androidx.compose.material.icons.filled.RestartAlt -import androidx.compose.material.icons.filled.Search -import androidx.compose.material.icons.filled.SearchOff -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FilterChip -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar -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.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import io.nekohasekai.sfa.R -import io.nekohasekai.sfa.compose.topbar.OverrideTopBar -import io.nekohasekai.sfa.compose.util.ProfileIcon -import io.nekohasekai.sfa.compose.util.icons.IconCategory -import io.nekohasekai.sfa.compose.util.icons.MaterialIconsLibrary - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun IconSelectionScreen(currentIconId: String?, onIconSelected: (String?) -> Unit, onNavigateBack: () -> Unit) { - var searchQuery by remember { mutableStateOf("") } - var selectedCategory by remember { mutableStateOf(null) } - var viewMode by remember { mutableStateOf(IconViewMode.CATEGORIES) } - var isSearchActive by remember { mutableStateOf(false) } - val focusManager = LocalFocusManager.current - - // Get icons based on current mode - val displayedIcons = - remember(searchQuery, selectedCategory, viewMode) { - when { - searchQuery.isNotEmpty() -> MaterialIconsLibrary.searchIcons(searchQuery) - selectedCategory != null -> { - MaterialIconsLibrary.categories - .find { it.name == selectedCategory } - ?.icons ?: emptyList() - } - viewMode == IconViewMode.ALL -> MaterialIconsLibrary.getAllIcons() - else -> emptyList() - } - } - - OverrideTopBar { - TopAppBar( - title = { Text(stringResource(R.string.select_icon)) }, - navigationIcon = { - IconButton(onClick = onNavigateBack) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.content_description_back), - ) - } - }, - actions = { - IconButton( - onClick = { - isSearchActive = !isSearchActive - if (!isSearchActive) { - searchQuery = "" - viewMode = IconViewMode.CATEGORIES - selectedCategory = null - focusManager.clearFocus() - } - }, - ) { - Icon( - imageVector = Icons.Default.Search, - contentDescription = - if (isSearchActive) { - stringResource(R.string.close_search) - } else { - stringResource( - R.string.search_icons, - ) - }, - tint = - if (isSearchActive) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurface - }, - ) - } - }, - colors = - TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface, - ), - ) - } - - val currentIcon = - currentIconId?.let { id -> - MaterialIconsLibrary.getIconById(id)?.let { icon -> id to icon } - } - val bottomInset = - with(LocalDensity.current) { - WindowInsets.navigationBars.getBottom(this).toDp() - } - val bottomBarPadding = - if (currentIcon != null) { - 88.dp + bottomInset - } else { - 0.dp - } - - Box(modifier = Modifier.fillMaxSize()) { - Column( - modifier = - Modifier - .fillMaxSize() - .padding(bottom = bottomBarPadding), - ) { - // Show search bar with animation - AnimatedVisibility( - visible = isSearchActive, - enter = - expandVertically( - animationSpec = tween(300), - ) + - fadeIn( - animationSpec = tween(300), - ), - exit = - shrinkVertically( - animationSpec = tween(300), - ) + - fadeOut( - animationSpec = tween(300), - ), - ) { - Surface( - modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.surface, - shadowElevation = 4.dp, - ) { - val focusRequester = remember { FocusRequester() } - - LaunchedEffect(Unit) { - focusRequester.requestFocus() - } - - OutlinedTextField( - value = searchQuery, - onValueChange = { - searchQuery = it - if (it.isNotEmpty()) { - viewMode = IconViewMode.SEARCH - } else { - viewMode = IconViewMode.CATEGORIES - selectedCategory = null - } - }, - modifier = - Modifier - .fillMaxWidth() - .padding(start = 16.dp, end = 16.dp, bottom = 12.dp) - .focusRequester(focusRequester), - placeholder = { Text(stringResource(R.string.search_icons_placeholder)) }, - leadingIcon = { - Icon( - imageVector = Icons.Default.Search, - contentDescription = stringResource(R.string.search), - ) - }, - trailingIcon = { - if (searchQuery.isNotEmpty()) { - IconButton(onClick = { - searchQuery = "" - viewMode = IconViewMode.CATEGORIES - selectedCategory = null - }) { - Icon( - imageVector = Icons.Default.Clear, - contentDescription = stringResource(R.string.content_description_clear_search), - ) - } - } - }, - singleLine = true, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), - keyboardActions = - KeyboardActions( - onSearch = { - focusManager.clearFocus() - }, - ), - ) - } - } - - Column( - modifier = - Modifier - .fillMaxSize() - .padding(horizontal = 16.dp), - ) { - // View mode tabs (only show when not searching) - AnimatedVisibility(visible = searchQuery.isEmpty()) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - FilterChip( - selected = viewMode == IconViewMode.CATEGORIES && selectedCategory == null, - onClick = { - viewMode = IconViewMode.CATEGORIES - selectedCategory = null - }, - label = { Text(stringResource(R.string.categories)) }, - leadingIcon = - if (viewMode == IconViewMode.CATEGORIES && selectedCategory == null) { - { Icon(Icons.Default.Check, contentDescription = null, Modifier.size(16.dp)) } - } else { - null - }, - ) - - FilterChip( - selected = viewMode == IconViewMode.ALL, - onClick = { - viewMode = IconViewMode.ALL - selectedCategory = null - }, - label = { Text(stringResource(R.string.all_icons)) }, - leadingIcon = - if (viewMode == IconViewMode.ALL) { - { Icon(Icons.Default.Check, contentDescription = null, Modifier.size(16.dp)) } - } else { - null - }, - ) - - FilterChip( - selected = currentIconId == null, - onClick = { - onIconSelected(null) - onNavigateBack() - }, - label = { Text(stringResource(R.string.default_text)) }, - leadingIcon = { - Icon(Icons.Default.RestartAlt, contentDescription = null, Modifier.size(16.dp)) - }, - ) - } - } - - // Back button when category is selected - AnimatedVisibility(visible = selectedCategory != null && searchQuery.isEmpty()) { - TextButton( - onClick = { - selectedCategory = null - viewMode = IconViewMode.CATEGORIES - }, - modifier = Modifier.padding(vertical = 4.dp), - ) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = null, - modifier = Modifier.size(20.dp), - ) - Spacer(modifier = Modifier.width(4.dp)) - Text(stringResource(R.string.back_to_categories)) - } - } - - Spacer(modifier = Modifier.height(8.dp)) - - // Main content area - Box( - modifier = - Modifier - .fillMaxWidth() - .weight(1f), - ) { - when { - // Search results - searchQuery.isNotEmpty() -> { - if (displayedIcons.isEmpty()) { - EmptySearchResult(searchQuery) - } else { - IconGrid( - icons = displayedIcons, - currentIconId = currentIconId, - onIconClick = { icon -> - onIconSelected(icon.id) - onNavigateBack() - }, - ) - } - } - // Category view - viewMode == IconViewMode.CATEGORIES && selectedCategory == null -> { - CategoryList( - categories = MaterialIconsLibrary.categories, - currentIconId = currentIconId, - onCategoryClick = { category -> - selectedCategory = category.name - }, - ) - } - // Icons in selected category or all icons - displayedIcons.isNotEmpty() -> { - Column { - selectedCategory?.let { - Text( - text = it, - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(bottom = 8.dp), - ) - } - IconGrid( - icons = displayedIcons, - currentIconId = currentIconId, - onIconClick = { icon -> - onIconSelected(icon.id) - onNavigateBack() - }, - ) - } - } - } - } - } - } - - currentIcon?.let { (id, icon) -> - Card( - modifier = - Modifier - .fillMaxWidth() - .align(Alignment.BottomCenter) - .windowInsetsPadding(WindowInsets.navigationBars) - .padding(horizontal = 16.dp, vertical = 8.dp), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f), - ), - ) { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = icon, - contentDescription = null, - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.primary, - ) - Spacer(modifier = Modifier.width(12.dp)) - Column { - val iconInfo = MaterialIconsLibrary.getAllIcons().find { it.id == id } - Text( - text = - stringResource( - R.string.current_icon_format, - iconInfo?.label ?: id, - ), - style = MaterialTheme.typography.bodyMedium, - ) - MaterialIconsLibrary.getCategoryForIcon(id)?.let { category -> - Text( - text = category, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - } - } - } -} - -@Composable -private fun CategoryList(categories: List, currentIconId: String?, onCategoryClick: (IconCategory) -> Unit) { - LazyColumn( - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - items(categories) { category -> - CategoryCard( - category = category, - hasSelectedIcon = category.icons.any { it.id == currentIconId }, - onClick = { onCategoryClick(category) }, - ) - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun CategoryCard(category: IconCategory, hasSelectedIcon: Boolean, onClick: () -> Unit) { - Card( - onClick = onClick, - modifier = Modifier.fillMaxWidth(), - colors = - CardDefaults.cardColors( - containerColor = - if (hasSelectedIcon) { - MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f) - } else { - MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) - }, - ), - ) { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = category.name, - style = MaterialTheme.typography.titleMedium, - ) - Text( - text = stringResource(R.string.icon_count_format, category.icons.size), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - // Preview first 3 icons - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - category.icons.take(3).forEach { icon -> - Icon( - imageVector = icon.icon, - contentDescription = null, - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Icon( - Icons.AutoMirrored.Filled.KeyboardArrowRight, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } -} - -@Composable -private fun IconGrid(icons: List, currentIconId: String?, onIconClick: (ProfileIcon) -> Unit) { - LazyVerticalGrid( - columns = GridCells.Adaptive(minSize = 72.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - items(icons) { icon -> - IconGridItem( - icon = icon, - isSelected = currentIconId == icon.id, - onClick = { onIconClick(icon) }, - ) - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun IconGridItem(icon: ProfileIcon, isSelected: Boolean, onClick: () -> Unit) { - Card( - onClick = onClick, - modifier = - Modifier - .fillMaxWidth() - .aspectRatio(1f), - colors = - CardDefaults.cardColors( - containerColor = - if (isSelected) { - MaterialTheme.colorScheme.primaryContainer - } else { - MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) - }, - ), - border = - if (isSelected) { - CardDefaults.outlinedCardBorder() - } else { - null - }, - ) { - Column( - modifier = - Modifier - .fillMaxSize() - .padding(8.dp), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Icon( - imageVector = icon.icon, - contentDescription = icon.label, - modifier = Modifier.size(28.dp), - tint = - if (isSelected) { - MaterialTheme.colorScheme.onPrimaryContainer - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - ) - - Spacer(modifier = Modifier.height(4.dp)) - - Text( - text = icon.label, - style = MaterialTheme.typography.labelSmall, - color = - if (isSelected) { - MaterialTheme.colorScheme.onPrimaryContainer - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth(), - ) - } - } -} - -@Composable -private fun EmptySearchResult(query: String) { - Column( - modifier = - Modifier - .fillMaxSize() - .padding(32.dp), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Icon( - Icons.Default.SearchOff, - contentDescription = null, - modifier = Modifier.size(64.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), - ) - Spacer(modifier = Modifier.height(16.dp)) - Text( - text = stringResource(R.string.no_icons_found), - style = MaterialTheme.typography.titleMedium, - ) - Text( - text = stringResource(R.string.no_icons_match, query), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } -} - -private enum class IconViewMode { - CATEGORIES, - ALL, - SEARCH, -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/ManualScrollTextProcessor.java b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/ManualScrollTextProcessor.java deleted file mode 100644 index ad1ead99f7..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/ManualScrollTextProcessor.java +++ /dev/null @@ -1,132 +0,0 @@ -package io.nekohasekai.sfa.compose.screen.profile; - -import android.content.Context; -import android.util.AttributeSet; -import android.view.MotionEvent; -import android.view.ViewConfiguration; -import com.blacksquircle.ui.editorkit.widget.TextProcessor; - -public class ManualScrollTextProcessor extends TextProcessor { - - private final int touchSlop; - private boolean allowCursorAutoScroll = true; - private float downX; - private float downY; - private boolean userDragging; - private int downSelectionStart = -1; - private int downSelectionEnd = -1; - private boolean restoringSelection; - - public ManualScrollTextProcessor(Context context) { - this(context, null); - } - - public ManualScrollTextProcessor(Context context, AttributeSet attrs) { - this(context, attrs, android.R.attr.autoCompleteTextViewStyle); - } - - public ManualScrollTextProcessor(Context context, AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - touchSlop = ViewConfiguration.get(context).getScaledTouchSlop(); - } - - public void resumeAutoScroll() { - allowCursorAutoScroll = true; - userDragging = false; - } - - @Override - public boolean bringPointIntoView(int offset) { - if (allowCursorAutoScroll) { - return super.bringPointIntoView(offset); - } - return false; - } - - @Override - public boolean onTouchEvent(MotionEvent event) { - int action = event.getActionMasked(); - switch (action) { - case MotionEvent.ACTION_DOWN: - downX = event.getX(); - downY = event.getY(); - userDragging = false; - restoringSelection = false; - downSelectionStart = getSelectionStart(); - downSelectionEnd = getSelectionEnd(); - break; - case MotionEvent.ACTION_MOVE: - if (!userDragging) { - float dx = Math.abs(event.getX() - downX); - float dy = Math.abs(event.getY() - downY); - if (dx > touchSlop || dy > touchSlop) { - userDragging = true; - allowCursorAutoScroll = false; - } - } - break; - case MotionEvent.ACTION_UP: - case MotionEvent.ACTION_CANCEL: - break; - default: - break; - } - - boolean handled = super.onTouchEvent(event); - - switch (action) { - case MotionEvent.ACTION_MOVE: - if (userDragging) { - maybeRestoreSelection(); - } - break; - case MotionEvent.ACTION_UP: - case MotionEvent.ACTION_CANCEL: - if (userDragging) { - maybeRestoreSelection(); - } else { - resumeAutoScroll(); - } - break; - default: - break; - } - - return handled; - } - - private void maybeRestoreSelection() { - if (userDragging && !restoringSelection) { - int selStart = getSelectionStart(); - int selEnd = getSelectionEnd(); - if (selStart != downSelectionStart || selEnd != downSelectionEnd) { - restoringSelection = true; - int targetEnd = downSelectionEnd >= 0 ? downSelectionEnd : downSelectionStart; - setSelection(downSelectionStart, targetEnd); - } - } - } - - @Override - protected void onSelectionChanged(int selStart, int selEnd) { - if (restoringSelection) { - restoringSelection = false; - super.onSelectionChanged(selStart, selEnd); - return; - } - - if (userDragging) { - if (downSelectionStart >= 0 - && (selStart != downSelectionStart || selEnd != downSelectionEnd)) { - restoringSelection = true; - int targetEnd = downSelectionEnd >= 0 ? downSelectionEnd : downSelectionStart; - setSelection(downSelectionStart, targetEnd); - return; - } - } - - downSelectionStart = selStart; - downSelectionEnd = selEnd; - super.onSelectionChanged(selStart, selEnd); - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/profileoverride/PerAppProxyScreen.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/profileoverride/PerAppProxyScreen.kt deleted file mode 100644 index 74b89866b2..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/profileoverride/PerAppProxyScreen.kt +++ /dev/null @@ -1,1390 +0,0 @@ -package io.nekohasekai.sfa.compose.screen.profileoverride - -import android.content.pm.PackageInfo -import android.content.pm.PackageManager -import android.os.Build -import android.util.Log -import android.widget.Toast -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.expandVertically -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.shrinkVertically -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.heightIn -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.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.Clear -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.ContentCopy -import androidx.compose.material.icons.filled.ContentPaste -import androidx.compose.material.icons.filled.ExpandLess -import androidx.compose.material.icons.filled.ExpandMore -import androidx.compose.material.icons.filled.FilterList -import androidx.compose.material.icons.filled.ManageSearch -import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material.icons.filled.RadioButtonChecked -import androidx.compose.material.icons.filled.RadioButtonUnchecked -import androidx.compose.material.icons.filled.Save -import androidx.compose.material.icons.filled.Search -import androidx.compose.material.icons.filled.SelectAll -import androidx.compose.material.icons.filled.Sort -import androidx.compose.material.icons.filled.Tune -import androidx.compose.material3.DropdownMenu -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.LinearProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar -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.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalFocusManager -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.android.tools.smali.dexlib2.dexbacked.DexBackedDexFile -import io.nekohasekai.sfa.Application -import io.nekohasekai.sfa.R -import io.nekohasekai.sfa.compose.shared.AppSelectionCard -import io.nekohasekai.sfa.compose.shared.PackageCache -import io.nekohasekai.sfa.compose.shared.SortMode -import io.nekohasekai.sfa.compose.shared.buildDisplayPackages -import io.nekohasekai.sfa.compose.topbar.OverrideTopBar -import io.nekohasekai.sfa.database.Settings -import io.nekohasekai.sfa.ktx.clipboardText -import io.nekohasekai.sfa.vendor.PackageQueryManager -import io.nekohasekai.sfa.vendor.PrivilegedAccessRequiredException -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.io.File -import java.util.concurrent.atomic.AtomicInteger -import java.util.zip.ZipFile - -private data class LoadResult(val proxyMode: Int, val packages: List, val selectedUids: Set) - -private data class ScanProgress(val current: Int, val max: Int) - -private sealed class ScanResult { - data object Empty : ScanResult() - data class Found(val apps: Map) : ScanResult() -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun PerAppProxyScreen(onBack: () -> Unit) { - val context = LocalContext.current - val focusManager = LocalFocusManager.current - val coroutineScope = rememberCoroutineScope() - - var proxyMode by remember { mutableStateOf(Settings.perAppProxyMode) } - var sortMode by remember { mutableStateOf(SortMode.NAME) } - var sortReverse by remember { mutableStateOf(false) } - var hideSystemApps by remember { mutableStateOf(false) } - var hideOfflineApps by remember { mutableStateOf(true) } - var hideDisabledApps by remember { mutableStateOf(true) } - - var packages by remember { mutableStateOf>(emptyList()) } - var displayPackages by remember { mutableStateOf>(emptyList()) } - var currentPackages by remember { mutableStateOf>(emptyList()) } - var selectedUids by remember { mutableStateOf>(emptySet()) } - var isLoading by remember { mutableStateOf(true) } - - var isSearchActive by remember { mutableStateOf(false) } - var searchQuery by remember { mutableStateOf("") } - - var scanProgress by remember { mutableStateOf(null) } - var scanResult by remember { mutableStateOf(null) } - - fun buildPackageList(newUids: Set): Set = newUids.mapNotNull { uid -> - packages.find { it.uid == uid }?.packageName - }.toSet() - - fun updateCurrentPackages(filterQuery: String) { - currentPackages = - if (filterQuery.isEmpty()) { - displayPackages - } else { - displayPackages.filter { - it.applicationLabel.contains(filterQuery, ignoreCase = true) || - it.packageName.contains(filterQuery, ignoreCase = true) || - it.uid.toString().contains(filterQuery) - } - } - } - - fun applyFilter() { - displayPackages = - buildDisplayPackages( - packages = packages, - selectedUids = selectedUids, - selectedFirst = true, - hideSystemApps = hideSystemApps, - hideOfflineApps = hideOfflineApps, - hideDisabledApps = hideDisabledApps, - sortMode = sortMode, - sortReverse = sortReverse, - ) - currentPackages = displayPackages - } - - fun saveSelectedApplications(newUids: Set) { - coroutineScope.launch { - Settings.perAppProxyList = buildPackageList(newUids) - } - } - - fun postSaveSelectedApplications(newUids: Set) { - selectedUids = newUids - saveSelectedApplications(newUids) - } - - fun toggleSelection(packageCache: PackageCache, selected: Boolean) { - val newSelected = - if (selected) { - selectedUids + packageCache.uid - } else { - selectedUids - packageCache.uid - } - if (newSelected == selectedUids) return - selectedUids = newSelected - saveSelectedApplications(newSelected) - } - - fun startScan() { - if (scanProgress != null) return - val scanPackages = currentPackages.toList() - if (scanPackages.isEmpty()) return - scanProgress = ScanProgress(0, scanPackages.size) - coroutineScope.launch { - val startTime = System.currentTimeMillis() - val foundApps = - withContext(Dispatchers.Default) { - mutableMapOf().also { found -> - val progressInt = AtomicInteger() - scanPackages.map { packageCache -> - async { - if (PerAppProxyScanner.scanChinaPackage(packageCache.info)) { - found[packageCache.packageName] = packageCache - } - val nextValue = progressInt.incrementAndGet() - withContext(Dispatchers.Main) { - scanProgress = ScanProgress(nextValue, scanPackages.size) - } - } - }.awaitAll() - } - } - Log.d( - "PerAppProxyScanner", - "Scan China apps took ${(System.currentTimeMillis() - startTime).toDouble() / 1000}s", - ) - scanProgress = null - scanResult = if (foundApps.isEmpty()) ScanResult.Empty else ScanResult.Found(foundApps) - } - } - - LaunchedEffect(Unit) { - isLoading = true - val packageManagerFlags = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - PackageManager.GET_PERMISSIONS or PackageManager.MATCH_UNINSTALLED_PACKAGES or - PackageManager.GET_ACTIVITIES or PackageManager.GET_SERVICES or - PackageManager.GET_RECEIVERS or PackageManager.GET_PROVIDERS - } else { - @Suppress("DEPRECATION") - PackageManager.GET_PERMISSIONS or PackageManager.GET_UNINSTALLED_PACKAGES or - PackageManager.GET_ACTIVITIES or PackageManager.GET_SERVICES or - PackageManager.GET_RECEIVERS or PackageManager.GET_PROVIDERS - } - val retryFlags = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - PackageManager.MATCH_UNINSTALLED_PACKAGES or PackageManager.GET_PERMISSIONS - } else { - @Suppress("DEPRECATION") - PackageManager.GET_UNINSTALLED_PACKAGES or PackageManager.GET_PERMISSIONS - } - val loadResult = - withContext(Dispatchers.IO) { - try { - val mode = - if (Settings.perAppProxyMode == Settings.PER_APP_PROXY_INCLUDE) { - Settings.PER_APP_PROXY_INCLUDE - } else { - Settings.PER_APP_PROXY_EXCLUDE - } - val installedPackages = PackageQueryManager.getInstalledPackages(packageManagerFlags, retryFlags) - val packageManager = context.packageManager - val packageCaches = - installedPackages.mapNotNull { packageInfo -> - if (packageInfo.packageName == context.packageName) return@mapNotNull null - val appInfo = packageInfo.applicationInfo ?: return@mapNotNull null - PackageCache(packageInfo, appInfo, packageManager) - } - val selectedPackageNames = Settings.perAppProxyList.toMutableSet() - val selectedUidSet = - packageCaches.mapNotNull { packageCache -> - if (selectedPackageNames.contains(packageCache.packageName)) { - packageCache.uid - } else { - null - } - }.toSet() - LoadResult(mode, packageCaches, selectedUidSet) - } catch (_: PrivilegedAccessRequiredException) { - null - } - } - if (loadResult == null) { - Toast.makeText( - context, - R.string.privileged_access_required, - Toast.LENGTH_LONG, - ).show() - onBack() - return@LaunchedEffect - } - proxyMode = loadResult.proxyMode - packages = loadResult.packages - selectedUids = loadResult.selectedUids - applyFilter() - updateCurrentPackages(searchQuery) - isLoading = false - } - - OverrideTopBar { - TopAppBar( - title = { Text(stringResource(R.string.per_app_proxy)) }, - navigationIcon = { - IconButton(onClick = onBack) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.content_description_back), - ) - } - }, - actions = { - IconButton( - onClick = { - isSearchActive = !isSearchActive - if (!isSearchActive) { - searchQuery = "" - updateCurrentPackages("") - focusManager.clearFocus() - } - }, - ) { - Icon( - imageVector = if (isSearchActive) Icons.Default.Close else Icons.Default.Search, - contentDescription = stringResource(R.string.search), - ) - } - PerAppProxyMenus( - proxyMode = proxyMode, - sortMode = sortMode, - sortReverse = sortReverse, - hideSystemApps = hideSystemApps, - hideOfflineApps = hideOfflineApps, - hideDisabledApps = hideDisabledApps, - onModeChange = { mode -> - proxyMode = mode - coroutineScope.launch { - Settings.perAppProxyMode = mode - } - }, - onSortModeChange = { mode -> - sortMode = mode - applyFilter() - }, - onSortReverseToggle = { - sortReverse = !sortReverse - applyFilter() - }, - onHideSystemAppsToggle = { - hideSystemApps = !hideSystemApps - applyFilter() - }, - onHideOfflineAppsToggle = { - hideOfflineApps = !hideOfflineApps - applyFilter() - }, - onHideDisabledAppsToggle = { - hideDisabledApps = !hideDisabledApps - applyFilter() - }, - onSelectAll = { - val newSelected = currentPackages.map { it.uid }.toSet() - postSaveSelectedApplications(newSelected) - }, - onDeselectAll = { - postSaveSelectedApplications(emptySet()) - }, - onImport = { - val packageNames = - clipboardText?.split("\n")?.distinct() - ?.takeIf { it.isNotEmpty() && it[0].isNotEmpty() } - if (packageNames.isNullOrEmpty()) { - Toast.makeText( - context, - R.string.toast_clipboard_empty, - Toast.LENGTH_SHORT, - ).show() - } else { - val newSelected = - packages.mapNotNull { packageCache -> - if (packageNames.contains(packageCache.packageName)) { - packageCache.uid - } else { - null - } - }.toSet() - postSaveSelectedApplications(newSelected) - Toast.makeText( - context, - R.string.toast_imported_from_clipboard, - Toast.LENGTH_SHORT, - ).show() - } - }, - onExport = { - val packageList = - packages.mapNotNull { packageCache -> - if (selectedUids.contains(packageCache.uid)) { - packageCache.packageName - } else { - null - } - } - clipboardText = packageList.joinToString("\n") - Toast.makeText( - context, - R.string.toast_copied_to_clipboard, - Toast.LENGTH_SHORT, - ).show() - }, - onScanChinaApps = { startScan() }, - ) - }, - colors = - TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface, - titleContentColor = MaterialTheme.colorScheme.onSurface, - ), - ) - } - - Column( - modifier = Modifier.fillMaxSize(), - ) { - AnimatedVisibility( - visible = isLoading, - enter = fadeIn(), - exit = fadeOut(), - ) { - LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) - } - - Surface( - modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.surfaceContainerLow, - ) { - Text( - text = - if (proxyMode == Settings.PER_APP_PROXY_INCLUDE) { - stringResource(R.string.per_app_proxy_mode_include_description) - } else { - stringResource(R.string.per_app_proxy_mode_exclude_description) - }, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp), - style = MaterialTheme.typography.bodyMedium, - ) - } - - AnimatedVisibility( - visible = isSearchActive, - enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut(), - ) { - val focusRequester = remember { FocusRequester() } - - LaunchedEffect(isSearchActive) { - if (isSearchActive) { - focusRequester.requestFocus() - } - } - - OutlinedTextField( - value = searchQuery, - onValueChange = { - searchQuery = it - updateCurrentPackages(it) - }, - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp) - .focusRequester(focusRequester), - placeholder = { Text(stringResource(R.string.search)) }, - leadingIcon = { - Icon( - imageVector = Icons.Default.Search, - contentDescription = stringResource(R.string.search), - ) - }, - trailingIcon = { - if (searchQuery.isNotEmpty()) { - IconButton(onClick = { - searchQuery = "" - updateCurrentPackages("") - focusManager.clearFocus() - }) { - Icon( - imageVector = Icons.Default.Clear, - contentDescription = stringResource(R.string.content_description_clear_search), - ) - } - } - }, - singleLine = true, - ) - } - - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = - androidx.compose.foundation.layout.PaddingValues( - horizontal = 16.dp, - vertical = 12.dp, - ), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - items(currentPackages, key = { it.packageName }) { packageCache -> - AppSelectionCard( - packageCache = packageCache, - selected = selectedUids.contains(packageCache.uid), - onToggle = { selected -> toggleSelection(packageCache, selected) }, - onCopyLabel = { clipboardText = packageCache.applicationLabel }, - onCopyPackage = { clipboardText = packageCache.packageName }, - onCopyUid = { clipboardText = packageCache.uid.toString() }, - ) - } - } - } - - if (scanProgress != null) { - val progress = scanProgress - Dialog( - onDismissRequest = {}, - properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false), - ) { - Surface( - shape = MaterialTheme.shapes.medium, - color = MaterialTheme.colorScheme.surface, - ) { - Column(modifier = Modifier.padding(24.dp)) { - Text( - text = stringResource(R.string.message_scanning), - style = MaterialTheme.typography.titleMedium, - ) - Spacer(modifier = Modifier.height(16.dp)) - LinearProgressIndicator( - progress = { - if (progress == null || progress.max == 0) { - 0f - } else { - progress.current.toFloat() / progress.max.toFloat() - } - }, - modifier = Modifier.fillMaxWidth(), - ) - Spacer(modifier = Modifier.height(8.dp)) - if (progress != null) { - Text( - text = "${progress.current}/${progress.max}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - } - } - - when (val result = scanResult) { - ScanResult.Empty -> { - Dialog( - onDismissRequest = { scanResult = null }, - ) { - Surface( - shape = MaterialTheme.shapes.medium, - color = MaterialTheme.colorScheme.surface, - ) { - Column(modifier = Modifier.padding(24.dp)) { - Text( - text = stringResource(R.string.title_scan_result), - style = MaterialTheme.typography.titleMedium, - ) - Spacer(modifier = Modifier.height(16.dp)) - Text( - text = stringResource(R.string.message_scan_app_no_apps_found), - style = MaterialTheme.typography.bodyMedium, - ) - Spacer(modifier = Modifier.height(24.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - ) { - TextButton(onClick = { scanResult = null }) { - Text(stringResource(R.string.ok)) - } - } - } - } - } - } - - is ScanResult.Found -> { - val dialogContent = - stringResource(R.string.message_scan_app_found) + "\n\n" + - result.apps.entries.joinToString("\n") { - "${it.value.applicationLabel} (${it.key})" - } - Dialog( - onDismissRequest = { scanResult = null }, - ) { - Surface( - shape = MaterialTheme.shapes.medium, - color = MaterialTheme.colorScheme.surface, - ) { - Column(modifier = Modifier.padding(24.dp)) { - Text( - text = stringResource(R.string.title_scan_result), - style = MaterialTheme.typography.titleMedium, - ) - Spacer(modifier = Modifier.height(16.dp)) - Box( - modifier = - Modifier - .fillMaxWidth() - .heightIn(max = 360.dp) - .verticalScroll(rememberScrollState()), - ) { - Text( - text = dialogContent, - style = MaterialTheme.typography.bodyMedium, - ) - } - Spacer(modifier = Modifier.height(24.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - ) { - TextButton(onClick = { scanResult = null }) { - Text(stringResource(android.R.string.cancel)) - } - Spacer(modifier = Modifier.size(8.dp)) - TextButton( - onClick = { - val newSelected = selectedUids.toMutableSet() - result.apps.values.forEach { - newSelected.remove(it.uid) - } - postSaveSelectedApplications(newSelected) - scanResult = null - }, - ) { - Text(stringResource(R.string.action_deselect)) - } - Spacer(modifier = Modifier.size(8.dp)) - TextButton( - onClick = { - val newSelected = selectedUids.toMutableSet() - result.apps.values.forEach { - newSelected.add(it.uid) - } - postSaveSelectedApplications(newSelected) - scanResult = null - }, - ) { - Text(stringResource(R.string.per_app_proxy_select)) - } - } - } - } - } - } - - null -> Unit - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun PerAppProxyMenus( - proxyMode: Int, - sortMode: SortMode, - sortReverse: Boolean, - hideSystemApps: Boolean, - hideOfflineApps: Boolean, - hideDisabledApps: Boolean, - onModeChange: (Int) -> Unit, - onSortModeChange: (SortMode) -> Unit, - onSortReverseToggle: () -> Unit, - onHideSystemAppsToggle: () -> Unit, - onHideOfflineAppsToggle: () -> Unit, - onHideDisabledAppsToggle: () -> Unit, - onSelectAll: () -> Unit, - onDeselectAll: () -> Unit, - onImport: () -> Unit, - onExport: () -> Unit, - onScanChinaApps: () -> Unit, -) { - var showMainMenu by remember { mutableStateOf(false) } - var showModeMenu by remember { mutableStateOf(false) } - var showSortMenu by remember { mutableStateOf(false) } - var showFilterMenu by remember { mutableStateOf(false) } - var showSelectMenu by remember { mutableStateOf(false) } - var showBackupMenu by remember { mutableStateOf(false) } - var showScanMenu by remember { mutableStateOf(false) } - - Box { - IconButton(onClick = { showMainMenu = true }) { - Icon(Icons.Default.MoreVert, contentDescription = null) - } - - DropdownMenu( - expanded = showMainMenu, - onDismissRequest = { - showMainMenu = false - showModeMenu = false - showSortMenu = false - showFilterMenu = false - showSelectMenu = false - showBackupMenu = false - showScanMenu = false - }, - ) { - DropdownMenuItem( - text = { Text(stringResource(R.string.per_app_proxy_mode)) }, - onClick = { showModeMenu = !showModeMenu }, - leadingIcon = { - Icon( - imageVector = Icons.Default.Tune, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - }, - trailingIcon = { - Icon( - imageVector = - if (showModeMenu) { - Icons.Default.ExpandLess - } else { - Icons.Default.ExpandMore - }, - contentDescription = null, - ) - }, - ) - if (showModeMenu) { - DropdownMenuItem( - text = { Text(stringResource(R.string.per_app_proxy_mode_include)) }, - onClick = { - onModeChange(Settings.PER_APP_PROXY_INCLUDE) - showMainMenu = false - showModeMenu = false - }, - leadingIcon = { - Icon( - imageVector = - if (proxyMode == Settings.PER_APP_PROXY_INCLUDE) { - Icons.Default.RadioButtonChecked - } else { - Icons.Default.RadioButtonUnchecked - }, - contentDescription = null, - tint = - if (proxyMode == Settings.PER_APP_PROXY_INCLUDE) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - modifier = Modifier.padding(start = 24.dp), - ) - }, - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.per_app_proxy_mode_exclude)) }, - onClick = { - onModeChange(Settings.PER_APP_PROXY_EXCLUDE) - showMainMenu = false - showModeMenu = false - }, - leadingIcon = { - Icon( - imageVector = - if (proxyMode == Settings.PER_APP_PROXY_EXCLUDE) { - Icons.Default.RadioButtonChecked - } else { - Icons.Default.RadioButtonUnchecked - }, - contentDescription = null, - tint = - if (proxyMode == Settings.PER_APP_PROXY_EXCLUDE) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - modifier = Modifier.padding(start = 24.dp), - ) - }, - ) - HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) - } - DropdownMenuItem( - text = { Text(stringResource(R.string.per_app_proxy_sort_mode)) }, - onClick = { showSortMenu = !showSortMenu }, - leadingIcon = { - Icon( - imageVector = Icons.Default.Sort, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - }, - trailingIcon = { - Icon( - imageVector = - if (showSortMenu) { - Icons.Default.ExpandLess - } else { - Icons.Default.ExpandMore - }, - contentDescription = null, - ) - }, - ) - if (showSortMenu) { - DropdownMenuItem( - text = { Text(stringResource(R.string.per_app_proxy_sort_mode_name)) }, - onClick = { - onSortModeChange(SortMode.NAME) - showMainMenu = false - showSortMenu = false - }, - leadingIcon = { - Icon( - imageVector = - if (sortMode == SortMode.NAME) { - Icons.Default.RadioButtonChecked - } else { - Icons.Default.RadioButtonUnchecked - }, - contentDescription = null, - tint = - if (sortMode == SortMode.NAME) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - modifier = Modifier.padding(start = 24.dp), - ) - }, - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.per_app_proxy_sort_mode_package_name)) }, - onClick = { - onSortModeChange(SortMode.PACKAGE_NAME) - showMainMenu = false - showSortMenu = false - }, - leadingIcon = { - Icon( - imageVector = - if (sortMode == SortMode.PACKAGE_NAME) { - Icons.Default.RadioButtonChecked - } else { - Icons.Default.RadioButtonUnchecked - }, - contentDescription = null, - tint = - if (sortMode == SortMode.PACKAGE_NAME) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - modifier = Modifier.padding(start = 24.dp), - ) - }, - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.per_app_proxy_sort_mode_uid)) }, - onClick = { - onSortModeChange(SortMode.UID) - showMainMenu = false - showSortMenu = false - }, - leadingIcon = { - Icon( - imageVector = - if (sortMode == SortMode.UID) { - Icons.Default.RadioButtonChecked - } else { - Icons.Default.RadioButtonUnchecked - }, - contentDescription = null, - tint = - if (sortMode == SortMode.UID) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - modifier = Modifier.padding(start = 24.dp), - ) - }, - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.per_app_proxy_sort_mode_install_time)) }, - onClick = { - onSortModeChange(SortMode.INSTALL_TIME) - showMainMenu = false - showSortMenu = false - }, - leadingIcon = { - Icon( - imageVector = - if (sortMode == SortMode.INSTALL_TIME) { - Icons.Default.RadioButtonChecked - } else { - Icons.Default.RadioButtonUnchecked - }, - contentDescription = null, - tint = - if (sortMode == SortMode.INSTALL_TIME) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - modifier = Modifier.padding(start = 24.dp), - ) - }, - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.per_app_proxy_sort_mode_update_time)) }, - onClick = { - onSortModeChange(SortMode.UPDATE_TIME) - showMainMenu = false - showSortMenu = false - }, - leadingIcon = { - Icon( - imageVector = - if (sortMode == SortMode.UPDATE_TIME) { - Icons.Default.RadioButtonChecked - } else { - Icons.Default.RadioButtonUnchecked - }, - contentDescription = null, - tint = - if (sortMode == SortMode.UPDATE_TIME) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - modifier = Modifier.padding(start = 24.dp), - ) - }, - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.per_app_proxy_sort_mode_reverse)) }, - onClick = { - onSortReverseToggle() - showMainMenu = false - showSortMenu = false - }, - leadingIcon = { - Icon( - imageVector = - if (sortReverse) { - Icons.Default.Check - } else { - Icons.Default.RadioButtonUnchecked - }, - contentDescription = null, - tint = - if (sortReverse) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - modifier = Modifier.padding(start = 24.dp), - ) - }, - ) - HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) - } - DropdownMenuItem( - text = { Text(stringResource(R.string.per_app_proxy_filter)) }, - onClick = { showFilterMenu = !showFilterMenu }, - leadingIcon = { - Icon( - imageVector = Icons.Default.FilterList, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - }, - trailingIcon = { - Icon( - imageVector = - if (showFilterMenu) { - Icons.Default.ExpandLess - } else { - Icons.Default.ExpandMore - }, - contentDescription = null, - ) - }, - ) - if (showFilterMenu) { - DropdownMenuItem( - text = { Text(stringResource(R.string.per_app_proxy_hide_system_apps)) }, - onClick = { - onHideSystemAppsToggle() - showMainMenu = false - showFilterMenu = false - }, - leadingIcon = { - Icon( - imageVector = - if (hideSystemApps) { - Icons.Default.Check - } else { - Icons.Default.RadioButtonUnchecked - }, - contentDescription = null, - tint = - if (hideSystemApps) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - modifier = Modifier.padding(start = 24.dp), - ) - }, - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.per_app_proxy_hide_offline_apps)) }, - onClick = { - onHideOfflineAppsToggle() - showMainMenu = false - showFilterMenu = false - }, - leadingIcon = { - Icon( - imageVector = - if (hideOfflineApps) { - Icons.Default.Check - } else { - Icons.Default.RadioButtonUnchecked - }, - contentDescription = null, - tint = - if (hideOfflineApps) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - modifier = Modifier.padding(start = 24.dp), - ) - }, - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.per_app_proxy_hide_disabled_apps)) }, - onClick = { - onHideDisabledAppsToggle() - showMainMenu = false - showFilterMenu = false - }, - leadingIcon = { - Icon( - imageVector = - if (hideDisabledApps) { - Icons.Default.Check - } else { - Icons.Default.RadioButtonUnchecked - }, - contentDescription = null, - tint = - if (hideDisabledApps) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - modifier = Modifier.padding(start = 24.dp), - ) - }, - ) - HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) - } - DropdownMenuItem( - text = { Text(stringResource(R.string.per_app_proxy_select)) }, - onClick = { showSelectMenu = !showSelectMenu }, - leadingIcon = { - Icon( - imageVector = Icons.Default.SelectAll, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - }, - trailingIcon = { - Icon( - imageVector = - if (showSelectMenu) { - Icons.Default.ExpandLess - } else { - Icons.Default.ExpandMore - }, - contentDescription = null, - ) - }, - ) - if (showSelectMenu) { - DropdownMenuItem( - text = { Text(stringResource(R.string.per_app_proxy_select_all)) }, - onClick = { - onSelectAll() - showMainMenu = false - showSelectMenu = false - }, - leadingIcon = { - Icon( - imageVector = Icons.Default.SelectAll, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(start = 24.dp), - ) - }, - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.per_app_proxy_select_none)) }, - onClick = { - onDeselectAll() - showMainMenu = false - showSelectMenu = false - }, - leadingIcon = { - Icon( - imageVector = Icons.Default.RadioButtonUnchecked, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(start = 24.dp), - ) - }, - ) - HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) - } - DropdownMenuItem( - text = { Text(stringResource(R.string.per_app_proxy_backup)) }, - onClick = { showBackupMenu = !showBackupMenu }, - leadingIcon = { - Icon( - imageVector = Icons.Default.Save, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - }, - trailingIcon = { - Icon( - imageVector = - if (showBackupMenu) { - Icons.Default.ExpandLess - } else { - Icons.Default.ExpandMore - }, - contentDescription = null, - ) - }, - ) - if (showBackupMenu) { - DropdownMenuItem( - text = { Text(stringResource(R.string.per_app_proxy_import)) }, - onClick = { - onImport() - showMainMenu = false - showBackupMenu = false - }, - leadingIcon = { - Icon( - imageVector = Icons.Default.ContentPaste, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(start = 24.dp), - ) - }, - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.per_app_proxy_export)) }, - onClick = { - onExport() - showMainMenu = false - showBackupMenu = false - }, - leadingIcon = { - Icon( - imageVector = Icons.Default.ContentCopy, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(start = 24.dp), - ) - }, - ) - HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) - } - DropdownMenuItem( - text = { Text(stringResource(R.string.per_app_proxy_scan)) }, - onClick = { showScanMenu = !showScanMenu }, - leadingIcon = { - Icon( - imageVector = Icons.Default.ManageSearch, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - }, - trailingIcon = { - Icon( - imageVector = - if (showScanMenu) { - Icons.Default.ExpandLess - } else { - Icons.Default.ExpandMore - }, - contentDescription = null, - ) - }, - ) - if (showScanMenu) { - DropdownMenuItem( - text = { Text(stringResource(R.string.per_app_proxy_scan_china_apps)) }, - onClick = { - onScanChinaApps() - showMainMenu = false - showScanMenu = false - }, - leadingIcon = { - Icon( - imageVector = Icons.Default.ManageSearch, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(start = 24.dp), - ) - }, - ) - } - } - } -} - -object PerAppProxyScanner { - private val skipPrefixList = - listOf( - "com.google", - "com.android.chrome", - "com.android.vending", - "com.microsoft", - "com.apple", - "com.zhiliaoapp.musically", // Banned by China - "com.android.providers.downloads", - ) - - private val chinaAppPrefixList = - listOf( - "com.tencent", - "com.alibaba", - "com.umeng", - "com.qihoo", - "com.ali", - "com.alipay", - "com.amap", - "com.sina", - "com.weibo", - "com.vivo", - "com.xiaomi", - "com.huawei", - "com.taobao", - "com.secneo", - "s.h.e.l.l", - "com.stub", - "com.kiwisec", - "com.secshell", - "com.wrapper", - "cn.securitystack", - "com.mogosec", - "com.secoen", - "com.netease", - "com.mx", - "com.qq.e", - "com.baidu", - "com.bytedance", - "com.bugly", - "com.miui", - "com.oppo", - "com.coloros", - "com.iqoo", - "com.meizu", - "com.gionee", - "cn.nubia", - "com.oplus", - "andes.oplus", - "com.unionpay", - "cn.wps", - ) - - private val chinaAppRegex by lazy { - ("(" + chinaAppPrefixList.joinToString("|").replace(".", "\\.") + ").*").toRegex() - } - - suspend fun scanAllChinaApps(): Set = withContext(Dispatchers.Default) { - val packageManagerFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - PackageManager.MATCH_UNINSTALLED_PACKAGES or - PackageManager.GET_ACTIVITIES or PackageManager.GET_SERVICES or - PackageManager.GET_RECEIVERS or PackageManager.GET_PROVIDERS - } else { - @Suppress("DEPRECATION") - PackageManager.GET_UNINSTALLED_PACKAGES or - PackageManager.GET_ACTIVITIES or PackageManager.GET_SERVICES or - PackageManager.GET_RECEIVERS or PackageManager.GET_PROVIDERS - } - val retryFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - PackageManager.MATCH_UNINSTALLED_PACKAGES or PackageManager.GET_PERMISSIONS - } else { - @Suppress("DEPRECATION") - PackageManager.GET_UNINSTALLED_PACKAGES or PackageManager.GET_PERMISSIONS - } - val installedPackages = PackageQueryManager.getInstalledPackages(packageManagerFlags, retryFlags) - val chinaApps = mutableSetOf() - installedPackages.map { packageInfo -> - async { - if (scanChinaPackage(packageInfo)) { - synchronized(chinaApps) { - chinaApps.add(packageInfo.packageName) - } - } - } - }.awaitAll() - chinaApps.toSet() - } - - fun scanChinaPackage(packageInfo: PackageInfo): Boolean { - val packageName = packageInfo.packageName - if (packageName == Application.application.packageName) return false - skipPrefixList.forEach { - if (packageName == it || packageName.startsWith("$it.")) return false - } - - if (packageName.matches(chinaAppRegex)) { - Log.d("PerAppProxyScanner", "Match package name: $packageName") - return true - } - try { - val appInfo = packageInfo.applicationInfo ?: return false - packageInfo.services?.forEach { - if (it.name.matches(chinaAppRegex)) { - Log.d("PerAppProxyScanner", "Match service ${it.name} in $packageName") - return true - } - } - packageInfo.activities?.forEach { - if (it.name.matches(chinaAppRegex)) { - Log.d("PerAppProxyScanner", "Match activity ${it.name} in $packageName") - return true - } - } - packageInfo.receivers?.forEach { - if (it.name.matches(chinaAppRegex)) { - Log.d("PerAppProxyScanner", "Match receiver ${it.name} in $packageName") - return true - } - } - packageInfo.providers?.forEach { - if (it.name.matches(chinaAppRegex)) { - Log.d("PerAppProxyScanner", "Match provider ${it.name} in $packageName") - return true - } - } - ZipFile(File(appInfo.publicSourceDir)).use { - for (packageEntry in it.entries()) { - if (packageEntry.name.startsWith("firebase-")) return false - } - for (packageEntry in it.entries()) { - if (!( - packageEntry.name.startsWith("classes") && - packageEntry.name.endsWith(".dex") - ) - ) { - continue - } - if (packageEntry.size > 15000000) { - Log.d( - "PerAppProxyScanner", - "Confirm $packageName due to large dex file", - ) - return true - } - val input = it.getInputStream(packageEntry).buffered() - val dexFile = - try { - DexBackedDexFile.fromInputStream(null, input) - } catch (e: Exception) { - Log.e("PerAppProxyScanner", "Error reading dex file", e) - return false - } - for (clazz in dexFile.classes) { - val clazzName = - clazz.type.substring(1, clazz.type.length - 1).replace("/", ".") - .replace("$", ".") - if (clazzName.matches(chinaAppRegex)) { - Log.d("PerAppProxyScanner", "Match $clazzName in $packageName") - return true - } - } - } - } - } catch (e: Exception) { - Log.e("PerAppProxyScanner", "Error scanning package $packageName", e) - } - return false - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/qrscan/QRCodeSmartCrop.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/qrscan/QRCodeSmartCrop.kt deleted file mode 100644 index d9a5de0601..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/qrscan/QRCodeSmartCrop.kt +++ /dev/null @@ -1,268 +0,0 @@ -package io.nekohasekai.sfa.compose.screen.qrscan - -import kotlin.math.max -import kotlin.math.min - -data class QRCodeCropArea( - val left: Int, - val top: Int, - val right: Int, - val bottom: Int, - val imageWidth: Int, - val imageHeight: Int, - val rotationDegrees: Int, -) - -object QRCodeSmartCrop { - fun findCropArea(yData: ByteArray, width: Int, height: Int, rotationDegrees: Int): QRCodeCropArea? { - val minDim = min(width, height) - if (minDim <= 0) return null - - val step = (minDim / 120).coerceIn(4, 16) - val samplesWide = (width + step - 1) / step - val samplesHigh = (height + step - 1) / step - val sampleCount = samplesWide * samplesHigh - if (sampleCount == 0) return null - - val histogram = IntArray(256) - var sum = 0 - var maxLuma = 0 - var sy = 0 - var y = 0 - while (sy < samplesHigh) { - val rowOffset = y * width - var sx = 0 - var x = 0 - while (sx < samplesWide) { - val value = yData[rowOffset + x].toInt() and 0xFF - sum += value - histogram[value]++ - if (value > maxLuma) maxLuma = value - sx++ - x += step - } - sy++ - y += step - } - - val mean = sum / sampleCount - val contrast = maxLuma - mean - if (contrast < 30) return null - - val p95 = percentile(histogram, sampleCount, 0.95f) - val p90 = percentile(histogram, sampleCount, 0.90f) - val p85 = percentile(histogram, sampleCount, 0.85f) - - val thresholds = intArrayOf( - max((mean + contrast * 0.75f).toInt(), p95), - max((mean + contrast * 0.6f).toInt(), p90), - max((mean + contrast * 0.5f).toInt(), p85), - ) - - val minBrightSamples = max(12, sampleCount / 300) - var bestArea: QRCodeCropArea? = null - var bestRatio = 1f - - for (i in thresholds.indices) { - val threshold = thresholds[i].coerceAtMost(250) - val component = findBestComponent( - yData, - width, - height, - step, - samplesWide, - samplesHigh, - threshold, - minBrightSamples, - ) ?: continue - - val area = buildCropArea(component, step, width, height, rotationDegrees) ?: continue - val areaRatio = ((area.right - area.left) * (area.bottom - area.top)).toFloat() / (width * height) - val maxRatio = if (i == thresholds.lastIndex) 0.9f else 0.82f - if (areaRatio <= maxRatio) return area - if (areaRatio < bestRatio) { - bestRatio = areaRatio - bestArea = area - } - } - - return bestArea - } - - private data class CropComponent(val minX: Int, val minY: Int, val maxX: Int, val maxY: Int, val count: Int, val score: Float) - - private fun findBestComponent( - yData: ByteArray, - width: Int, - height: Int, - step: Int, - samplesWide: Int, - samplesHigh: Int, - threshold: Int, - minBrightSamples: Int, - ): CropComponent? { - val totalSamples = samplesWide * samplesHigh - val bright = BooleanArray(totalSamples) - var brightCount = 0 - - var sy = 0 - var y = 0 - while (sy < samplesHigh) { - val rowOffset = y * width - var sx = 0 - var x = 0 - while (sx < samplesWide) { - val value = yData[rowOffset + x].toInt() and 0xFF - if (value >= threshold) { - bright[sy * samplesWide + sx] = true - brightCount++ - } - sx++ - x += step - } - sy++ - y += step - } - - if (brightCount < minBrightSamples) return null - - val visited = BooleanArray(totalSamples) - val queue = IntArray(totalSamples) - val minComponentSamples = max(8, minBrightSamples / 3) - val centerX = width / 2f - val centerY = height / 2f - val maxDistSq = centerX * centerX + centerY * centerY - - var best: CropComponent? = null - for (cy in 0 until samplesHigh) { - for (cx in 0 until samplesWide) { - val index = cy * samplesWide + cx - if (!bright[index] || visited[index]) continue - - var head = 0 - var tail = 0 - queue[tail++] = index - visited[index] = true - - var count = 0 - var minX = cx - var maxX = cx - var minY = cy - var maxY = cy - - while (head < tail) { - val current = queue[head++] - val x = current % samplesWide - val yIndex = current / samplesWide - count++ - - if (x < minX) minX = x - if (x > maxX) maxX = x - if (yIndex < minY) minY = yIndex - if (yIndex > maxY) maxY = yIndex - - val startX = if (x > 0) x - 1 else x - val endX = if (x + 1 < samplesWide) x + 1 else x - val startY = if (yIndex > 0) yIndex - 1 else yIndex - val endY = if (yIndex + 1 < samplesHigh) yIndex + 1 else yIndex - - var ny = startY - while (ny <= endY) { - val rowIndex = ny * samplesWide - var nx = startX - while (nx <= endX) { - if (nx != x || ny != yIndex) { - val neighbor = rowIndex + nx - if (bright[neighbor] && !visited[neighbor]) { - visited[neighbor] = true - queue[tail++] = neighbor - } - } - nx++ - } - ny++ - } - } - - if (count < minComponentSamples) continue - - val compWidth = maxX - minX + 1 - val compHeight = maxY - minY + 1 - val aspect = max(compWidth.toFloat() / compHeight, compHeight.toFloat() / compWidth) - val aspectPenalty = ((aspect - 1f) / 1.6f).coerceIn(0f, 1f) - val compCenterX = (minX + maxX + 1) * 0.5f * step - val compCenterY = (minY + maxY + 1) * 0.5f * step - val dx = compCenterX - centerX - val dy = compCenterY - centerY - val normDist = if (maxDistSq > 0f) (dx * dx + dy * dy) / maxDistSq else 0f - val edgeTouches = (if (minX == 0) 1 else 0) + - (if (minY == 0) 1 else 0) + - (if (maxX == samplesWide - 1) 1 else 0) + - (if (maxY == samplesHigh - 1) 1 else 0) - - var score = count.toFloat() - score *= 1f - 0.5f * normDist.coerceIn(0f, 1f) - score *= 1f - 0.35f * aspectPenalty - score *= 1f - 0.15f * edgeTouches - - if (best == null || score > best!!.score) { - best = CropComponent( - minX = minX, - minY = minY, - maxX = maxX, - maxY = maxY, - count = count, - score = score, - ) - } - } - } - - return best - } - - private fun buildCropArea(component: CropComponent, step: Int, width: Int, height: Int, rotationDegrees: Int): QRCodeCropArea? { - val left = component.minX * step - val top = component.minY * step - val right = min(width, (component.maxX + 1) * step) - val bottom = min(height, (component.maxY + 1) * step) - val cropWidth = right - left - val cropHeight = bottom - top - if (cropWidth <= 0 || cropHeight <= 0) return null - - val frameArea = width * height - val cropArea = cropWidth * cropHeight - if (cropArea < frameArea / 96) return null - - val aspect = cropWidth.toFloat() / cropHeight - if (aspect < 0.45f || aspect > 2.2f) return null - - val padding = (max(cropWidth, cropHeight) * 0.14f).toInt().coerceAtLeast(step * 2) - val cropLeft = (left - padding).coerceAtLeast(0) - val cropTop = (top - padding).coerceAtLeast(0) - val cropRight = (right + padding).coerceAtMost(width) - val cropBottom = (bottom + padding).coerceAtMost(height) - if (cropRight <= cropLeft || cropBottom <= cropTop) return null - - return QRCodeCropArea( - left = cropLeft, - top = cropTop, - right = cropRight, - bottom = cropBottom, - imageWidth = width, - imageHeight = height, - rotationDegrees = rotationDegrees, - ) - } - - private fun percentile(histogram: IntArray, count: Int, percentile: Float): Int { - if (count <= 0) return 0 - val target = (count * percentile).toInt().coerceIn(0, count - 1) - var acc = 0 - for (i in histogram.indices) { - acc += histogram[i] - if (acc > target) return i - } - return histogram.lastIndex - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/qrscan/QRScanViewModel.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/qrscan/QRScanViewModel.kt deleted file mode 100644 index 7c1d1ec547..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/qrscan/QRScanViewModel.kt +++ /dev/null @@ -1,403 +0,0 @@ -package io.nekohasekai.sfa.compose.screen.qrscan - -import android.app.Application -import android.net.Uri -import android.util.Base64 -import android.util.Log -import androidx.camera.core.Camera -import androidx.camera.core.CameraSelector -import androidx.camera.core.ImageAnalysis -import androidx.camera.core.Preview -import androidx.camera.lifecycle.ProcessCameraProvider -import androidx.camera.view.PreviewView -import androidx.core.content.ContextCompat -import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.LifecycleOwner -import io.nekohasekai.libbox.Libbox -import io.nekohasekai.sfa.qrs.QRSDecoder -import io.nekohasekai.sfa.qrs.readIntLE -import io.nekohasekai.sfa.vendor.Vendor -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import java.util.concurrent.ExecutorService -import java.util.concurrent.Executors -import java.util.concurrent.atomic.AtomicBoolean - -sealed class QRScanResult { - data class RemoteProfile(val uri: Uri) : QRScanResult() - data class QRSData(val data: ByteArray) : QRScanResult() { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - other as QRSData - return data.contentEquals(other.data) - } - - override fun hashCode(): Int = data.contentHashCode() - } -} - -data class QRScanUiState( - val isLoading: Boolean = true, - val useFrontCamera: Boolean = false, - val torchEnabled: Boolean = false, - val useVendorAnalyzer: Boolean = true, - val vendorAnalyzerAvailable: Boolean = false, - val qrsMode: Boolean = false, - val qrsProgress: Pair? = null, - val cropArea: QRCodeCropArea? = null, - val errorMessage: String? = null, - val result: QRScanResult? = null, - val zoomRatio: Float = 1f, - val maxZoomRatio: Float = 1f, -) - -class QRScanViewModel(application: Application) : AndroidViewModel(application) { - companion object { - private const val TAG = "QRScanViewModel" - } - - private val _uiState = MutableStateFlow(QRScanUiState()) - val uiState: StateFlow = _uiState.asStateFlow() - - private val analysisExecutor: ExecutorService = Executors.newSingleThreadExecutor() - private var cameraProvider: ProcessCameraProvider? = null - private var camera: Camera? = null - private var imageAnalysis: ImageAnalysis? = null - private var imageAnalyzer: ImageAnalysis.Analyzer? = null - private var cameraPreview: Preview? = null - - private var qrsDecoder: QRSDecoder? = null - private val showingError = AtomicBoolean(false) - private val qrsLock = Any() - - private val vendorAnalyzer: ImageAnalysis.Analyzer? = Vendor.createQRCodeAnalyzer( - onSuccess = { rawValue -> handleScanSuccess(rawValue) }, - onFailure = { exception -> handleScanFailure(exception) }, - onCropArea = ::updateCropArea, - ) - - init { - _uiState.update { - it.copy( - vendorAnalyzerAvailable = vendorAnalyzer != null, - useVendorAnalyzer = vendorAnalyzer != null, - ) - } - } - - private val onSuccess: (String) -> Unit = { rawValue: String -> - handleScanSuccess(rawValue) - } - - private val onFailure: (Exception) -> Unit = { exception -> - handleScanFailure(exception) - } - - private fun updateCropArea(area: QRCodeCropArea?) { - _uiState.update { state -> - if (state.cropArea == area) { - state - } else { - state.copy(cropArea = area) - } - } - } - - private fun handleScanSuccess(rawValue: String) { - Log.d(TAG, "Scanned: ${rawValue.take(100)}...") - val qrsPayload = extractQRSPayload(rawValue) - Log.d(TAG, "extractQRSPayload result: ${qrsPayload?.size ?: "null"}") - if (qrsPayload != null) { - handleQRSFrame(qrsPayload) - } else { - updateCropArea(null) - if (_uiState.value.qrsMode) { - resetQRSState() - } - imageAnalysis?.clearAnalyzer() - processQRCode(rawValue) - } - } - - private fun handleScanFailure(exception: Exception) { - if (_uiState.value.qrsMode) { - return - } - updateCropArea(null) - imageAnalysis?.clearAnalyzer() - if (showingError.compareAndSet(false, true)) { - resetAnalyzer() - _uiState.update { it.copy(errorMessage = exception.message) } - } - } - - private fun resetAnalyzer() { - if (_uiState.value.useVendorAnalyzer && vendorAnalyzer != null) { - _uiState.update { it.copy(useVendorAnalyzer = false) } - imageAnalysis?.clearAnalyzer() - imageAnalyzer = ZxingQRCodeAnalyzer(onSuccess, onFailure, onCropArea = ::updateCropArea) - imageAnalysis?.setAnalyzer(analysisExecutor, imageAnalyzer!!) - } - } - - fun startCamera(lifecycleOwner: LifecycleOwner, previewView: PreviewView) { - val context = getApplication() - val cameraProviderFuture = try { - ProcessCameraProvider.getInstance(context) - } catch (e: Exception) { - _uiState.update { it.copy(errorMessage = e.message, isLoading = false) } - return - } - - cameraProviderFuture.addListener({ - try { - cameraProvider = cameraProviderFuture.get() - - cameraPreview = Preview.Builder().build().also { - it.setSurfaceProvider(previewView.surfaceProvider) - } - - imageAnalysis = ImageAnalysis.Builder() - .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) - .build() - - imageAnalyzer = if (_uiState.value.useVendorAnalyzer && vendorAnalyzer != null) { - vendorAnalyzer - } else { - ZxingQRCodeAnalyzer(onSuccess, onFailure, onCropArea = ::updateCropArea) - } - imageAnalysis?.setAnalyzer(analysisExecutor, imageAnalyzer!!) - - bindCamera(lifecycleOwner) - } catch (e: Exception) { - _uiState.update { it.copy(errorMessage = e.message, isLoading = false) } - } - }, ContextCompat.getMainExecutor(context)) - } - - private fun bindCamera(lifecycleOwner: LifecycleOwner) { - val provider = cameraProvider ?: return - val preview = cameraPreview ?: return - val analysis = imageAnalysis ?: return - - provider.unbindAll() - - val cameraSelector = if (_uiState.value.useFrontCamera) { - CameraSelector.DEFAULT_FRONT_CAMERA - } else { - CameraSelector.DEFAULT_BACK_CAMERA - } - - try { - camera = provider.bindToLifecycle( - lifecycleOwner, - cameraSelector, - preview, - analysis, - ) - val maxZoom = camera?.cameraInfo?.zoomState?.value?.maxZoomRatio ?: 1f - _uiState.update { it.copy(maxZoomRatio = maxZoom, zoomRatio = 1f) } - } catch (e: Exception) { - _uiState.update { it.copy(errorMessage = e.message, isLoading = false) } - } - } - - fun onPreviewStreamStateChanged(isStreaming: Boolean) { - if (isStreaming) { - _uiState.update { it.copy(isLoading = false) } - } - } - - fun toggleFrontCamera(lifecycleOwner: LifecycleOwner) { - _uiState.update { it.copy(useFrontCamera = !it.useFrontCamera) } - bindCamera(lifecycleOwner) - } - - fun toggleTorch() { - val newTorchState = !_uiState.value.torchEnabled - camera?.cameraControl?.enableTorch(newTorchState) - _uiState.update { it.copy(torchEnabled = newTorchState) } - } - - fun setZoomRatio(ratio: Float) { - val clampedRatio = ratio.coerceIn(1f, _uiState.value.maxZoomRatio) - camera?.cameraControl?.setZoomRatio(clampedRatio) - _uiState.update { it.copy(zoomRatio = clampedRatio) } - } - - fun toggleVendorAnalyzer() { - if (vendorAnalyzer == null) return - - val newState = !_uiState.value.useVendorAnalyzer - _uiState.update { it.copy(useVendorAnalyzer = newState) } - updateCropArea(null) - - imageAnalysis?.clearAnalyzer() - imageAnalyzer = if (newState) { - vendorAnalyzer - } else { - ZxingQRCodeAnalyzer(onSuccess, onFailure, onCropArea = ::updateCropArea) - } - imageAnalysis?.setAnalyzer(analysisExecutor, imageAnalyzer!!) - } - - fun dismissError() { - showingError.set(false) - _uiState.update { it.copy(errorMessage = null) } - imageAnalysis?.setAnalyzer(analysisExecutor, imageAnalyzer!!) - } - - fun clearResult() { - resetQRSState() - _uiState.update { it.copy(result = null, cropArea = null) } - } - - private fun extractQRSPayload(content: String): ByteArray? { - val base64Data = when { - content.startsWith("http") && content.contains("#") -> { - content.substring(content.indexOf('#') + 1) - } - else -> content - } - - val decoded = try { - Base64.decode(base64Data, Base64.DEFAULT) - } catch (e: Exception) { - Log.d(TAG, "Base64 decode failed: ${e.message}") - return null - } - - Log.d(TAG, "Decoded size: ${decoded.size}") - if (decoded.size < 20) { - Log.d(TAG, "Too small: ${decoded.size} < 20") - return null - } - - val degree = decoded.readIntLE(0) - Log.d(TAG, "degree: $degree") - if (degree <= 0 || degree > 1000) { - Log.d(TAG, "Invalid degree: $degree") - return null - } - - val headerSize = 4 + 4 * degree + 12 - if (decoded.size < headerSize) { - Log.d(TAG, "Too small for header: ${decoded.size} < $headerSize") - return null - } - - val k = decoded.readIntLE(4 + 4 * degree) - Log.d(TAG, "k: $k") - if (k <= 0 || k > 100000) { - Log.d(TAG, "Invalid k: $k") - return null - } - - Log.d(TAG, "Valid QRS block detected!") - return decoded - } - - private fun handleQRSFrame(payload: ByteArray) = synchronized(qrsLock) { - Log.d(TAG, "Processing QRS frame") - if (qrsDecoder == null) { - qrsDecoder = QRSDecoder() - _uiState.update { it.copy(qrsMode = true) } - (imageAnalyzer as? ZxingQRCodeAnalyzer)?.qrsMode = true - Log.d(TAG, "Created new QRSDecoder, entered QRS mode") - } - - val progress = qrsDecoder!!.processFrame(payload) - Log.d(TAG, "processFrame result: $progress") - if (progress == null) { - Log.d(TAG, "processFrame returned null!") - return@synchronized - } - - _uiState.update { - it.copy(qrsProgress = Pair(progress.decodedBlocks, progress.totalBlocks)) - } - - if (progress.isComplete) { - if (progress.error != null) { - Log.e(TAG, "QRS complete with error: ${progress.error}, retrying...") - resetQRSState() - } else if (progress.data != null) { - imageAnalysis?.clearAnalyzer() - Log.d(TAG, "QRS complete! Data size: ${progress.data.size}") - importQRSProfile(progress.data) - } - } - } - - fun resetQRSState() = synchronized(qrsLock) { - qrsDecoder?.reset() - qrsDecoder = null - (imageAnalyzer as? ZxingQRCodeAnalyzer)?.qrsMode = false - _uiState.update { it.copy(qrsMode = false, qrsProgress = null) } - } - - private fun parseQRSFileFormat(data: ByteArray): ByteArray { - var offset = 0 - - val metaLength = ((data[offset].toInt() and 0xFF) shl 24) or - ((data[offset + 1].toInt() and 0xFF) shl 16) or - ((data[offset + 2].toInt() and 0xFF) shl 8) or - (data[offset + 3].toInt() and 0xFF) - offset += 4 - - offset += metaLength - - val dataLength = ((data[offset].toInt() and 0xFF) shl 24) or - ((data[offset + 1].toInt() and 0xFF) shl 16) or - ((data[offset + 2].toInt() and 0xFF) shl 8) or - (data[offset + 3].toInt() and 0xFF) - offset += 4 - - return data.copyOfRange(offset, offset + dataLength) - } - - private fun importQRSProfile(data: ByteArray) { - try { - val actualData = try { - parseQRSFileFormat(data) - } catch (e: Exception) { - Log.d(TAG, "Not official QRS format, using raw data") - data - } - Log.d(TAG, "Decoding profile content, size: ${actualData.size}") - Libbox.decodeProfileContent(actualData) - _uiState.update { it.copy(result = QRScanResult.QRSData(actualData)) } - } catch (e: Exception) { - _uiState.update { it.copy(errorMessage = e.message) } - resetQRSState() - imageAnalysis?.setAnalyzer(analysisExecutor, imageAnalyzer!!) - } - } - - private fun processQRCode(value: String): Boolean { - try { - val uri = Uri.parse(value) - if (uri.scheme != "sing-box" || uri.host != "import-remote-profile") { - _uiState.update { it.copy(errorMessage = "Not a valid sing-box remote profile URI") } - imageAnalysis?.setAnalyzer(analysisExecutor, imageAnalyzer!!) - return false - } - Libbox.parseRemoteProfileImportLink(uri.toString()) - _uiState.update { it.copy(result = QRScanResult.RemoteProfile(uri)) } - return true - } catch (e: Exception) { - if (showingError.compareAndSet(false, true)) { - _uiState.update { it.copy(errorMessage = e.message) } - } - } - return false - } - - override fun onCleared() { - super.onCleared() - analysisExecutor.shutdown() - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/qrscan/ZxingQRCodeAnalyzer.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/qrscan/ZxingQRCodeAnalyzer.kt deleted file mode 100644 index f79eb9cf98..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/qrscan/ZxingQRCodeAnalyzer.kt +++ /dev/null @@ -1,118 +0,0 @@ -package io.nekohasekai.sfa.compose.screen.qrscan - -import androidx.camera.core.ImageAnalysis -import androidx.camera.core.ImageProxy -import com.google.zxing.BinaryBitmap -import com.google.zxing.ChecksumException -import com.google.zxing.FormatException -import com.google.zxing.NotFoundException -import com.google.zxing.PlanarYUVLuminanceSource -import com.google.zxing.Result -import com.google.zxing.common.GlobalHistogramBinarizer -import com.google.zxing.common.HybridBinarizer -import com.google.zxing.qrcode.QRCodeReader - -class ZxingQRCodeAnalyzer( - private val onSuccess: ((String) -> Unit), - private val onFailure: ((Exception) -> Unit), - private val onCropArea: ((QRCodeCropArea?) -> Unit)? = null, -) : ImageAnalysis.Analyzer { - private val qrCodeReader = QRCodeReader() - private var yDataBuffer: ByteArray? = null - - var qrsMode: Boolean = false - - override fun analyze(image: ImageProxy) { - try { - val yData = image.toYUVData() - val width = image.width - val height = image.height - val rotationDegrees = image.imageInfo.rotationDegrees - val source = PlanarYUVLuminanceSource(yData, width, height, 0, 0, width, height, false) - - // Fast path: HybridBinarizer - tryDecode(BinaryBitmap(HybridBinarizer(source)))?.let { - onSuccess(it.text) - return - } - - val cropArea = QRCodeSmartCrop.findCropArea(yData, width, height, rotationDegrees) - onCropArea?.invoke(cropArea) - if (cropArea != null) { - val cropWidth = cropArea.right - cropArea.left - val cropHeight = cropArea.bottom - cropArea.top - val smartSource = PlanarYUVLuminanceSource( - yData, - width, - height, - cropArea.left, - cropArea.top, - cropWidth, - cropHeight, - false, - ) - tryDecode(BinaryBitmap(HybridBinarizer(smartSource)))?.let { - onSuccess(it.text) - return - } - } - - // In QRS mode, skip additional binarizer attempts for performance - if (qrsMode) return - - // Inverted HybridBinarizer (uses ZXing's native invert) - tryDecode(BinaryBitmap(HybridBinarizer(source.invert())))?.let { - onSuccess(it.text) - return - } - - // GlobalHistogramBinarizer (normal) - tryDecode(BinaryBitmap(GlobalHistogramBinarizer(source)))?.let { - onSuccess(it.text) - return - } - - // GlobalHistogramBinarizer (inverted) - tryDecode(BinaryBitmap(GlobalHistogramBinarizer(source.invert())))?.let { - onSuccess(it.text) - return - } - } catch (e: NotFoundException) { - // No QR code found in frame, ignore - } catch (e: ChecksumException) { - // Checksum error, ignore - } catch (e: FormatException) { - // Format error, ignore - } catch (e: Exception) { - onFailure(e) - } finally { - qrCodeReader.reset() - image.close() - } - } - - private fun ImageProxy.toYUVData(): ByteArray { - val yPlane = planes[0] - val yBuffer = yPlane.buffer - val rowStride = yPlane.rowStride - val size = width * height - - val yData = yDataBuffer?.takeIf { it.size >= size } ?: ByteArray(size).also { yDataBuffer = it } - if (rowStride == width) { - yBuffer.get(yData, 0, size) - } else { - for (row in 0 until height) { - yBuffer.position(row * rowStride) - yBuffer.get(yData, row * width, width) - } - } - return yData - } - - private fun tryDecode(bitmap: BinaryBitmap): Result? = try { - qrCodeReader.decode(bitmap) - } catch (_: NotFoundException) { - qrCodeReader.reset() - null - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/AppSettingsScreen.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/AppSettingsScreen.kt deleted file mode 100644 index 8aa6d21868..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/AppSettingsScreen.kt +++ /dev/null @@ -1,1194 +0,0 @@ -package io.nekohasekai.sfa.compose.screen.settings - -import android.app.LocaleConfig -import android.app.NotificationChannel -import android.app.NotificationManager -import android.content.Context -import android.content.Intent -import android.net.Uri -import android.os.Build -import android.util.Log -import androidx.appcompat.app.AppCompatDelegate -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -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.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.outlined.AdminPanelSettings -import androidx.compose.material.icons.outlined.Autorenew -import androidx.compose.material.icons.outlined.Download -import androidx.compose.material.icons.outlined.Info -import androidx.compose.material.icons.outlined.Language -import androidx.compose.material.icons.outlined.NewReleases -import androidx.compose.material.icons.outlined.Notifications -import androidx.compose.material.icons.outlined.Refresh -import androidx.compose.material.icons.outlined.Settings -import androidx.compose.material.icons.outlined.Speed -import androidx.compose.material.icons.outlined.SystemUpdateAlt -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Badge -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -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.RadioButton -import androidx.compose.material3.Switch -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar -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.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.core.os.LocaleListCompat -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.compose.LifecycleEventEffect -import androidx.navigation.NavController -import io.nekohasekai.sfa.Application -import io.nekohasekai.sfa.BuildConfig -import io.nekohasekai.sfa.R -import io.nekohasekai.sfa.compose.component.UpdateAvailableDialog -import io.nekohasekai.sfa.compose.topbar.OverrideTopBar -import io.nekohasekai.sfa.database.Settings -import io.nekohasekai.sfa.update.UpdateCheckException -import io.nekohasekai.sfa.update.UpdateState -import io.nekohasekai.sfa.update.UpdateTrack -import io.nekohasekai.sfa.utils.HookStatusClient -import io.nekohasekai.sfa.vendor.Vendor -import io.nekohasekai.sfa.xposed.XposedActivation -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import org.xmlpull.v1.XmlPullParser -import java.util.Locale -import android.provider.Settings as AndroidSettings - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun AppSettingsScreen(navController: NavController) { - OverrideTopBar { - TopAppBar( - title = { Text(stringResource(R.string.title_app_settings)) }, - navigationIcon = { - IconButton(onClick = { navController.navigateUp() }) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.content_description_back), - ) - } - }, - ) - } - - val context = LocalContext.current - val scope = rememberCoroutineScope() - val hasUpdate by UpdateState.hasUpdate - val updateInfo by UpdateState.updateInfo - val isChecking by UpdateState.isChecking - var showTrackDialog by remember { mutableStateOf(false) } - var currentTrack by remember { mutableStateOf(Settings.updateTrack) } - var checkUpdateEnabled by remember { mutableStateOf(Settings.checkUpdateEnabled) } - var showErrorDialog by remember { mutableStateOf(null) } - - var silentInstallEnabled by remember { mutableStateOf(Settings.silentInstallEnabled) } - var silentInstallMethod by remember { mutableStateOf(Settings.silentInstallMethod) } - val systemHookStatus by HookStatusClient.status.collectAsState() - val xposedActivated = systemHookStatus?.active == true || XposedActivation.isActivated(context) - var isMethodAvailable by remember { mutableStateOf(true) } - var autoUpdateEnabled by remember { mutableStateOf(Settings.autoUpdateEnabled) } - var showInstallMethodMenu by remember { mutableStateOf(false) } - var isVerifyingMethod by remember { mutableStateOf(false) } - var silentInstallError by remember { mutableStateOf(null) } - - var showDownloadDialog by remember { mutableStateOf(false) } - var downloadJob by remember { mutableStateOf(null) } - var downloadError by remember { mutableStateOf(null) } - var showUpdateAvailableDialog by remember { mutableStateOf(false) } - - var notificationEnabled by remember { mutableStateOf(true) } - var dynamicNotification by remember { mutableStateOf(Settings.dynamicNotification) } - var showDisableNotificationDialog by remember { mutableStateOf(false) } - - var showLanguageDialog by remember { mutableStateOf(false) } - val availableLocales = remember { getSupportedLocales(context) } - var currentLocaleTag by remember { - val appLocales = AppCompatDelegate.getApplicationLocales() - mutableStateOf(if (appLocales.isEmpty) "" else appLocales.toLanguageTags()) - } - - LaunchedEffect(Unit) { - HookStatusClient.refresh() - } - - // Re-check states when returning from background (e.g., after granting permission) - LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { - HookStatusClient.refresh() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - Application.notification.createNotificationChannel( - NotificationChannel( - "service", - "Service Notifications", - NotificationManager.IMPORTANCE_LOW, - ), - ) - val channel = Application.notification.getNotificationChannel("service") - notificationEnabled = channel?.importance != NotificationManager.IMPORTANCE_NONE - } else { - notificationEnabled = Application.notification.areNotificationsEnabled() - } - if (silentInstallEnabled) { - scope.launch { - val success = withContext(Dispatchers.IO) { - Vendor.verifySilentInstallMethod(silentInstallMethod) - } - isMethodAvailable = success - silentInstallError = if (success) { - null - } else { - when (silentInstallMethod) { - "PACKAGE_INSTALLER" -> context.getString(R.string.package_installer_not_available) - "SHIZUKU" -> context.getString(R.string.shizuku_not_available) - else -> context.getString(R.string.silent_install_verify_failed, silentInstallMethod) - } - } - } - } - } - - if (showTrackDialog) { - UpdateTrackDialog( - currentTrack = currentTrack, - onTrackSelected = { track -> - currentTrack = track - UpdateState.clear() - scope.launch(Dispatchers.IO) { - Settings.updateTrack = track - } - showTrackDialog = false - }, - onDismiss = { showTrackDialog = false }, - ) - } - - showErrorDialog?.let { messageRes -> - AlertDialog( - onDismissRequest = { showErrorDialog = null }, - title = { Text(stringResource(R.string.check_update)) }, - text = { Text(stringResource(messageRes)) }, - confirmButton = { - TextButton(onClick = { showErrorDialog = null }) { - Text(stringResource(R.string.ok)) - } - }, - ) - } - - if (showDownloadDialog) { - AlertDialog( - onDismissRequest = {}, - title = { Text(stringResource(R.string.update)) }, - text = { - Column { - if (downloadError != null) { - Text( - downloadError!!, - color = MaterialTheme.colorScheme.error, - ) - } else { - Row(verticalAlignment = Alignment.CenterVertically) { - CircularProgressIndicator(modifier = Modifier.size(24.dp)) - Spacer(modifier = Modifier.width(12.dp)) - Text(stringResource(R.string.downloading)) - } - } - } - }, - confirmButton = { - TextButton( - onClick = { - downloadJob?.cancel() - downloadJob = null - showDownloadDialog = false - downloadError = null - }, - ) { - Text(stringResource(if (downloadError != null) R.string.ok else android.R.string.cancel)) - } - }, - ) - } - - if (showInstallMethodMenu) { - InstallMethodDialog( - currentMethod = silentInstallMethod, - onMethodSelected = { method -> - showInstallMethodMenu = false - if (silentInstallMethod == method) return@InstallMethodDialog - silentInstallMethod = method - Settings.silentInstallMethod = method - isVerifyingMethod = true - scope.launch { - val success = withContext(Dispatchers.IO) { - Vendor.verifySilentInstallMethod(method) - } - isVerifyingMethod = false - isMethodAvailable = success - silentInstallError = if (success) { - null - } else { - when (method) { - "PACKAGE_INSTALLER" -> context.getString(R.string.package_installer_not_available) - "SHIZUKU" -> context.getString(R.string.shizuku_not_available) - else -> context.getString(R.string.silent_install_verify_failed, method) - } - } - } - }, - onDismiss = { showInstallMethodMenu = false }, - ) - } - - if (showDisableNotificationDialog) { - AlertDialog( - onDismissRequest = { showDisableNotificationDialog = false }, - title = { Text(stringResource(R.string.enable_notification)) }, - text = { - Text( - stringResource( - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - R.string.disable_notification_description - } else { - R.string.disable_notification_description_legacy - }, - ), - ) - }, - confirmButton = { - TextButton(onClick = { - showDisableNotificationDialog = false - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - context.startActivity( - Intent(AndroidSettings.ACTION_CHANNEL_NOTIFICATION_SETTINGS).apply { - putExtra(AndroidSettings.EXTRA_APP_PACKAGE, context.packageName) - putExtra(AndroidSettings.EXTRA_CHANNEL_ID, "service") - }, - ) - } else { - context.startActivity( - Intent( - AndroidSettings.ACTION_APPLICATION_DETAILS_SETTINGS, - Uri.parse("package:${context.packageName}"), - ), - ) - } - }) { - Text(stringResource(R.string.ok)) - } - }, - dismissButton = { - TextButton(onClick = { showDisableNotificationDialog = false }) { - Text(stringResource(android.R.string.cancel)) - } - }, - ) - } - - if (showUpdateAvailableDialog && updateInfo != null) { - UpdateAvailableDialog( - updateInfo = updateInfo!!, - onDismiss = { showUpdateAvailableDialog = false }, - onUpdate = { - showDownloadDialog = true - downloadError = null - downloadJob = scope.launch { - try { - withContext(Dispatchers.IO) { - Vendor.downloadAndInstall(context, updateInfo!!.downloadUrl) - } - showDownloadDialog = false - } catch (e: Exception) { - Log.e("AppSettingsScreen", "Error downloading update", e) - downloadError = e.message - } - } - }, - ) - } - - if (showLanguageDialog) { - LanguageDialog( - currentTag = currentLocaleTag, - availableLocales = availableLocales, - onLocaleSelected = { tag -> - currentLocaleTag = tag - val localeList = if (tag.isEmpty()) { - LocaleListCompat.getEmptyLocaleList() - } else { - LocaleListCompat.forLanguageTags(tag) - } - AppCompatDelegate.setApplicationLocales(localeList) - showLanguageDialog = false - }, - onDismiss = { showLanguageDialog = false }, - ) - } - - Column( - modifier = - Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.surface) - .verticalScroll(rememberScrollState()) - .padding(vertical = 8.dp), - ) { - // Info Card - Card( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - ), - ) { - Column { - ListItem( - headlineContent = { - Text( - stringResource(R.string.app_version_title), - style = MaterialTheme.typography.bodyLarge, - ) - }, - supportingContent = { - Text( - BuildConfig.VERSION_NAME, - style = MaterialTheme.typography.bodyMedium, - ) - }, - leadingContent = { - Icon( - imageVector = Icons.Outlined.Info, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - }, - trailingContent = { - if (hasUpdate) { - Badge(containerColor = MaterialTheme.colorScheme.primary) { Text("New") } - } - }, - modifier = - Modifier - .clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)), - colors = - ListItemDefaults.colors( - containerColor = Color.Transparent, - ), - ) - - ListItem( - headlineContent = { - Text( - stringResource(R.string.language), - style = MaterialTheme.typography.bodyLarge, - ) - }, - supportingContent = { - val displayName = if (currentLocaleTag.isEmpty()) { - stringResource(R.string.system_default) - } else { - val locale = Locale.forLanguageTag(currentLocaleTag) - locale.getDisplayName(locale).replaceFirstChar { it.uppercase(locale) } - } - Text(displayName, style = MaterialTheme.typography.bodyMedium) - }, - leadingContent = { - Icon( - imageVector = Icons.Outlined.Language, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - }, - modifier = - Modifier - .clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)) - .clickable { showLanguageDialog = true }, - colors = - ListItemDefaults.colors( - containerColor = Color.Transparent, - ), - ) - } - } - - Spacer(modifier = Modifier.height(8.dp)) - - Text( - text = stringResource(R.string.notification_settings), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp), - ) - - Card( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - ), - ) { - Column { - ListItem( - headlineContent = { - Text( - stringResource(R.string.enable_notification), - style = MaterialTheme.typography.bodyLarge, - ) - }, - leadingContent = { - Icon( - imageVector = Icons.Outlined.Notifications, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - }, - trailingContent = { - Switch( - checked = notificationEnabled, - onCheckedChange = null, - ) - }, - modifier = - Modifier - .clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)) - .clickable { showDisableNotificationDialog = true }, - colors = - ListItemDefaults.colors( - containerColor = Color.Transparent, - ), - ) - ListItem( - headlineContent = { - Text( - stringResource(R.string.dynamic_notification), - style = MaterialTheme.typography.bodyLarge, - ) - }, - leadingContent = { - Icon( - imageVector = Icons.Outlined.Speed, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - }, - trailingContent = { - Switch( - checked = dynamicNotification, - onCheckedChange = { checked -> - dynamicNotification = checked - scope.launch(Dispatchers.IO) { - Settings.dynamicNotification = checked - } - }, - ) - }, - modifier = - Modifier - .clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)), - colors = - ListItemDefaults.colors( - containerColor = Color.Transparent, - ), - ) - } - } - - Spacer(modifier = Modifier.height(8.dp)) - - Text( - text = stringResource(R.string.update_settings), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp), - ) - - Card( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - ), - ) { - Column { - val updateItemCount = - run { - var count = 0 - if (Vendor.supportsTrackSelection()) { - count += 1 - } - count += 1 - if (Vendor.supportsSilentInstall()) { - count += 1 - if (silentInstallEnabled) { - count += 1 - if (silentInstallMethod == "SHIZUKU" && !isMethodAvailable) { - count += 1 - } - if (silentInstallMethod == "PACKAGE_INSTALLER" && !isMethodAvailable) { - count += 1 - } - } - } - if (Vendor.supportsAutoUpdate()) { - count += 1 - } - count - } - - var updateItemIndex = 0 - fun updateItemModifier(): Modifier { - val index = updateItemIndex++ - return when { - updateItemCount == 1 -> Modifier.clip(RoundedCornerShape(12.dp)) - index == 0 -> Modifier.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)) - index == updateItemCount - 1 -> - Modifier.clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)) - else -> Modifier - } - } - - if (Vendor.supportsTrackSelection()) { - ListItem( - headlineContent = { - Text( - stringResource(R.string.update_track), - style = MaterialTheme.typography.bodyLarge, - ) - }, - supportingContent = { - val trackName = when (UpdateTrack.fromString(currentTrack)) { - UpdateTrack.STABLE -> stringResource(R.string.update_track_stable) - UpdateTrack.BETA -> stringResource(R.string.update_track_beta) - } - Text(trackName, style = MaterialTheme.typography.bodyMedium) - }, - leadingContent = { - Icon( - imageVector = Icons.Outlined.NewReleases, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - }, - modifier = - updateItemModifier() - .clickable { showTrackDialog = true }, - colors = - ListItemDefaults.colors( - containerColor = Color.Transparent, - ), - ) - } - - ListItem( - headlineContent = { - Text( - stringResource(R.string.check_update_automatic), - style = MaterialTheme.typography.bodyLarge, - ) - }, - leadingContent = { - Icon( - imageVector = Icons.Outlined.Autorenew, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - }, - trailingContent = { - Switch( - checked = checkUpdateEnabled, - onCheckedChange = { checked -> - checkUpdateEnabled = checked - scope.launch(Dispatchers.IO) { - Settings.checkUpdateEnabled = checked - } - }, - ) - }, - modifier = updateItemModifier(), - colors = - ListItemDefaults.colors( - containerColor = Color.Transparent, - ), - ) - - if (Vendor.supportsSilentInstall()) { - ListItem( - headlineContent = { - Text( - stringResource(R.string.silent_install), - style = MaterialTheme.typography.bodyLarge, - ) - }, - supportingContent = { - Text( - silentInstallError ?: stringResource(R.string.silent_install_description), - style = MaterialTheme.typography.bodySmall, - color = if (silentInstallError != null) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, - ) - }, - leadingContent = { - Icon( - imageVector = Icons.Outlined.AdminPanelSettings, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - }, - trailingContent = { - if (isVerifyingMethod) { - CircularProgressIndicator( - modifier = Modifier.size(24.dp), - strokeWidth = 2.dp, - ) - } else { - Switch( - checked = silentInstallEnabled, - onCheckedChange = { checked -> - silentInstallEnabled = checked - Settings.silentInstallEnabled = checked - if (checked) { - isVerifyingMethod = true - scope.launch { - val success = withContext(Dispatchers.IO) { - Vendor.verifySilentInstallMethod(silentInstallMethod) - } - isVerifyingMethod = false - isMethodAvailable = success - silentInstallError = if (success) { - null - } else { - when (silentInstallMethod) { - "PACKAGE_INSTALLER" -> context.getString(R.string.package_installer_not_available) - "SHIZUKU" -> context.getString(R.string.shizuku_not_available) - else -> context.getString(R.string.silent_install_verify_failed, silentInstallMethod) - } - } - } - } else { - silentInstallError = null - } - }, - ) - } - }, - modifier = updateItemModifier(), - colors = - ListItemDefaults.colors( - containerColor = Color.Transparent, - ), - ) - - if (silentInstallEnabled) { - ListItem( - headlineContent = { - Text( - stringResource(R.string.silent_install_method), - style = MaterialTheme.typography.bodyLarge, - ) - }, - supportingContent = { - Text( - if (xposedActivated) { - stringResource(R.string.install_method_root) - } else { - when (silentInstallMethod) { - "PACKAGE_INSTALLER" -> stringResource(R.string.install_method_package_installer) - "SHIZUKU" -> stringResource(R.string.install_method_shizuku) - "ROOT" -> stringResource(R.string.install_method_root) - else -> silentInstallMethod - } - }, - style = MaterialTheme.typography.bodyMedium, - ) - }, - leadingContent = { - Icon( - imageVector = Icons.Outlined.Settings, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - }, - modifier = - updateItemModifier() - .let { if (!xposedActivated) it.clickable { showInstallMethodMenu = true } else it }, - colors = - ListItemDefaults.colors( - containerColor = Color.Transparent, - ), - ) - - if (silentInstallMethod == "SHIZUKU" && !isMethodAvailable) { - ListItem( - headlineContent = { - Text( - stringResource(R.string.get_shizuku), - style = MaterialTheme.typography.bodyLarge, - ) - }, - supportingContent = { - Text( - stringResource(R.string.shizuku_description), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - }, - leadingContent = { - Icon( - imageVector = Icons.Outlined.Download, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - }, - modifier = - updateItemModifier() - .clickable { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://shizuku.rikka.app/")) - context.startActivity(intent) - }, - colors = - ListItemDefaults.colors( - containerColor = Color.Transparent, - ), - ) - } - - if (silentInstallMethod == "PACKAGE_INSTALLER" && !isMethodAvailable) { - ListItem( - headlineContent = { - Text( - stringResource(R.string.grant_install_permission), - style = MaterialTheme.typography.bodyLarge, - ) - }, - supportingContent = { - Text( - stringResource(R.string.grant_install_permission_description), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - }, - leadingContent = { - Icon( - imageVector = Icons.Outlined.Settings, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - }, - modifier = - updateItemModifier() - .clickable { - val intent = Intent( - AndroidSettings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, - Uri.parse("package:${context.packageName}"), - ) - context.startActivity(intent) - }, - colors = - ListItemDefaults.colors( - containerColor = Color.Transparent, - ), - ) - } - } - } - - if (Vendor.supportsAutoUpdate()) { - ListItem( - headlineContent = { - Text( - stringResource(R.string.auto_update), - style = MaterialTheme.typography.bodyLarge, - ) - }, - supportingContent = { - Text( - stringResource(R.string.auto_update_description), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - }, - leadingContent = { - Icon( - imageVector = Icons.Outlined.SystemUpdateAlt, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - }, - trailingContent = { - Switch( - checked = autoUpdateEnabled, - onCheckedChange = { checked -> - autoUpdateEnabled = checked - scope.launch(Dispatchers.IO) { - Settings.autoUpdateEnabled = checked - Vendor.scheduleAutoUpdate() - } - }, - ) - }, - modifier = updateItemModifier(), - colors = - ListItemDefaults.colors( - containerColor = Color.Transparent, - ), - ) - } - } - } - - Spacer(modifier = Modifier.height(8.dp)) - - // Action Section - Text( - text = stringResource(R.string.action), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp), - ) - - Card( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - ), - ) { - Column { - ListItem( - headlineContent = { - Text( - stringResource(R.string.check_update), - style = MaterialTheme.typography.bodyLarge, - ) - }, - leadingContent = { - Icon( - imageVector = Icons.Outlined.Refresh, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - }, - trailingContent = { - if (isChecking) { - CircularProgressIndicator( - modifier = Modifier.size(24.dp), - strokeWidth = 2.dp, - ) - } - }, - modifier = - Modifier - .clip( - if (hasUpdate) { - RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp) - } else { - RoundedCornerShape(12.dp) - }, - ) - .clickable(enabled = !isChecking) { - scope.launch { - UpdateState.isChecking.value = true - withContext(Dispatchers.IO) { - try { - val result = Vendor.checkUpdateAsync() - UpdateState.setUpdate(result) - if (result == null) { - showErrorDialog = R.string.no_updates_available - } else { - showUpdateAvailableDialog = true - } - } catch (_: UpdateCheckException.TrackNotSupported) { - UpdateState.setUpdate(null) - showErrorDialog = R.string.update_track_not_supported - } catch (_: Exception) { - UpdateState.setUpdate(null) - } - } - UpdateState.isChecking.value = false - } - }, - colors = - ListItemDefaults.colors( - containerColor = Color.Transparent, - ), - ) - - if (hasUpdate && updateInfo != null) { - ListItem( - headlineContent = { - Text( - stringResource(R.string.update), - style = MaterialTheme.typography.bodyLarge, - ) - }, - supportingContent = { - Text( - updateInfo!!.versionName, - style = MaterialTheme.typography.bodyMedium, - ) - }, - leadingContent = { - Icon( - imageVector = Icons.Outlined.Download, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - }, - modifier = - Modifier - .clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)) - .clickable { - showUpdateAvailableDialog = true - }, - colors = - ListItemDefaults.colors( - containerColor = Color.Transparent, - ), - ) - } - } - } - } -} - -@Composable -private fun UpdateTrackDialog( - currentTrack: String, - onTrackSelected: (String) -> Unit, - onDismiss: () -> Unit, -) { - val tracks = listOf( - "stable" to stringResource(R.string.update_track_stable), - "beta" to stringResource(R.string.update_track_beta), - ) - - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(stringResource(R.string.update_track)) }, - text = { - Column { - tracks.forEach { (value, label) -> - Row( - modifier = - Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) - .clickable { onTrackSelected(value) } - .padding(vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - RadioButton( - selected = currentTrack == value, - onClick = { onTrackSelected(value) }, - ) - Text( - text = label, - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.padding(start = 8.dp), - ) - } - } - } - }, - confirmButton = { - TextButton(onClick = onDismiss) { - Text(stringResource(android.R.string.cancel)) - } - }, - ) -} - -@Composable -private fun LanguageDialog( - currentTag: String, - availableLocales: List, - onLocaleSelected: (String) -> Unit, - onDismiss: () -> Unit, -) { - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(stringResource(R.string.language)) }, - text = { - Column { - Row( - modifier = - Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) - .clickable { onLocaleSelected("") } - .padding(vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - RadioButton( - selected = currentTag.isEmpty(), - onClick = { onLocaleSelected("") }, - ) - Text( - text = stringResource(R.string.system_default), - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.padding(start = 8.dp), - ) - } - availableLocales.forEach { locale -> - val tag = locale.toLanguageTag() - Row( - modifier = - Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) - .clickable { onLocaleSelected(tag) } - .padding(vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - RadioButton( - selected = currentTag == tag, - onClick = { onLocaleSelected(tag) }, - ) - Text( - text = locale.getDisplayName(locale).replaceFirstChar { it.uppercase(locale) }, - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.padding(start = 8.dp), - ) - } - } - } - }, - confirmButton = { - TextButton(onClick = onDismiss) { - Text(stringResource(android.R.string.cancel)) - } - }, - ) -} - -private fun getSupportedLocales(context: Context): List { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - val localeConfig = LocaleConfig(context) - val localeList = localeConfig.supportedLocales ?: return emptyList() - return (0 until localeList.size()).map { localeList.get(it) } - } - return parseLocalesConfig(context) -} - -private fun parseLocalesConfig(context: Context): List { - val locales = mutableListOf() - try { - val resId = context.resources.getIdentifier( - "_generated_res_locale_config", - "xml", - context.packageName, - ) - if (resId == 0) return emptyList() - val parser = context.resources.getXml(resId) - while (parser.next() != XmlPullParser.END_DOCUMENT) { - if (parser.eventType == XmlPullParser.START_TAG && parser.name == "locale") { - val name = parser.getAttributeValue( - "http://schemas.android.com/apk/res/android", - "name", - ) - if (name != null) { - locales.add(Locale.forLanguageTag(name)) - } - } - } - } catch (_: Exception) { - } - return locales -} - -@Composable -private fun InstallMethodDialog( - currentMethod: String, - onMethodSelected: (String) -> Unit, - onDismiss: () -> Unit, -) { - val methods = buildList { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - add("PACKAGE_INSTALLER" to stringResource(R.string.install_method_package_installer)) - } - add("SHIZUKU" to stringResource(R.string.install_method_shizuku)) - add("ROOT" to stringResource(R.string.install_method_root)) - } - - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(stringResource(R.string.silent_install_method)) }, - text = { - Column { - methods.forEach { (value, label) -> - Row( - modifier = - Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) - .clickable { onMethodSelected(value) } - .padding(vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - RadioButton( - selected = currentMethod == value, - onClick = { onMethodSelected(value) }, - ) - Text( - text = label, - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.padding(start = 8.dp), - ) - } - } - } - }, - confirmButton = { - TextButton(onClick = onDismiss) { - Text(stringResource(android.R.string.cancel)) - } - }, - ) -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/CoreSettingsScreen.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/CoreSettingsScreen.kt deleted file mode 100644 index c23400c32a..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/CoreSettingsScreen.kt +++ /dev/null @@ -1,349 +0,0 @@ -package io.nekohasekai.sfa.compose.screen.settings - -import android.content.ActivityNotFoundException -import android.content.Context -import android.content.Intent -import android.provider.DocumentsContract -import android.widget.Toast -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -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.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.outlined.DeleteForever -import androidx.compose.material.icons.outlined.FolderOpen -import androidx.compose.material.icons.outlined.Info -import androidx.compose.material.icons.outlined.Storage -import androidx.compose.material.icons.outlined.WarningAmber -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.ExperimentalMaterial3Api -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.Switch -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -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.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.navigation.NavController -import io.nekohasekai.libbox.Libbox -import io.nekohasekai.sfa.R -import io.nekohasekai.sfa.compose.topbar.OverrideTopBar -import io.nekohasekai.sfa.database.Settings -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun CoreSettingsScreen(navController: NavController) { - OverrideTopBar { - TopAppBar( - title = { Text(stringResource(R.string.core)) }, - navigationIcon = { - IconButton(onClick = { navController.navigateUp() }) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.content_description_back), - ) - } - }, - ) - } - - val context = LocalContext.current - val scope = rememberCoroutineScope() - var dataSize by remember { mutableStateOf("") } - val version = remember { Libbox.version() } - var disableDeprecatedWarnings by remember { mutableStateOf(Settings.disableDeprecatedWarnings) } - - // Calculate data size on launch - LaunchedEffect(Unit) { - withContext(Dispatchers.IO) { - val filesDir = context.getExternalFilesDir(null) ?: context.filesDir - val size = - filesDir.walkTopDown() - .filter { it.isFile } - .map { it.length() } - .sum() - val formattedSize = Libbox.formatBytes(size) - dataSize = formattedSize - } - } - - Column( - modifier = - Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.surface) - .verticalScroll(rememberScrollState()) - .padding(vertical = 8.dp), - ) { - // Core Information Card - Card( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - ), - ) { - Column { - // Version Info - ListItem( - headlineContent = { - Text( - stringResource(R.string.core_version_title), - style = MaterialTheme.typography.bodyLarge, - ) - }, - supportingContent = { - Text( - version, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 4.dp), - ) - }, - leadingContent = { - Icon( - imageVector = Icons.Outlined.Info, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - }, - modifier = Modifier.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)), - colors = - ListItemDefaults.colors( - containerColor = Color.Transparent, - ), - ) - - // Data Size - ListItem( - headlineContent = { - Text( - stringResource(R.string.core_data_size), - style = MaterialTheme.typography.bodyLarge, - ) - }, - supportingContent = { - Text( - dataSize.ifEmpty { stringResource(R.string.calculating) }, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 4.dp), - ) - }, - leadingContent = { - Icon( - imageVector = Icons.Outlined.Storage, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - }, - modifier = - Modifier.clip( - RoundedCornerShape( - bottomStart = 12.dp, - bottomEnd = 12.dp, - ), - ), - colors = - ListItemDefaults.colors( - containerColor = Color.Transparent, - ), - ) - } - } - - // Options Section - Spacer(modifier = Modifier.height(16.dp)) - - Text( - text = stringResource(R.string.options), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp), - ) - - Card( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - ), - ) { - ListItem( - headlineContent = { - Text( - stringResource(R.string.disable_deprecated_warnings), - style = MaterialTheme.typography.bodyLarge, - ) - }, - leadingContent = { - Icon( - imageVector = Icons.Outlined.WarningAmber, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - }, - trailingContent = { - Switch( - checked = disableDeprecatedWarnings, - onCheckedChange = { checked -> - disableDeprecatedWarnings = checked - scope.launch(Dispatchers.IO) { - Settings.disableDeprecatedWarnings = checked - } - }, - ) - }, - modifier = Modifier.clip(RoundedCornerShape(12.dp)), - colors = - ListItemDefaults.colors( - containerColor = Color.Transparent, - ), - ) - } - - // Working Directory Section - Spacer(modifier = Modifier.height(16.dp)) - Text( - text = stringResource(R.string.working_directory), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp), - ) - - // Working Directory Card - Card( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - ), - ) { - // Browse - ListItem( - headlineContent = { - Text( - stringResource(R.string.browse), - style = MaterialTheme.typography.bodyLarge, - ) - }, - leadingContent = { - Icon( - imageVector = Icons.Outlined.FolderOpen, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - }, - modifier = - Modifier - .clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)) - .clickable { - openInFileManager(context) - }, - colors = - ListItemDefaults.colors( - containerColor = Color.Transparent, - ), - ) - - // Destroy - ListItem( - headlineContent = { - Text( - stringResource(R.string.destroy), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.error, - ) - }, - leadingContent = { - Icon( - imageVector = Icons.Outlined.DeleteForever, - contentDescription = null, - tint = MaterialTheme.colorScheme.error, - ) - }, - modifier = - Modifier - .clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)) - .clickable { - scope.launch(Dispatchers.IO) { - val filesDir = context.getExternalFilesDir(null) ?: context.filesDir - filesDir.deleteRecursively() - filesDir.mkdirs() - - // Recalculate data size - val newSize = - filesDir.walkTopDown() - .filter { it.isFile } - .map { it.length() } - .sum() - val formattedSize = Libbox.formatBytes(newSize) - dataSize = formattedSize - } - }, - colors = - ListItemDefaults.colors( - containerColor = Color.Transparent, - ), - ) - } - - Spacer(modifier = Modifier.height(16.dp)) - } -} - -private fun openInFileManager(context: Context) { - val authority = "${context.packageName}.workingdir" - val rootUri = DocumentsContract.buildRootUri(authority, "working_directory") - - val intent = Intent(Intent.ACTION_VIEW).apply { - setDataAndType(rootUri, DocumentsContract.Document.MIME_TYPE_DIR) - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION) - } - - try { - context.startActivity(intent) - } catch (e: ActivityNotFoundException) { - Toast.makeText( - context, - context.getString(R.string.no_file_manager), - Toast.LENGTH_SHORT, - ).show() - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/PrivilegeSettingsScreen.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/PrivilegeSettingsScreen.kt deleted file mode 100644 index dbcf6bc42c..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/PrivilegeSettingsScreen.kt +++ /dev/null @@ -1,968 +0,0 @@ -package io.nekohasekai.sfa.compose.screen.settings - -import android.content.Intent -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.background -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.fillMaxSize -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.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.automirrored.outlined.KeyboardArrowRight -import androidx.compose.material.icons.filled.RestartAlt -import androidx.compose.material.icons.outlined.AppShortcut -import androidx.compose.material.icons.outlined.BugReport -import androidx.compose.material.icons.outlined.CheckBox -import androidx.compose.material.icons.outlined.Code -import androidx.compose.material.icons.outlined.FilterAlt -import androidx.compose.material.icons.outlined.ViewModule -import androidx.compose.material.icons.outlined.WarningAmber -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -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.Switch -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar -import androidx.compose.runtime.Composable -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.draw.alpha -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.core.content.FileProvider -import androidx.navigation.NavController -import io.nekohasekai.libbox.Libbox -import io.nekohasekai.sfa.R -import io.nekohasekai.sfa.compose.base.GlobalEventBus -import io.nekohasekai.sfa.compose.base.SelectableMessageDialog -import io.nekohasekai.sfa.compose.base.UiEvent -import io.nekohasekai.sfa.compose.topbar.OverrideTopBar -import io.nekohasekai.sfa.constant.Status -import io.nekohasekai.sfa.database.Settings -import io.nekohasekai.sfa.utils.DetectionResult -import io.nekohasekai.sfa.utils.HookModuleUpdateNotifier -import io.nekohasekai.sfa.utils.HookStatusClient -import io.nekohasekai.sfa.utils.PrivilegeSettingsClient -import io.nekohasekai.sfa.utils.VpnDetectionTest -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.io.File -import java.io.FileInputStream -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun PrivilegeSettingsScreen(navController: NavController, serviceStatus: Status = Status.Stopped) { - OverrideTopBar { - TopAppBar( - title = { Text(stringResource(R.string.privilege_settings)) }, - navigationIcon = { - IconButton(onClick = { navController.navigateUp() }) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.content_description_back), - ) - } - }, - ) - } - - val context = LocalContext.current - val scope = rememberCoroutineScope() - val systemHookStatus by HookStatusClient.status.collectAsState() - var privilegeSettingsEnabled by remember { mutableStateOf(Settings.privilegeSettingsEnabled) } - - var showTestDialog by remember { mutableStateOf(false) } - var testResult by remember { mutableStateOf(null) } - var isTestRunning by remember { mutableStateOf(false) } - var interfaceRenameEnabled by remember { mutableStateOf(Settings.privilegeSettingsInterfaceRenameEnabled) } - var interfacePrefix by remember { mutableStateOf(Settings.privilegeSettingsInterfacePrefix) } - var showInterfacePrefixDialog by remember { mutableStateOf(false) } - var interfacePrefixInput by remember { mutableStateOf(interfacePrefix) } - var showExportProgressDialog by remember { mutableStateOf(false) } - var exportCancelled by remember { mutableStateOf(false) } - var exportError by remember { mutableStateOf(null) } - var showExportSuccessDialog by remember { mutableStateOf(false) } - var exportedFile by remember { mutableStateOf(null) } - var showMessageDialog by remember { mutableStateOf(false) } - var messageDialogTitle by remember { mutableStateOf("") } - var messageDialogMessage by remember { mutableStateOf("") } - - val saveFileLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.CreateDocument("application/zip"), - ) { uri -> - val file = exportedFile - if (uri != null && file != null) { - scope.launch(Dispatchers.IO) { - try { - context.contentResolver.openOutputStream(uri)?.use { output -> - FileInputStream(file).use { input -> - input.copyTo(output) - } - } - } catch (e: Throwable) { - android.util.Log.e("PrivilegeSettings", "Failed to save file", e) - } - } - } - showExportSuccessDialog = false - exportedFile = null - } - - androidx.compose.runtime.LaunchedEffect(Unit) { - HookStatusClient.refresh() - } - - val hasPendingDowngrade = HookModuleUpdateNotifier.isDowngrade(systemHookStatus) - val hasPendingUpdate = HookModuleUpdateNotifier.isUpgrade(systemHookStatus) - val hasPendingChange = hasPendingDowngrade || hasPendingUpdate - androidx.compose.runtime.LaunchedEffect(systemHookStatus) { - HookModuleUpdateNotifier.maybeNotify(context, systemHookStatus) - } - - if (showTestDialog) { - SelfTestDialog( - isRunning = isTestRunning, - result = testResult, - onDismiss = { - showTestDialog = false - testResult = null - }, - ) - } - if (showInterfacePrefixDialog) { - AlertDialog( - onDismissRequest = { showInterfacePrefixDialog = false }, - title = { Text(stringResource(R.string.privilege_settings_interface_rename_title)) }, - text = { - OutlinedTextField( - value = interfacePrefixInput, - onValueChange = { interfacePrefixInput = it }, - singleLine = true, - label = { Text(stringResource(R.string.privilege_settings_interface_prefix)) }, - ) - }, - confirmButton = { - TextButton( - onClick = { - val trimmed = interfacePrefixInput.trim() - val filtered = buildString(trimmed.length) { - for (ch in trimmed) { - if (ch.isLetterOrDigit() || ch == '_') { - append(ch) - } - } - } - val normalized = if (filtered.isEmpty()) "en" else filtered - interfacePrefix = normalized - Settings.privilegeSettingsInterfacePrefix = normalized - showInterfacePrefixDialog = false - scope.launch { - val failure = - withContext(Dispatchers.IO) { - PrivilegeSettingsClient.sync() - } - if (failure != null) { - messageDialogTitle = context.getString(R.string.error_title) - messageDialogMessage = failure.message ?: failure.toString() - showMessageDialog = true - } else if (serviceStatus == Status.Started) { - GlobalEventBus.tryEmit(UiEvent.RestartToTakeEffect) - } - } - }, - ) { - Text(stringResource(R.string.save)) - } - }, - dismissButton = { - TextButton(onClick = { showInterfacePrefixDialog = false }) { - Text(stringResource(R.string.cancel)) - } - }, - ) - } - if (showMessageDialog) { - SelectableMessageDialog( - title = messageDialogTitle, - message = messageDialogMessage, - onDismiss = { showMessageDialog = false }, - ) - } - if (showExportProgressDialog) { - AlertDialog( - onDismissRequest = {}, - title = { Text(stringResource(R.string.privilege_settings_export_debug)) }, - text = { - Row(verticalAlignment = Alignment.CenterVertically) { - CircularProgressIndicator(modifier = Modifier.size(24.dp)) - Spacer(modifier = Modifier.width(12.dp)) - Text( - if (exportError != null) { - exportError!! - } else { - stringResource(R.string.exporting) - }, - ) - } - }, - confirmButton = { - TextButton( - onClick = { - if (exportError != null) { - showExportProgressDialog = false - exportError = null - } else { - exportCancelled = true - showExportProgressDialog = false - } - }, - ) { - Text(stringResource(if (exportError != null) R.string.ok else android.R.string.cancel)) - } - }, - ) - } - if (showExportSuccessDialog && exportedFile != null) { - AlertDialog( - onDismissRequest = { - showExportSuccessDialog = false - exportedFile = null - }, - title = { Text(stringResource(R.string.privilege_settings_export_debug_complete)) }, - text = { - val file = exportedFile - if (file != null) { - Text(stringResource(R.string.privilege_settings_export_debug_message, Libbox.formatBytes(file.length()))) - } - }, - confirmButton = { - TextButton( - onClick = { - val file = exportedFile ?: return@TextButton - val uri = FileProvider.getUriForFile( - context, - "${context.packageName}.cache", - file, - ) - val intent = Intent(Intent.ACTION_SEND).apply { - type = "application/zip" - putExtra(Intent.EXTRA_STREAM, uri) - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - } - context.startActivity(Intent.createChooser(intent, null)) - showExportSuccessDialog = false - exportedFile = null - }, - ) { - Text(stringResource(R.string.menu_share)) - } - }, - dismissButton = { - TextButton( - onClick = { - val file = exportedFile ?: return@TextButton - saveFileLauncher.launch(file.name) - }, - ) { - Text(stringResource(R.string.save)) - } - }, - ) - } - - Column( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.surface) - .verticalScroll(rememberScrollState()) - .padding(vertical = 8.dp), - ) { - val isLsposedActivated = systemHookStatus?.active == true - val showLogs = isLsposedActivated && !hasPendingChange - val showExportDebug = showLogs - val statusShape = - if (showLogs || hasPendingChange) { - RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp) - } else { - RoundedCornerShape(12.dp) - } - val logItemShape = - if (showExportDebug) { - RoundedCornerShape(0.dp) - } else { - RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp) - } - val statusLabel = - when { - hasPendingDowngrade -> stringResource(R.string.lsposed_module_pending_downgrade) - hasPendingUpdate -> stringResource(R.string.lsposed_module_pending_update) - isLsposedActivated -> stringResource(R.string.lsposed_module_activated) - else -> stringResource(R.string.lsposed_module_not_activated) - } - val statusIcon = - when { - hasPendingDowngrade -> Icons.Outlined.WarningAmber - hasPendingUpdate -> Icons.Outlined.WarningAmber - isLsposedActivated -> Icons.Outlined.CheckBox - else -> Icons.Outlined.WarningAmber - } - val statusIconTint = - when { - hasPendingDowngrade -> MaterialTheme.colorScheme.error - hasPendingUpdate -> Color(0xFFFFC107) - isLsposedActivated -> MaterialTheme.colorScheme.primary - else -> MaterialTheme.colorScheme.error - } - - Text( - text = stringResource(R.string.privilege_module_title), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(start = 32.dp, top = 16.dp, bottom = 8.dp), - ) - - Card( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - ), - ) { - Column( - modifier = Modifier.fillMaxWidth(), - ) { - ListItem( - headlineContent = { - Text( - statusLabel, - style = MaterialTheme.typography.bodyLarge, - ) - }, - supportingContent = null, - leadingContent = { - Icon( - imageVector = Icons.Outlined.Code, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - }, - trailingContent = { - Icon( - imageVector = statusIcon, - contentDescription = null, - tint = statusIconTint, - ) - }, - modifier = Modifier.clip(statusShape), - colors = ListItemDefaults.colors( - containerColor = Color.Transparent, - ), - ) - if (showLogs) { - ListItem( - headlineContent = { - Text( - stringResource(R.string.privilege_settings_view_logs), - style = MaterialTheme.typography.bodyLarge, - ) - }, - leadingContent = { - Icon( - imageVector = Icons.Outlined.ViewModule, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - }, - trailingContent = { - Icon( - imageVector = Icons.AutoMirrored.Outlined.KeyboardArrowRight, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - }, - modifier = - Modifier - .clip(logItemShape) - .clickable { - navController.navigate("settings/privilege/logs") - }, - colors = ListItemDefaults.colors( - containerColor = Color.Transparent, - ), - ) - } - if (showExportDebug) { - ListItem( - headlineContent = { - Text( - stringResource(R.string.privilege_settings_export_debug), - style = MaterialTheme.typography.bodyLarge, - ) - }, - leadingContent = { - Icon( - imageVector = Icons.Outlined.BugReport, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - }, - modifier = - Modifier - .clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)) - .clickable { - val exportBase = File(context.cacheDir, "debug") - if (!exportBase.exists()) { - exportBase.mkdirs() - } - val timestamp = - SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) - val outZip = File(exportBase, "sing-box-lsposed-debug-$timestamp.zip") - exportCancelled = false - exportError = null - showExportProgressDialog = true - scope.launch { - val result = withContext(Dispatchers.IO) { - PrivilegeSettingsClient.exportDebugInfo(outZip.absolutePath) - } - if (exportCancelled) { - outZip.delete() - return@launch - } - showExportProgressDialog = false - val failure = result.error - if (failure == null) { - exportedFile = outZip - showExportSuccessDialog = true - } else { - messageDialogTitle = context.getString(R.string.error_title) - messageDialogMessage = context.getString( - R.string.privilege_settings_export_debug_failed, - failure, - ) - showMessageDialog = true - } - } - }, - colors = ListItemDefaults.colors( - containerColor = Color.Transparent, - ), - ) - } - if (hasPendingChange) { - ListItem( - headlineContent = { - Text( - stringResource(R.string.privilege_module_restart_action), - style = MaterialTheme.typography.bodyLarge, - ) - }, - leadingContent = { - Icon( - imageVector = Icons.Default.RestartAlt, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - }, - modifier = - Modifier - .clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)) - .clickable { - scope.launch { - val failure = withContext(Dispatchers.IO) { - runCatching { - val process = Runtime.getRuntime().exec( - arrayOf( - "su", - "-c", - "/system/bin/svc power reboot || /system/bin/reboot", - ), - ) - val error = process.errorStream.bufferedReader().use { it.readText().trim() } - process.inputStream.close() - process.outputStream.close() - process.errorStream.close() - val code = process.waitFor() - if (code == 0) { - null - } else { - error.ifBlank { "exit=$code" } - } - }.getOrElse { it.message ?: "unknown" } - } - if (failure != null) { - val message = - if (failure == "unknown" || failure.startsWith("exit=")) { - context.getString(R.string.root_access_required) - } else { - context.getString(R.string.privilege_module_restart_failed, failure) - } - messageDialogTitle = context.getString(R.string.error_title) - messageDialogMessage = message - showMessageDialog = true - } - } - }, - colors = ListItemDefaults.colors( - containerColor = Color.Transparent, - ), - ) - } - } - } - - Text( - text = stringResource(R.string.privilege_settings_hide_title), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(start = 32.dp, top = 24.dp, bottom = 8.dp), - ) - - val privilegeControlsEnabled = isLsposedActivated && !hasPendingChange - val hasManageItem = privilegeSettingsEnabled - - Card( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - ), - ) { - Column( - modifier = Modifier.fillMaxWidth(), - ) { - val disabledAlpha = 0.38f - ListItem( - headlineContent = { - Text( - stringResource(R.string.enabled), - style = MaterialTheme.typography.bodyLarge, - ) - }, - supportingContent = { - Text( - stringResource(R.string.privilege_settings_hide_description), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - }, - leadingContent = { - Icon( - imageVector = Icons.Outlined.FilterAlt, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - }, - trailingContent = { - Switch( - checked = privilegeSettingsEnabled, - onCheckedChange = { checked -> - privilegeSettingsEnabled = checked - if (checked && !interfaceRenameEnabled) { - interfaceRenameEnabled = true - } - scope.launch { - val failure = - withContext(Dispatchers.IO) { - Settings.privilegeSettingsEnabled = checked - if (checked) { - Settings.privilegeSettingsInterfaceRenameEnabled = true - } - PrivilegeSettingsClient.sync() - } - if (failure != null) { - messageDialogTitle = context.getString(R.string.error_title) - messageDialogMessage = failure.message ?: failure.toString() - showMessageDialog = true - } else if (checked && serviceStatus == Status.Started) { - GlobalEventBus.tryEmit(UiEvent.RestartToTakeEffect) - } - } - }, - enabled = privilegeControlsEnabled, - ) - }, - modifier = Modifier - .alpha(if (privilegeControlsEnabled) 1f else disabledAlpha) - .clip( - if (hasManageItem) { - RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp) - } else { - RoundedCornerShape(12.dp) - }, - ), - colors = ListItemDefaults.colors( - containerColor = Color.Transparent, - ), - ) - - val manageEnabled = privilegeControlsEnabled && privilegeSettingsEnabled - if (hasManageItem) { - ListItem( - headlineContent = { - Text( - stringResource(R.string.privilege_settings_hide_manage), - style = MaterialTheme.typography.bodyLarge, - ) - }, - leadingContent = { - Icon( - imageVector = Icons.Outlined.AppShortcut, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - }, - trailingContent = { - Icon( - imageVector = Icons.AutoMirrored.Outlined.KeyboardArrowRight, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - }, - modifier = Modifier - .alpha(if (manageEnabled) 1f else disabledAlpha) - .clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)) - .clickable(enabled = manageEnabled) { - navController.navigate("settings/privilege/manage") - }, - colors = ListItemDefaults.colors( - containerColor = Color.Transparent, - ), - ) - } - } - } - - Text( - text = stringResource(R.string.privilege_settings_interface_rename_title), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(start = 32.dp, top = 24.dp, bottom = 8.dp), - ) - - Card( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - ), - ) { - Column( - modifier = Modifier.fillMaxWidth(), - ) { - val renameControlsEnabled = isLsposedActivated && !hasPendingChange - val disabledAlpha = 0.38f - ListItem( - headlineContent = { - Text( - stringResource(R.string.enabled), - style = MaterialTheme.typography.bodyLarge, - ) - }, - leadingContent = { - Icon( - imageVector = Icons.Outlined.FilterAlt, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - }, - trailingContent = { - Switch( - checked = interfaceRenameEnabled, - onCheckedChange = { checked -> - interfaceRenameEnabled = checked - scope.launch { - val failure = - withContext(Dispatchers.IO) { - Settings.privilegeSettingsInterfaceRenameEnabled = checked - PrivilegeSettingsClient.sync() - } - if (failure != null) { - messageDialogTitle = context.getString(R.string.error_title) - messageDialogMessage = failure.message ?: failure.toString() - showMessageDialog = true - } else if (serviceStatus == Status.Started) { - GlobalEventBus.tryEmit(UiEvent.RestartToTakeEffect) - } - } - }, - enabled = renameControlsEnabled, - ) - }, - modifier = Modifier - .alpha(if (renameControlsEnabled) 1f else disabledAlpha) - .clip( - if (interfaceRenameEnabled) { - RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp) - } else { - RoundedCornerShape(12.dp) - }, - ), - colors = ListItemDefaults.colors( - containerColor = Color.Transparent, - ), - ) - - if (interfaceRenameEnabled) { - val prefixEnabled = renameControlsEnabled - ListItem( - headlineContent = { - Text( - stringResource(R.string.privilege_settings_interface_prefix), - style = MaterialTheme.typography.bodyLarge, - ) - }, - supportingContent = { - Text( - interfacePrefix, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - }, - leadingContent = { - Icon( - imageVector = Icons.Outlined.Code, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - }, - trailingContent = { - Icon( - imageVector = Icons.AutoMirrored.Outlined.KeyboardArrowRight, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - }, - modifier = Modifier - .alpha(if (prefixEnabled) 1f else disabledAlpha) - .clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)) - .clickable(enabled = prefixEnabled) { - interfacePrefixInput = interfacePrefix - showInterfacePrefixDialog = true - }, - colors = ListItemDefaults.colors( - containerColor = Color.Transparent, - ), - ) - } - } - } - - Text( - text = stringResource(R.string.privilege_settings_vpn_detection_title), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(start = 32.dp, top = 24.dp, bottom = 8.dp), - ) - - Card( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - ), - ) { - Column( - modifier = Modifier.fillMaxWidth(), - ) { - val testEnabled = !hasPendingChange - ListItem( - headlineContent = { - Text( - stringResource(R.string.privilege_settings_hide_test), - style = MaterialTheme.typography.bodyLarge, - ) - }, - leadingContent = { - Icon( - imageVector = Icons.Outlined.BugReport, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - }, - trailingContent = { - Icon( - imageVector = Icons.AutoMirrored.Outlined.KeyboardArrowRight, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - }, - modifier = Modifier - .alpha(if (testEnabled) 1f else 0.38f) - .clip(RoundedCornerShape(12.dp)) - .clickable(enabled = testEnabled) { - showTestDialog = true - isTestRunning = true - testResult = null - scope.launch { - val result = withContext(Dispatchers.IO) { - VpnDetectionTest.runDetection(context) - } - testResult = result - isTestRunning = false - } - }, - colors = ListItemDefaults.colors( - containerColor = Color.Transparent, - ), - ) - } - } - } -} - -@Composable -private fun SelfTestDialog(isRunning: Boolean, result: DetectionResult?, onDismiss: () -> Unit) { - val notDetectedText = stringResource(R.string.privilege_settings_hide_test_not_detected) - - AlertDialog( - onDismissRequest = onDismiss, - title = { - Text(stringResource(R.string.privilege_settings_hide_test_result)) - }, - text = { - if (isRunning) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 24.dp), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - ) { - CircularProgressIndicator() - Text( - text = stringResource(R.string.privilege_settings_hide_test_running), - modifier = Modifier.padding(start = 16.dp), - ) - } - } else if (result != null) { - val frameworkInterfacesText = result.frameworkInterfaces - .takeIf { it.isNotEmpty() } - ?.joinToString(", ") - val frameworkProxyText = result.httpProxy?.takeIf { it.isNotBlank() } - val frameworkExtraLines = listOfNotNull(frameworkInterfacesText, frameworkProxyText) - val nativeInterfacesText = result.nativeInterfaces - .takeIf { it.isNotEmpty() } - ?.joinToString(", ") - val nativeExtraLines = listOfNotNull(nativeInterfacesText) - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Column { - Text( - text = "Framework", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - ) - if (result.frameworkDetected.isEmpty()) { - Text( - text = notDetectedText, - style = MaterialTheme.typography.bodyMedium, - color = Color(0xFF4CAF50), - ) - } else { - Text( - text = result.frameworkDetected.joinToString(", "), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.error, - modifier = Modifier.padding(top = 4.dp), - ) - if (frameworkExtraLines.isNotEmpty()) { - Column( - modifier = Modifier.padding(top = 4.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - frameworkExtraLines.forEach { line -> - Text( - text = line, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.error, - ) - } - } - } - } - } - - Column { - Text( - text = "Native", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - ) - if (!result.nativeDetected) { - Text( - text = notDetectedText, - style = MaterialTheme.typography.bodyMedium, - color = Color(0xFF4CAF50), - ) - } else { - Text( - text = "getifaddrs()", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.error, - modifier = Modifier.padding(top = 4.dp), - ) - if (nativeExtraLines.isNotEmpty()) { - Column( - modifier = Modifier.padding(top = 4.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - nativeExtraLines.forEach { line -> - Text( - text = line, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.error, - ) - } - } - } - } - } - } - } - }, - confirmButton = { - TextButton(onClick = onDismiss) { - Text(stringResource(R.string.close)) - } - }, - ) -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/ProfileOverrideScreen.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/ProfileOverrideScreen.kt deleted file mode 100644 index 22370e8a51..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/ProfileOverrideScreen.kt +++ /dev/null @@ -1,687 +0,0 @@ -package io.nekohasekai.sfa.compose.screen.settings - -import android.content.Intent -import android.net.Uri -import android.widget.Toast -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Column -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.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.automirrored.outlined.KeyboardArrowRight -import androidx.compose.material.icons.outlined.AppShortcut -import androidx.compose.material.icons.outlined.FilterList -import androidx.compose.material.icons.outlined.Route -import androidx.compose.material.icons.outlined.SmartToy -import androidx.compose.material.icons.outlined.Tune -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -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.RadioButton -import androidx.compose.material3.Switch -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar -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.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalLifecycleOwner -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.navigation.NavController -import io.nekohasekai.sfa.R -import io.nekohasekai.sfa.bg.RootClient -import io.nekohasekai.sfa.compose.screen.profileoverride.PerAppProxyScanner -import io.nekohasekai.sfa.compose.topbar.OverrideTopBar -import io.nekohasekai.sfa.database.Settings -import io.nekohasekai.sfa.vendor.PackageQueryManager -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun ProfileOverrideScreen(navController: NavController) { - OverrideTopBar { - TopAppBar( - title = { Text(stringResource(R.string.profile_override)) }, - navigationIcon = { - IconButton(onClick = { navController.navigateUp() }) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.content_description_back), - ) - } - }, - ) - } - - val context = LocalContext.current - val scope = rememberCoroutineScope() - - var autoRedirect by remember { mutableStateOf(Settings.autoRedirect) } - var perAppProxyEnabled by remember { mutableStateOf(Settings.perAppProxyEnabled) } - var managedModeEnabled by remember { mutableStateOf(Settings.perAppProxyManagedMode) } - var isScanning by remember { mutableStateOf(false) } - - fun scanAndSaveManagedList() { - isScanning = true - scope.launch { - val chinaApps = PerAppProxyScanner.scanAllChinaApps() - withContext(Dispatchers.IO) { - Settings.perAppProxyManagedList = chinaApps - } - isScanning = false - } - } - - var showShizukuDialog by remember { mutableStateOf(false) } - var showRootDialog by remember { mutableStateOf(false) } - var showModeDialog by remember { mutableStateOf(false) } - - val showModeSelector = PackageQueryManager.showModeSelector - var packageQueryMode by remember { mutableStateOf(Settings.perAppProxyPackageQueryMode) } - val useRootMode = packageQueryMode == Settings.PACKAGE_QUERY_MODE_ROOT - - val isShizukuInstalled by PackageQueryManager.shizukuInstalled.collectAsState() - val isShizukuBinderReady by PackageQueryManager.shizukuBinderReady.collectAsState() - val isShizukuPermissionGranted by PackageQueryManager.shizukuPermissionGranted.collectAsState() - val isShizukuAvailable = isShizukuBinderReady && isShizukuPermissionGranted - var isShizukuStateInitialized by remember(showModeSelector) { mutableStateOf(!showModeSelector) } - - DisposableEffect(showModeSelector) { - if (showModeSelector) { - isShizukuStateInitialized = false - PackageQueryManager.registerListeners() - PackageQueryManager.refreshShizukuState() - isShizukuStateInitialized = true - } - onDispose { - if (showModeSelector) { - PackageQueryManager.unregisterListeners() - isShizukuStateInitialized = false - } - } - } - - val lifecycleOwner = LocalLifecycleOwner.current - DisposableEffect(lifecycleOwner, showModeSelector) { - if (!showModeSelector) return@DisposableEffect onDispose { } - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - PackageQueryManager.refreshShizukuState() - } - } - lifecycleOwner.lifecycle.addObserver(observer) - onDispose { - lifecycleOwner.lifecycle.removeObserver(observer) - } - } - - // Auto-disable per-app proxy if Shizuku authorization is revoked (only when using Shizuku mode) - LaunchedEffect(isShizukuAvailable, useRootMode, isShizukuStateInitialized, perAppProxyEnabled, showModeSelector) { - if ( - showModeSelector && - !useRootMode && - isShizukuStateInitialized && - perAppProxyEnabled && - !PackageQueryManager.isShizukuAvailable() - ) { - perAppProxyEnabled = false - withContext(Dispatchers.IO) { - Settings.perAppProxyEnabled = false - } - } - } - - // Auto-close dialog and enable feature when Shizuku becomes available - LaunchedEffect(isShizukuAvailable) { - if (showModeSelector && isShizukuAvailable && showShizukuDialog) { - showShizukuDialog = false - perAppProxyEnabled = true - withContext(Dispatchers.IO) { - Settings.perAppProxyEnabled = true - } - if (managedModeEnabled) { - scanAndSaveManagedList() - } - } - } - - Column( - modifier = - Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.surface) - .verticalScroll(rememberScrollState()) - .padding(vertical = 8.dp), - ) { - // Card 1: Auto Redirect - Card( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - ), - ) { - ListItem( - headlineContent = { - Text( - stringResource(R.string.auto_redirect), - style = MaterialTheme.typography.bodyLarge, - ) - }, - supportingContent = { - Text( - stringResource(R.string.auto_redirect_description), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 4.dp), - ) - }, - leadingContent = { - Icon( - imageVector = Icons.Outlined.Route, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - }, - trailingContent = { - Switch( - checked = autoRedirect, - onCheckedChange = { checked -> - if (checked && !autoRedirect) { - scope.launch { - val hasRoot = RootClient.checkRootAvailable() - if (hasRoot) { - autoRedirect = true - withContext(Dispatchers.IO) { - Settings.autoRedirect = true - } - } else { - Toast.makeText( - context, - context.getString(R.string.root_access_required), - Toast.LENGTH_LONG, - ).show() - } - } - } else if (!checked) { - autoRedirect = false - scope.launch(Dispatchers.IO) { - Settings.autoRedirect = false - } - } - }, - ) - }, - modifier = Modifier.clip(RoundedCornerShape(12.dp)), - colors = - ListItemDefaults.colors( - containerColor = Color.Transparent, - ), - ) - } - - // Section: Per-App Proxy - val canUsePerAppProxy = if (showModeSelector) { - if (useRootMode) true else isShizukuAvailable - } else { - true - } - - Text( - text = stringResource(R.string.per_app_proxy), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(start = 32.dp, top = 16.dp, bottom = 8.dp), - ) - - Card( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - ), - ) { - Column { - // Mode selector (only when privileged query is needed) - if (showModeSelector) { - val modeEnabled = !perAppProxyEnabled - val disabledAlpha = 0.38f - ListItem( - headlineContent = { - Text( - stringResource(R.string.per_app_proxy_package_query_mode), - style = MaterialTheme.typography.bodyLarge, - color = if (modeEnabled) { - Color.Unspecified - } else { - MaterialTheme.colorScheme.onSurface.copy(alpha = disabledAlpha) - }, - ) - }, - supportingContent = { - Text( - if (useRootMode) "ROOT" else "Shizuku", - style = MaterialTheme.typography.bodyMedium, - color = if (modeEnabled) { - MaterialTheme.colorScheme.onSurfaceVariant - } else { - MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = disabledAlpha) - }, - ) - }, - leadingContent = { - Icon( - imageVector = Icons.Outlined.Tune, - contentDescription = null, - tint = if (modeEnabled) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurface.copy(alpha = disabledAlpha) - }, - ) - }, - trailingContent = { - Icon( - imageVector = Icons.AutoMirrored.Outlined.KeyboardArrowRight, - contentDescription = null, - tint = if (modeEnabled) { - MaterialTheme.colorScheme.onSurfaceVariant - } else { - MaterialTheme.colorScheme.onSurface.copy(alpha = disabledAlpha) - }, - ) - }, - modifier = Modifier - .clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)) - .clickable(enabled = modeEnabled) { showModeDialog = true }, - colors = ListItemDefaults.colors( - containerColor = Color.Transparent, - ), - ) - } - - // Enabled toggle - ListItem( - headlineContent = { - Text( - stringResource(R.string.enabled), - style = MaterialTheme.typography.bodyLarge, - ) - }, - leadingContent = { - Icon( - imageVector = Icons.Outlined.FilterList, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - }, - trailingContent = { - Switch( - checked = perAppProxyEnabled, - onCheckedChange = { checked -> - if (checked && showModeSelector) { - if (useRootMode) { - showRootDialog = true - } else { - showShizukuDialog = true - } - } else { - perAppProxyEnabled = checked - scope.launch(Dispatchers.IO) { - Settings.perAppProxyEnabled = checked - } - if (checked && managedModeEnabled) { - scanAndSaveManagedList() - } - } - }, - enabled = !isScanning, - ) - }, - modifier = - Modifier.clip( - if (showModeSelector) { - RoundedCornerShape(0.dp) - } else if (perAppProxyEnabled && canUsePerAppProxy) { - RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp) - } else { - RoundedCornerShape(12.dp) - }, - ), - colors = - ListItemDefaults.colors( - containerColor = Color.Transparent, - ), - ) - - if (perAppProxyEnabled && canUsePerAppProxy) { - // Manage entry - val manageEnabled = !managedModeEnabled - val disabledAlpha = 0.38f - ListItem( - headlineContent = { - Text( - stringResource(R.string.per_app_proxy_manage), - style = MaterialTheme.typography.bodyLarge, - color = if (manageEnabled) { - Color.Unspecified - } else { - MaterialTheme.colorScheme.onSurface.copy(alpha = disabledAlpha) - }, - ) - }, - leadingContent = { - Icon( - imageVector = Icons.Outlined.AppShortcut, - contentDescription = null, - tint = if (manageEnabled) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurface.copy(alpha = disabledAlpha) - }, - ) - }, - trailingContent = { - Icon( - imageVector = Icons.AutoMirrored.Outlined.KeyboardArrowRight, - contentDescription = null, - tint = if (manageEnabled) { - MaterialTheme.colorScheme.onSurfaceVariant - } else { - MaterialTheme.colorScheme.onSurface.copy(alpha = disabledAlpha) - }, - ) - }, - modifier = - Modifier.clickable(enabled = manageEnabled) { - navController.navigate("settings/profile_override/manage") - }, - colors = - ListItemDefaults.colors( - containerColor = Color.Transparent, - ), - ) - - // Managed Mode toggle - ListItem( - headlineContent = { - Text( - stringResource(R.string.per_app_proxy_managed_mode), - style = MaterialTheme.typography.bodyLarge, - ) - }, - supportingContent = { - Text( - stringResource(R.string.per_app_proxy_managed_mode_description), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 4.dp), - ) - }, - leadingContent = { - Icon( - imageVector = Icons.Outlined.SmartToy, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - }, - trailingContent = { - if (isScanning) { - CircularProgressIndicator( - modifier = Modifier.size(24.dp), - strokeWidth = 2.dp, - ) - } else { - Switch( - checked = managedModeEnabled, - onCheckedChange = { checked -> - if (checked) { - managedModeEnabled = true - scope.launch(Dispatchers.IO) { - Settings.perAppProxyManagedMode = true - } - scanAndSaveManagedList() - } else { - managedModeEnabled = false - scope.launch(Dispatchers.IO) { - Settings.perAppProxyManagedMode = false - } - } - }, - ) - } - }, - modifier = Modifier.clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)), - colors = - ListItemDefaults.colors( - containerColor = Color.Transparent, - ), - ) - } - } - } - - // Shizuku dialog - if (showShizukuDialog) { - AlertDialog( - onDismissRequest = { showShizukuDialog = false }, - title = { - Text(stringResource(R.string.per_app_proxy)) - }, - text = { - Text(stringResource(R.string.per_app_proxy_shizuku_required)) - }, - confirmButton = { - when { - isShizukuAvailable -> { - TextButton( - onClick = { - showShizukuDialog = false - perAppProxyEnabled = true - scope.launch(Dispatchers.IO) { - Settings.perAppProxyEnabled = true - } - if (managedModeEnabled) { - scanAndSaveManagedList() - } - }, - ) { - Text(stringResource(R.string.ok)) - } - } - isShizukuBinderReady -> { - TextButton( - onClick = { - PackageQueryManager.requestShizukuPermission() - }, - ) { - Text(stringResource(R.string.request_shizuku)) - } - } - isShizukuInstalled -> { - TextButton( - onClick = { - showShizukuDialog = false - val intent = context.packageManager.getLaunchIntentForPackage("moe.shizuku.privileged.api") - if (intent != null) { - context.startActivity(intent) - } - }, - ) { - Text(stringResource(R.string.start_shizuku)) - } - } - else -> { - TextButton( - onClick = { - showShizukuDialog = false - val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://shizuku.rikka.app/")) - context.startActivity(intent) - }, - ) { - Text(stringResource(R.string.get_shizuku)) - } - } - } - }, - dismissButton = { - if (!isShizukuAvailable) { - TextButton( - onClick = { showShizukuDialog = false }, - ) { - Text(stringResource(R.string.cancel)) - } - } - }, - ) - } - - // ROOT dialog - if (showRootDialog) { - AlertDialog( - onDismissRequest = { showRootDialog = false }, - title = { - Text(stringResource(R.string.per_app_proxy)) - }, - text = { - Text(stringResource(R.string.per_app_proxy_root_required)) - }, - confirmButton = { - TextButton( - onClick = { - scope.launch { - val hasRoot = PackageQueryManager.checkRootAvailable() - if (hasRoot) { - showRootDialog = false - perAppProxyEnabled = true - withContext(Dispatchers.IO) { - Settings.perAppProxyEnabled = true - } - if (managedModeEnabled) { - scanAndSaveManagedList() - } - } else { - showRootDialog = false - Toast.makeText( - context, - R.string.root_access_denied, - Toast.LENGTH_LONG, - ).show() - } - } - }, - ) { - Text(stringResource(R.string.ok)) - } - }, - dismissButton = { - TextButton( - onClick = { showRootDialog = false }, - ) { - Text(stringResource(R.string.cancel)) - } - }, - ) - } - - // Mode selection dialog - if (showModeDialog) { - AlertDialog( - onDismissRequest = { showModeDialog = false }, - title = { - Text(stringResource(R.string.per_app_proxy_package_query_mode)) - }, - text = { - Column { - ListItem( - headlineContent = { Text("Shizuku") }, - leadingContent = { - RadioButton( - selected = packageQueryMode == Settings.PACKAGE_QUERY_MODE_SHIZUKU, - onClick = null, - ) - }, - modifier = Modifier.clickable { - packageQueryMode = Settings.PACKAGE_QUERY_MODE_SHIZUKU - PackageQueryManager.setQueryMode(Settings.PACKAGE_QUERY_MODE_SHIZUKU) - scope.launch(Dispatchers.IO) { - Settings.perAppProxyPackageQueryMode = Settings.PACKAGE_QUERY_MODE_SHIZUKU - } - if ( - perAppProxyEnabled && - isShizukuStateInitialized && - !PackageQueryManager.isShizukuAvailable() - ) { - perAppProxyEnabled = false - scope.launch(Dispatchers.IO) { - Settings.perAppProxyEnabled = false - } - } - showModeDialog = false - }, - colors = ListItemDefaults.colors( - containerColor = Color.Transparent, - ), - ) - ListItem( - headlineContent = { Text("ROOT") }, - leadingContent = { - RadioButton( - selected = packageQueryMode == Settings.PACKAGE_QUERY_MODE_ROOT, - onClick = null, - ) - }, - modifier = Modifier.clickable { - packageQueryMode = Settings.PACKAGE_QUERY_MODE_ROOT - PackageQueryManager.setQueryMode(Settings.PACKAGE_QUERY_MODE_ROOT) - scope.launch(Dispatchers.IO) { - Settings.perAppProxyPackageQueryMode = Settings.PACKAGE_QUERY_MODE_ROOT - } - showModeDialog = false - }, - colors = ListItemDefaults.colors( - containerColor = Color.Transparent, - ), - ) - } - }, - confirmButton = {}, - ) - } - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/ServiceSettingsScreen.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/ServiceSettingsScreen.kt deleted file mode 100644 index 7f89fff529..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/ServiceSettingsScreen.kt +++ /dev/null @@ -1,176 +0,0 @@ -package io.nekohasekai.sfa.compose.screen.settings - -import android.content.Intent -import android.net.Uri -import android.os.Build -import android.os.PowerManager -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.background -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.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -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.material.icons.outlined.BatteryChargingFull -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.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -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.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.navigation.NavController -import io.nekohasekai.sfa.R -import io.nekohasekai.sfa.bg.ServiceConnection -import io.nekohasekai.sfa.compose.topbar.OverrideTopBar -import io.nekohasekai.sfa.ktx.launchCustomTab - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun ServiceSettingsScreen(navController: NavController, serviceConnection: ServiceConnection? = null) { - OverrideTopBar { - TopAppBar( - title = { Text(stringResource(R.string.service)) }, - navigationIcon = { - IconButton(onClick = { navController.navigateUp() }) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.content_description_back), - ) - } - }, - ) - } - - val context = LocalContext.current - // Check battery optimization status - var isBatteryOptimizationIgnored by remember { mutableStateOf(false) } - // Activity result launcher for battery optimization permission - val requestBatteryOptimizationLauncher = - rememberLauncherForActivityResult( - ActivityResultContracts.StartActivityForResult(), - ) { _ -> - // Recheck the status after returning from settings - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - val pm = context.getSystemService(PowerManager::class.java) - isBatteryOptimizationIgnored = - pm?.isIgnoringBatteryOptimizations(context.packageName) == true - } - } - - // Check battery optimization status on launch - LaunchedEffect(Unit) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - val pm = context.getSystemService(PowerManager::class.java) - isBatteryOptimizationIgnored = - pm?.isIgnoringBatteryOptimizations(context.packageName) == true - } else { - isBatteryOptimizationIgnored = true - } - } - - Column( - modifier = - Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.surface) - .verticalScroll(rememberScrollState()) - .padding(vertical = 8.dp), - ) { - // Background Permission Card (only show if battery optimization is not ignored) - if (!isBatteryOptimizationIgnored && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - Card( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.5f), - ), - ) { - Column( - modifier = Modifier.padding(16.dp), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = Icons.Outlined.BatteryChargingFull, - contentDescription = null, - tint = MaterialTheme.colorScheme.tertiary, - modifier = Modifier.padding(end = 12.dp), - ) - Text( - stringResource(R.string.background_permission), - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onTertiaryContainer, - ) - } - - Text( - stringResource(R.string.background_permission_description), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onTertiaryContainer, - modifier = Modifier.padding(top = 8.dp, bottom = 16.dp), - ) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - ) { - OutlinedButton( - onClick = { - context.launchCustomTab("https://dontkillmyapp.com/") - }, - modifier = Modifier.padding(end = 8.dp), - ) { - Text(stringResource(R.string.read_more)) - } - - Button( - onClick = { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - val intent = - Intent( - android.provider.Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, - Uri.parse("package:${context.packageName}"), - ) - requestBatteryOptimizationLauncher.launch(intent) - } - }, - ) { - Text(stringResource(R.string.request_background_permission)) - } - } - } - } - } - - Spacer(modifier = Modifier.height(16.dp)) - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/SettingsScreen.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/SettingsScreen.kt deleted file mode 100644 index f468f51b21..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/SettingsScreen.kt +++ /dev/null @@ -1,366 +0,0 @@ -package io.nekohasekai.sfa.compose.screen.settings - -import android.os.Build -import android.os.PowerManager -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -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.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.outlined.OpenInNew -import androidx.compose.material.icons.outlined.AdminPanelSettings -import androidx.compose.material.icons.outlined.Code -import androidx.compose.material.icons.outlined.Description -import androidx.compose.material.icons.outlined.Favorite -import androidx.compose.material.icons.outlined.FilterAlt -import androidx.compose.material.icons.outlined.Info -import androidx.compose.material.icons.outlined.Settings -import androidx.compose.material.icons.outlined.Tune -import androidx.compose.material3.Badge -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.ListItem -import androidx.compose.material3.ListItemDefaults -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -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.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.navigation.NavController -import io.nekohasekai.sfa.R -import io.nekohasekai.sfa.compose.topbar.OverrideTopBar -import io.nekohasekai.sfa.database.Settings -import io.nekohasekai.sfa.update.UpdateState -import io.nekohasekai.sfa.utils.HookModuleUpdateNotifier -import io.nekohasekai.sfa.utils.HookStatusClient - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun SettingsScreen(navController: NavController) { - OverrideTopBar { - TopAppBar( - title = { Text(stringResource(R.string.title_settings)) }, - ) - } - - val context = LocalContext.current - val scope = rememberCoroutineScope() - val hasUpdate by UpdateState.hasUpdate - val hookStatus by HookStatusClient.status.collectAsState() - val hasPendingPrivilegeDowngrade = HookModuleUpdateNotifier.isDowngrade(hookStatus) - val hasPendingPrivilegeUpdate = HookModuleUpdateNotifier.isUpgrade(hookStatus) - var isBatteryOptimizationIgnored by remember { mutableStateOf(true) } - - LaunchedEffect(Unit) { - HookStatusClient.refresh() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - val pm = context.getSystemService(PowerManager::class.java) - isBatteryOptimizationIgnored = - pm?.isIgnoringBatteryOptimizations(context.packageName) == true - } - } - - Column( - modifier = - Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.surface) - .verticalScroll(rememberScrollState()) - .padding(vertical = 8.dp), - ) { - // General Settings Group - Card( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - ), - ) { - Column { - ListItem( - headlineContent = { - Text( - stringResource(R.string.title_app_settings), - style = MaterialTheme.typography.bodyLarge, - ) - }, - leadingContent = { - Icon( - imageVector = Icons.Outlined.Info, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - }, - trailingContent = { - if (hasUpdate) { - Badge(containerColor = MaterialTheme.colorScheme.primary) - } - }, - modifier = - Modifier - .clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)) - .clickable { navController.navigate("settings/app") }, - colors = - ListItemDefaults.colors( - containerColor = Color.Transparent, - ), - ) - - ListItem( - headlineContent = { - Text( - stringResource(R.string.core), - style = MaterialTheme.typography.bodyLarge, - ) - }, - leadingContent = { - Icon( - imageVector = Icons.Outlined.Settings, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - }, - modifier = - Modifier - .clickable { navController.navigate("settings/core") }, - colors = - ListItemDefaults.colors( - containerColor = Color.Transparent, - ), - ) - - ListItem( - headlineContent = { - Text( - stringResource(R.string.service), - style = MaterialTheme.typography.bodyLarge, - ) - }, - leadingContent = { - Icon( - imageVector = Icons.Outlined.Tune, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - }, - trailingContent = { - if (!isBatteryOptimizationIgnored) { - Badge(containerColor = MaterialTheme.colorScheme.primary) - } - }, - modifier = Modifier.clickable { navController.navigate("settings/service") }, - colors = - ListItemDefaults.colors( - containerColor = Color.Transparent, - ), - ) - - ListItem( - headlineContent = { - Text( - stringResource(R.string.profile_override), - style = MaterialTheme.typography.bodyLarge, - ) - }, - leadingContent = { - Icon( - imageVector = Icons.Outlined.FilterAlt, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - }, - modifier = - Modifier - .clickable { navController.navigate("settings/profile_override") }, - colors = - ListItemDefaults.colors( - containerColor = Color.Transparent, - ), - ) - - ListItem( - headlineContent = { - Text( - stringResource(R.string.privilege_settings), - style = MaterialTheme.typography.bodyLarge, - ) - }, - leadingContent = { - Icon( - imageVector = Icons.Outlined.AdminPanelSettings, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - }, - trailingContent = { - if (hasPendingPrivilegeDowngrade) { - Badge(containerColor = MaterialTheme.colorScheme.error) - } else if (hasPendingPrivilegeUpdate) { - Badge(containerColor = Color(0xFFFFC107)) - } - }, - modifier = - Modifier - .clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)) - .clickable { navController.navigate("settings/privilege") }, - colors = - ListItemDefaults.colors( - containerColor = Color.Transparent, - ), - ) - } - } - - // About Section - Text( - text = stringResource(R.string.about), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp), - ) - - Card( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - ), - ) { - Column { - ListItem( - headlineContent = { - Text( - stringResource(R.string.error_deprecated_documentation), - style = MaterialTheme.typography.bodyLarge, - ) - }, - leadingContent = { - Icon( - imageVector = Icons.Outlined.Description, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - }, - trailingContent = { - Icon( - imageVector = Icons.AutoMirrored.Outlined.OpenInNew, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - }, - modifier = - Modifier - .clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)) - .clickable { - val intent = android.content.Intent(android.content.Intent.ACTION_VIEW) - intent.data = android.net.Uri.parse("https://sing-box.sagernet.org/") - context.startActivity(intent) - }, - colors = - ListItemDefaults.colors( - containerColor = Color.Transparent, - ), - ) - - ListItem( - headlineContent = { - Text( - stringResource(R.string.source_code), - style = MaterialTheme.typography.bodyLarge, - ) - }, - leadingContent = { - Icon( - imageVector = Icons.Outlined.Code, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - }, - trailingContent = { - Icon( - imageVector = Icons.AutoMirrored.Outlined.OpenInNew, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - }, - modifier = - Modifier - .clickable { - val intent = android.content.Intent(android.content.Intent.ACTION_VIEW) - intent.data = - android.net.Uri.parse("https://github.com/SagerNet/sing-box-for-android") - context.startActivity(intent) - }, - colors = - ListItemDefaults.colors( - containerColor = Color.Transparent, - ), - ) - - ListItem( - headlineContent = { - Text( - stringResource(R.string.sponsor), - style = MaterialTheme.typography.bodyLarge, - ) - }, - leadingContent = { - Icon( - imageVector = Icons.Outlined.Favorite, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - }, - trailingContent = { - Icon( - imageVector = Icons.AutoMirrored.Outlined.OpenInNew, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - }, - modifier = - Modifier - .clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)) - .clickable { - val intent = android.content.Intent(android.content.Intent.ACTION_VIEW) - intent.data = android.net.Uri.parse("https://sekai.icu/sponsors/") - context.startActivity(intent) - }, - colors = - ListItemDefaults.colors( - containerColor = Color.Transparent, - ), - ) - } - } - - Spacer(modifier = Modifier.height(16.dp)) - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/shared/AppSelectionComponents.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/shared/AppSelectionComponents.kt deleted file mode 100644 index 2272ebe8f4..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/shared/AppSelectionComponents.kt +++ /dev/null @@ -1,301 +0,0 @@ -package io.nekohasekai.sfa.compose.shared - -import android.Manifest -import android.content.pm.ApplicationInfo -import android.content.pm.PackageInfo -import android.content.pm.PackageManager -import android.graphics.Bitmap -import android.graphics.Canvas -import android.graphics.drawable.BitmapDrawable -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.Image -import androidx.compose.foundation.clickable -import androidx.compose.foundation.combinedClickable -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.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ContentCopy -import androidx.compose.material.icons.filled.ExpandLess -import androidx.compose.material.icons.filled.ExpandMore -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Switch -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.clip -import androidx.compose.ui.graphics.asImageBitmap -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import io.nekohasekai.sfa.R - -enum class SortMode { - NAME, - PACKAGE_NAME, - UID, - INSTALL_TIME, - UPDATE_TIME, -} - -class PackageCache( - private val packageInfo: PackageInfo, - private val appInfo: ApplicationInfo, - private val packageManager: PackageManager, -) { - val packageName: String get() = packageInfo.packageName - - val uid: Int get() = packageInfo.applicationInfo!!.uid - - val installTime: Long get() = packageInfo.firstInstallTime - val updateTime: Long get() = packageInfo.lastUpdateTime - val isSystem: Boolean get() = appInfo.flags and ApplicationInfo.FLAG_SYSTEM == 1 - val isOffline: Boolean - get() = packageInfo.requestedPermissions?.contains(Manifest.permission.INTERNET) != true - val isDisabled: Boolean get() = appInfo.flags and ApplicationInfo.FLAG_INSTALLED == 0 - - val applicationIcon by lazy { - val drawable = appInfo.loadIcon(packageManager) - val bitmap = - if (drawable is BitmapDrawable) { - drawable.bitmap - } else { - val imageBitmap = - Bitmap.createBitmap( - drawable.intrinsicWidth.coerceAtLeast(1), - drawable.intrinsicHeight.coerceAtLeast(1), - Bitmap.Config.ARGB_8888, - ) - val canvas = Canvas(imageBitmap) - drawable.setBounds(0, 0, canvas.width, canvas.height) - drawable.draw(canvas) - imageBitmap - } - bitmap.asImageBitmap() - } - - val applicationLabel by lazy { - appInfo.loadLabel(packageManager).toString() - } - - val info: PackageInfo get() = packageInfo -} - -fun buildDisplayPackages( - packages: List, - selectedUids: Set = emptySet(), - selectedFirst: Boolean = false, - hideSystemApps: Boolean, - hideOfflineApps: Boolean, - hideDisabledApps: Boolean, - sortMode: SortMode, - sortReverse: Boolean, -): List { - val displayPackages = - packages.filter { packageCache -> - if (hideSystemApps && packageCache.isSystem) { - return@filter false - } - if (hideOfflineApps && packageCache.isOffline) { - return@filter false - } - if (hideDisabledApps && packageCache.isDisabled) { - return@filter false - } - true - } - val sortComparator = - Comparator { left, right -> - if (selectedFirst) { - val selectedCompare = - compareValues( - !selectedUids.contains(left.uid), - !selectedUids.contains(right.uid), - ) - if (selectedCompare != 0) { - return@Comparator selectedCompare - } - } - val value = - when (sortMode) { - SortMode.NAME -> compareValues(left.applicationLabel, right.applicationLabel) - SortMode.PACKAGE_NAME -> compareValues(left.packageName, right.packageName) - SortMode.UID -> compareValues(left.uid, right.uid) - SortMode.INSTALL_TIME -> compareValues(left.installTime, right.installTime) - SortMode.UPDATE_TIME -> compareValues(left.updateTime, right.updateTime) - } - if (sortReverse) -value else value - } - return displayPackages.sortedWith(sortComparator) -} - -@OptIn(ExperimentalFoundationApi::class) -@Composable -fun AppSelectionCard( - packageCache: PackageCache, - selected: Boolean, - onToggle: (Boolean) -> Unit, - enableCopyActions: Boolean = true, - onCopyLabel: (() -> Unit)? = null, - onCopyPackage: (() -> Unit)? = null, - onCopyUid: (() -> Unit)? = null, -) { - var showContextMenu by remember { mutableStateOf(false) } - var showCopyMenu by remember { mutableStateOf(false) } - val cardShape = MaterialTheme.shapes.medium - val cardModifier = - if (enableCopyActions) { - Modifier - .fillMaxWidth() - .clip(cardShape) - .combinedClickable( - onClick = { onToggle(!selected) }, - onLongClick = { showContextMenu = true }, - ) - } else { - Modifier - .fillMaxWidth() - .clip(cardShape) - .clickable { onToggle(!selected) } - } - - Box { - Card( - modifier = cardModifier, - shape = cardShape, - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerLow, - ), - ) { - Row( - modifier = Modifier.padding(12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Image( - bitmap = packageCache.applicationIcon, - contentDescription = stringResource(R.string.content_description_app_icon), - modifier = Modifier.size(40.dp), - ) - Column( - modifier = Modifier.weight(1f), - ) { - Text( - text = packageCache.applicationLabel, - style = MaterialTheme.typography.titleMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - text = "${packageCache.packageName} (${packageCache.uid})", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - softWrap = true, - ) - } - Switch( - checked = selected, - onCheckedChange = { onToggle(it) }, - ) - } - } - - if (enableCopyActions) { - DropdownMenu( - expanded = showContextMenu, - onDismissRequest = { - showContextMenu = false - showCopyMenu = false - }, - ) { - DropdownMenuItem( - text = { Text(stringResource(R.string.per_app_proxy_action_copy)) }, - onClick = { showCopyMenu = !showCopyMenu }, - leadingIcon = { - Icon( - imageVector = Icons.Default.ContentCopy, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - }, - trailingIcon = { - Icon( - imageVector = - if (showCopyMenu) { - Icons.Default.ExpandLess - } else { - Icons.Default.ExpandMore - }, - contentDescription = null, - ) - }, - ) - if (showCopyMenu) { - DropdownMenuItem( - text = { Text(stringResource(R.string.profile_name)) }, - onClick = { - showContextMenu = false - showCopyMenu = false - onCopyLabel?.invoke() - }, - leadingIcon = { - Icon( - imageVector = Icons.Default.ContentCopy, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(start = 24.dp), - ) - }, - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.per_app_proxy_action_copy_package_name)) }, - onClick = { - showContextMenu = false - showCopyMenu = false - onCopyPackage?.invoke() - }, - leadingIcon = { - Icon( - imageVector = Icons.Default.ContentCopy, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(start = 24.dp), - ) - }, - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.per_app_proxy_action_copy_uid)) }, - onClick = { - showContextMenu = false - showCopyMenu = false - onCopyUid?.invoke() - }, - leadingIcon = { - Icon( - imageVector = Icons.Default.ContentCopy, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(start = 24.dp), - ) - }, - ) - } - } - } - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/theme/Color.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/theme/Color.kt deleted file mode 100644 index 485f443a1f..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/theme/Color.kt +++ /dev/null @@ -1,32 +0,0 @@ -package io.nekohasekai.sfa.compose.theme - -import androidx.compose.ui.graphics.Color - -// Primary colors from existing app -val SingBoxPrimary = Color(0xFFD81B60) -val SingBoxPrimaryDark = Color(0xFFA00037) -val SingBoxPrimaryLight = Color(0xFFFF5C8D) - -// Service status colors -val ServiceRunning = Color(0xFF4CAF50) -val ServiceStopped = Color(0xFF9E9E9E) -val ServiceError = Color(0xFFF44336) - -// Log colors -val LogRed = Color(0xFFFF2158) -val LogGreen = Color(0xFF2ECC71) -val LogYellow = Color(0xFFE5E500) -val LogBlue = Color(0xFF3498DB) -val LogPurple = Color(0xFFE500E5) -val LogRedLight = Color(0xFFE91E63) -val LogBlueLight = Color(0xFF00A6B2) -val LogWhite = Color(0xFFECECEC) - -// Material You seed color -val SeedColor = Color(0xFFD81B60) - -// Additional semantic colors -val SuccessGreen = Color(0xFF4CAF50) -val WarningOrange = Color(0xFFFF9800) -val ErrorRed = Color(0xFFF44336) -val InfoBlue = Color(0xFF2196F3) diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/theme/Shape.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/theme/Shape.kt deleted file mode 100644 index 6cd9e04423..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/theme/Shape.kt +++ /dev/null @@ -1,14 +0,0 @@ -package io.nekohasekai.sfa.compose.theme - -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Shapes -import androidx.compose.ui.unit.dp - -val Shapes = - Shapes( - extraSmall = RoundedCornerShape(4.dp), - small = RoundedCornerShape(8.dp), - medium = RoundedCornerShape(12.dp), - large = RoundedCornerShape(16.dp), - extraLarge = RoundedCornerShape(28.dp), - ) diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/theme/Theme.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/theme/Theme.kt deleted file mode 100644 index 34785129cc..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/theme/Theme.kt +++ /dev/null @@ -1,69 +0,0 @@ -package io.nekohasekai.sfa.compose.theme - -import android.app.Activity -import android.os.Build -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.darkColorScheme -import androidx.compose.material3.dynamicDarkColorScheme -import androidx.compose.material3.dynamicLightColorScheme -import androidx.compose.material3.lightColorScheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.SideEffect -import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalView -import androidx.core.view.WindowCompat - -private val DarkColorScheme = - darkColorScheme( - primary = SingBoxPrimary, - secondary = SingBoxPrimaryLight, - tertiary = LogBlue, - ) - -private val LightColorScheme = - lightColorScheme( - primary = SingBoxPrimary, - secondary = SingBoxPrimaryDark, - tertiary = LogBlue, - ) - -@Composable -fun SFATheme( - darkTheme: Boolean = isSystemInDarkTheme(), - // Dynamic color is available on Android 12+ - dynamicColor: Boolean = true, - content: @Composable () -> Unit, -) { - val colorScheme = - when { - dynamicColor && Build.VERSION.SDK_INT >= 31 -> { - val context = LocalContext.current - if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) - } - - darkTheme -> DarkColorScheme - else -> LightColorScheme - } - - val view = LocalView.current - if (!view.isInEditMode) { - SideEffect { - val window = (view.context as? Activity)?.window ?: return@SideEffect - window.statusBarColor = colorScheme.surface.toArgb() - window.navigationBarColor = colorScheme.background.toArgb() - WindowCompat.getInsetsController(window, view).apply { - isAppearanceLightStatusBars = !darkTheme - isAppearanceLightNavigationBars = !darkTheme - } - } - } - - MaterialTheme( - colorScheme = colorScheme, - typography = Typography, - shapes = Shapes, - content = content, - ) -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/theme/Type.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/theme/Type.kt deleted file mode 100644 index 48cccc34e2..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/theme/Type.kt +++ /dev/null @@ -1,137 +0,0 @@ -package io.nekohasekai.sfa.compose.theme - -import androidx.compose.material3.Typography -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.sp - -// Material 3 Typography -val Typography = - Typography( - // Display styles - displayLarge = - TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 57.sp, - lineHeight = 64.sp, - letterSpacing = (-0.25).sp, - ), - displayMedium = - TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 45.sp, - lineHeight = 52.sp, - letterSpacing = 0.sp, - ), - displaySmall = - TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 36.sp, - lineHeight = 44.sp, - letterSpacing = 0.sp, - ), - // Headline styles - headlineLarge = - TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 32.sp, - lineHeight = 40.sp, - letterSpacing = 0.sp, - ), - headlineMedium = - TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 28.sp, - lineHeight = 36.sp, - letterSpacing = 0.sp, - ), - headlineSmall = - TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 24.sp, - lineHeight = 32.sp, - letterSpacing = 0.sp, - ), - // Title styles - titleLarge = - TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Medium, - fontSize = 22.sp, - lineHeight = 28.sp, - letterSpacing = 0.sp, - ), - titleMedium = - TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Medium, - fontSize = 16.sp, - lineHeight = 24.sp, - letterSpacing = 0.15.sp, - ), - titleSmall = - TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Medium, - fontSize = 14.sp, - lineHeight = 20.sp, - letterSpacing = 0.1.sp, - ), - // Body styles - bodyLarge = - TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 16.sp, - lineHeight = 24.sp, - letterSpacing = 0.5.sp, - ), - bodyMedium = - TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 14.sp, - lineHeight = 20.sp, - letterSpacing = 0.25.sp, - ), - bodySmall = - TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 12.sp, - lineHeight = 16.sp, - letterSpacing = 0.4.sp, - ), - // Label styles - labelLarge = - TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Medium, - fontSize = 14.sp, - lineHeight = 20.sp, - letterSpacing = 0.1.sp, - ), - labelMedium = - TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Medium, - fontSize = 12.sp, - lineHeight = 16.sp, - letterSpacing = 0.5.sp, - ), - labelSmall = - TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Medium, - fontSize = 11.sp, - lineHeight = 16.sp, - letterSpacing = 0.5.sp, - ), - ) diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/topbar/TopBarController.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/topbar/TopBarController.kt deleted file mode 100644 index 6c80f2e48b..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/topbar/TopBarController.kt +++ /dev/null @@ -1,37 +0,0 @@ -package io.nekohasekai.sfa.compose.topbar - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.compositionLocalOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState - -internal data class TopBarEntry(val key: Any, val content: @Composable () -> Unit) - -class TopBarController internal constructor(private val state: MutableState>) { - val current: (@Composable () -> Unit)? get() = state.value.lastOrNull()?.content - - fun set(key: Any, content: @Composable () -> Unit) { - state.value = state.value.filterNot { it.key == key } + TopBarEntry(key, content) - } - - fun clear(key: Any) { - state.value = state.value.filterNot { it.key == key } - } -} - -val LocalTopBarController = compositionLocalOf { - error("TopBarController not provided") -} - -@Composable -fun OverrideTopBar(content: @Composable () -> Unit) { - val controller = LocalTopBarController.current - val token = remember { Any() } - val currentContent = rememberUpdatedState(content) - DisposableEffect(controller, token) { - controller.set(token) { currentContent.value() } - onDispose { controller.clear(token) } - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/AnsiColorUtils.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/AnsiColorUtils.kt deleted file mode 100644 index 5bd3c7e8d1..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/AnsiColorUtils.kt +++ /dev/null @@ -1,120 +0,0 @@ -package io.nekohasekai.sfa.compose.util - -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.font.FontStyle -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextDecoration - -object AnsiColorUtils { - private val ansiRegex = Regex("\u001B\\[[;\\d]*m") - - private val logRed = Color(0xFFFF2158) - private val logGreen = Color(0xFF2ECC71) - private val logYellow = Color(0xFFE5E500) - private val logBlue = Color(0xFF3498DB) - private val logPurple = Color(0xFF9B59B6) - private val logBlueLight = Color(0xFF5DADE2) - private val logWhite = Color(0xFFECF0F1) - - fun ansiToAnnotatedString(text: String): AnnotatedString { - val cleanText = stripAnsi(text) - val matches = ansiRegex.findAll(text).toList() - - if (matches.isEmpty()) { - return AnnotatedString(cleanText) - } - - return buildAnnotatedString { - append(cleanText) - - var currentStyle: SpanStyle? = null - var currentStart = 0 - var offset = 0 - - matches.forEach { match -> - val code = match.value - val codeStart = match.range.first - offset - val decoration = parseAnsiCode(code) - - if (decoration == null) { - // Reset code - if (currentStyle != null && currentStart < codeStart) { - addStyle(currentStyle!!, currentStart, codeStart) - } - currentStyle = null - currentStart = codeStart - } else { - // Apply previous style if exists - if (currentStyle != null && currentStart < codeStart) { - addStyle(currentStyle!!, currentStart, codeStart) - } - currentStyle = decoration - currentStart = codeStart - } - - offset += code.length - } - - // Apply remaining style - if (currentStyle != null && currentStart < cleanText.length) { - addStyle(currentStyle!!, currentStart, cleanText.length) - } - } - } - - fun stripAnsi(text: String): String = text.replace(ansiRegex, "") - - private fun parseAnsiCode(code: String): SpanStyle? { - val colorCodes = code.substringAfter('[').substringBefore('m').split(';') - - var color: Color? = null - var fontWeight: FontWeight? = null - var fontStyle: FontStyle? = null - var textDecoration: TextDecoration? = null - - colorCodes.forEach { codeStr -> - when (codeStr) { - "0" -> return null // Reset - "1" -> fontWeight = FontWeight.Bold - "3" -> fontStyle = FontStyle.Italic - "4" -> textDecoration = TextDecoration.Underline - "30" -> color = Color.Black - "31" -> color = logRed - "32" -> color = logGreen - "33" -> color = logYellow - "34" -> color = logBlue - "35" -> color = logPurple - "36" -> color = logBlueLight - "37" -> color = logWhite - else -> { - val codeInt = codeStr.toIntOrNull() - if (codeInt != null && codeInt in 38..125) { - val adjustedCode = codeInt % 125 - val row = adjustedCode / 36 - val column = adjustedCode % 36 - color = - Color( - red = row * 51, - green = (column / 6) * 51, - blue = (column % 6) * 51, - ) - } - } - } - } - - return if (color != null || fontWeight != null || fontStyle != null || textDecoration != null) { - SpanStyle( - color = color ?: Color.Unspecified, - fontWeight = fontWeight, - fontStyle = fontStyle, - textDecoration = textDecoration, - ) - } else { - null - } - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/MaterialIconsLibrary.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/MaterialIconsLibrary.kt deleted file mode 100644 index b6bb873c3c..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/MaterialIconsLibrary.kt +++ /dev/null @@ -1,434 +0,0 @@ -package io.nekohasekai.sfa.compose.util - -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.* -import androidx.compose.material.icons.filled.* -import androidx.compose.material.icons.outlined.* -import androidx.compose.material.icons.rounded.* -import androidx.compose.material.icons.sharp.* -import androidx.compose.material.icons.twotone.* -import androidx.compose.ui.graphics.vector.ImageVector - -data class IconCategory(val name: String, val icons: List) - -object MaterialIconsLibrary { - val categories = - listOf( - IconCategory( - "Security & Privacy", - listOf( - ProfileIcon("shield", Icons.Filled.Shield, "Shield"), - ProfileIcon("security", Icons.Filled.Security, "Security"), - ProfileIcon("lock", Icons.Filled.Lock, "Lock"), - ProfileIcon("lock_open", Icons.Filled.LockOpen, "Lock Open"), - ProfileIcon("vpn_key", Icons.Filled.VpnKey, "VPN Key"), - ProfileIcon("vpn_lock", Icons.Filled.VpnLock, "VPN Lock"), - ProfileIcon("key", Icons.Filled.Key, "Key"), - ProfileIcon("password", Icons.Filled.Password, "Password"), - ProfileIcon("fingerprint", Icons.Filled.Fingerprint, "Fingerprint"), - ProfileIcon("verified_user", Icons.Filled.VerifiedUser, "Verified"), - ProfileIcon("privacy_tip", Icons.Filled.PrivacyTip, "Privacy"), - ProfileIcon("admin_panel", Icons.Filled.AdminPanelSettings, "Admin"), - ProfileIcon("policy", Icons.Filled.Policy, "Policy"), - ProfileIcon("gpp_good", Icons.Filled.GppGood, "Protected"), - ProfileIcon("gpp_maybe", Icons.Filled.GppMaybe, "Maybe Protected"), - ProfileIcon("enhanced_encryption", Icons.Filled.EnhancedEncryption, "Encryption"), - ProfileIcon("no_encryption", Icons.Filled.NoEncryption, "No Encryption"), - ProfileIcon("https", Icons.Filled.Https, "HTTPS"), - ProfileIcon("http", Icons.Filled.Http, "HTTP"), - ProfileIcon("safety_check", Icons.Filled.SafetyCheck, "Safety Check"), - ), - ), - IconCategory( - "Network & Connection", - listOf( - ProfileIcon("wifi", Icons.Filled.Wifi, "WiFi"), - ProfileIcon("wifi_off", Icons.Filled.WifiOff, "WiFi Off"), - ProfileIcon("wifi_lock", Icons.Filled.WifiLock, "WiFi Lock"), - ProfileIcon("wifi_tethering", Icons.Filled.WifiTethering, "Tethering"), - ProfileIcon("signal_wifi_4_bar", Icons.Filled.SignalWifi4Bar, "Strong WiFi"), - ProfileIcon("signal_wifi_bad", Icons.Filled.SignalWifiBad, "Bad WiFi"), - ProfileIcon("router", Icons.Filled.Router, "Router"), - ProfileIcon("network_check", Icons.Filled.NetworkCheck, "Network Check"), - ProfileIcon("network_locked", Icons.Filled.NetworkLocked, "Network Locked"), - ProfileIcon("network_ping", Icons.Filled.NetworkPing, "Network Ping"), - ProfileIcon("hub", Icons.Filled.Hub, "Hub"), - ProfileIcon("dns", Icons.Filled.Dns, "DNS"), - ProfileIcon("lan", Icons.Filled.Lan, "LAN"), - ProfileIcon("cable", Icons.Filled.Cable, "Cable"), - ProfileIcon("settings_ethernet", Icons.Filled.SettingsEthernet, "Ethernet"), - ProfileIcon("cell_tower", Icons.Filled.CellTower, "Cell Tower"), - ProfileIcon("cell_wifi", Icons.Filled.CellWifi, "Cell WiFi"), - ProfileIcon("signal_cellular_4_bar", Icons.Filled.SignalCellular4Bar, "4G"), - ProfileIcon("signal_cellular_alt", Icons.Filled.SignalCellularAlt, "Cellular"), - // Some newer icons might not be available in all versions - // ProfileIcon("5g", Icons.Filled.FiveG, "5G"), - // ProfileIcon("4g_mobiledata", Icons.Filled.FourGMobiledata, "4G"), - // ProfileIcon("lte_mobiledata", Icons.Filled.LteMobiledata, "LTE") - ), - ), - IconCategory( - "Global & Cloud", - listOf( - ProfileIcon("language", Icons.Filled.Language, "Globe"), - ProfileIcon("public", Icons.Filled.Public, "Public"), - ProfileIcon("public_off", Icons.Filled.PublicOff, "Public Off"), - ProfileIcon("travel_explore", Icons.Filled.TravelExplore, "Explore"), - ProfileIcon("cloud", Icons.Filled.Cloud, "Cloud"), - ProfileIcon("cloud_upload", Icons.Filled.CloudUpload, "Cloud Upload"), - ProfileIcon("cloud_download", Icons.Filled.CloudDownload, "Cloud Download"), - ProfileIcon("cloud_sync", Icons.Filled.CloudSync, "Cloud Sync"), - ProfileIcon("cloud_done", Icons.Filled.CloudDone, "Cloud Done"), - ProfileIcon("cloud_off", Icons.Filled.CloudOff, "Cloud Off"), - ProfileIcon("cloud_queue", Icons.Filled.CloudQueue, "Cloud Queue"), - ProfileIcon("backup", Icons.Filled.Backup, "Backup"), - ProfileIcon("satellite", Icons.Filled.Satellite, "Satellite"), - ProfileIcon("satellite_alt", Icons.Filled.SatelliteAlt, "Satellite Alt"), - ProfileIcon("share", Icons.Filled.Share, "Share"), - ProfileIcon("share_location", Icons.Filled.ShareLocation, "Share Location"), - ProfileIcon("sync", Icons.Filled.Sync, "Sync"), - ProfileIcon("sync_alt", Icons.Filled.SyncAlt, "Sync Alt"), - ), - ), - IconCategory( - "Devices", - listOf( - ProfileIcon("computer", Icons.Filled.Computer, "Computer"), - ProfileIcon("desktop_windows", Icons.Filled.DesktopWindows, "Desktop"), - ProfileIcon("laptop", Icons.Filled.Laptop, "Laptop"), - ProfileIcon("laptop_chromebook", Icons.Filled.LaptopChromebook, "Chromebook"), - ProfileIcon("laptop_mac", Icons.Filled.LaptopMac, "MacBook"), - ProfileIcon("laptop_windows", Icons.Filled.LaptopWindows, "Windows Laptop"), - ProfileIcon("smartphone", Icons.Filled.Smartphone, "Phone"), - ProfileIcon("phone_android", Icons.Filled.PhoneAndroid, "Android"), - ProfileIcon("phone_iphone", Icons.Filled.PhoneIphone, "iPhone"), - ProfileIcon("tablet", Icons.Filled.Tablet, "Tablet"), - ProfileIcon("tablet_android", Icons.Filled.TabletAndroid, "Android Tablet"), - ProfileIcon("tablet_mac", Icons.Filled.TabletMac, "iPad"), - ProfileIcon("watch", Icons.Filled.Watch, "Watch"), - ProfileIcon("tv", Icons.Filled.Tv, "TV"), - ProfileIcon("smart_display", Icons.Filled.SmartDisplay, "Smart Display"), - ProfileIcon("speaker", Icons.Filled.Speaker, "Speaker"), - ProfileIcon("headphones", Icons.Filled.Headphones, "Headphones"), - ProfileIcon("devices", Icons.Filled.Devices, "Devices"), - ProfileIcon("device_hub", Icons.Filled.DeviceHub, "Device Hub"), - ProfileIcon("cast", Icons.Filled.Cast, "Cast"), - ProfileIcon("cast_connected", Icons.Filled.CastConnected, "Cast Connected"), - ), - ), - IconCategory( - "Places & Activities", - listOf( - ProfileIcon("home", Icons.Filled.Home, "Home"), - ProfileIcon("house", Icons.Filled.House, "House"), - ProfileIcon("cabin", Icons.Filled.Cabin, "Cabin"), - ProfileIcon("apartment", Icons.Filled.Apartment, "Apartment"), - ProfileIcon("work", Icons.Filled.Work, "Work"), - ProfileIcon("work_outline", Icons.Outlined.Work, "Work Outline"), - ProfileIcon("business", Icons.Filled.Business, "Business"), - ProfileIcon("business_center", Icons.Filled.BusinessCenter, "Business Center"), - ProfileIcon("school", Icons.Filled.School, "School"), - ProfileIcon("local_library", Icons.Filled.LocalLibrary, "Library"), - ProfileIcon("store", Icons.Filled.Store, "Store"), - ProfileIcon("storefront", Icons.Filled.Storefront, "Storefront"), - ProfileIcon("restaurant", Icons.Filled.Restaurant, "Restaurant"), - ProfileIcon("coffee", Icons.Filled.Coffee, "Coffee"), - ProfileIcon("local_cafe", Icons.Filled.LocalCafe, "Cafe"), - ProfileIcon("hotel", Icons.Filled.Hotel, "Hotel"), - ProfileIcon("flight", Icons.Filled.Flight, "Flight"), - ProfileIcon("flight_takeoff", Icons.Filled.FlightTakeoff, "Takeoff"), - ProfileIcon("flight_land", Icons.Filled.FlightLand, "Landing"), - ProfileIcon("train", Icons.Filled.Train, "Train"), - ProfileIcon("directions_car", Icons.Filled.DirectionsCar, "Car"), - ProfileIcon("directions_bus", Icons.Filled.DirectionsBus, "Bus"), - ProfileIcon("directions_subway", Icons.Filled.DirectionsSubway, "Subway"), - ProfileIcon("beach_access", Icons.Filled.BeachAccess, "Beach"), - ProfileIcon("park", Icons.Filled.Park, "Park"), - ProfileIcon("fitness_center", Icons.Filled.FitnessCenter, "Gym"), - ProfileIcon("sports_esports", Icons.Filled.SportsEsports, "Gaming"), - ProfileIcon("stadium", Icons.Filled.Stadium, "Stadium"), - ), - ), - IconCategory( - "Communication", - listOf( - ProfileIcon("email", Icons.Filled.Email, "Email"), - ProfileIcon("mail", Icons.Filled.Mail, "Mail"), - ProfileIcon("message", Icons.Filled.Message, "Message"), - ProfileIcon("chat", Icons.Filled.Chat, "Chat"), - ProfileIcon("chat_bubble", Icons.Filled.ChatBubble, "Chat Bubble"), - ProfileIcon("forum", Icons.Filled.Forum, "Forum"), - ProfileIcon("comment", Icons.Filled.Comment, "Comment"), - ProfileIcon("call", Icons.Filled.Call, "Call"), - ProfileIcon("video_call", Icons.Filled.VideoCall, "Video Call"), - ProfileIcon("contacts", Icons.Filled.Contacts, "Contacts"), - ProfileIcon("contact_mail", Icons.Filled.ContactMail, "Contact Mail"), - ProfileIcon("contact_phone", Icons.Filled.ContactPhone, "Contact Phone"), - ProfileIcon("notifications", Icons.Filled.Notifications, "Notifications"), - ProfileIcon("notifications_active", Icons.Filled.NotificationsActive, "Active Notif"), - ProfileIcon("notification_important", Icons.Filled.NotificationImportant, "Important"), - ProfileIcon("announcement", Icons.Filled.Announcement, "Announcement"), - ), - ), - IconCategory( - "Media & Entertainment", - listOf( - ProfileIcon("play_arrow", Icons.Filled.PlayArrow, "Play"), - ProfileIcon("play_circle", Icons.Filled.PlayCircle, "Play Circle"), - ProfileIcon("pause", Icons.Filled.Pause, "Pause"), - ProfileIcon("pause_circle", Icons.Filled.PauseCircle, "Pause Circle"), - ProfileIcon("stop", Icons.Filled.Stop, "Stop"), - ProfileIcon("skip_next", Icons.Filled.SkipNext, "Next"), - ProfileIcon("skip_previous", Icons.Filled.SkipPrevious, "Previous"), - ProfileIcon("music_note", Icons.Filled.MusicNote, "Music"), - ProfileIcon("audiotrack", Icons.Filled.Audiotrack, "Audio"), - ProfileIcon("album", Icons.Filled.Album, "Album"), - ProfileIcon("mic", Icons.Filled.Mic, "Microphone"), - ProfileIcon("videocam", Icons.Filled.Videocam, "Video"), - ProfileIcon("movie", Icons.Filled.Movie, "Movie"), - ProfileIcon("theaters", Icons.Filled.Theaters, "Theater"), - ProfileIcon("live_tv", Icons.Filled.LiveTv, "Live TV"), - ProfileIcon("photo", Icons.Filled.Photo, "Photo"), - ProfileIcon("photo_camera", Icons.Filled.PhotoCamera, "Camera"), - ProfileIcon("photo_library", Icons.Filled.PhotoLibrary, "Gallery"), - ProfileIcon("games", Icons.Filled.Games, "Games"), - ProfileIcon("sports_soccer", Icons.Filled.SportsSoccer, "Soccer"), - ProfileIcon("sports_basketball", Icons.Filled.SportsBasketball, "Basketball"), - ProfileIcon("sports_football", Icons.Filled.SportsFootball, "Football"), - ), - ), - IconCategory( - "Files & Folders", - listOf( - ProfileIcon("folder", Icons.Filled.Folder, "Folder"), - ProfileIcon("folder_open", Icons.Filled.FolderOpen, "Folder Open"), - ProfileIcon("folder_shared", Icons.Filled.FolderShared, "Shared Folder"), - ProfileIcon("folder_special", Icons.Filled.FolderSpecial, "Special Folder"), - ProfileIcon("create_new_folder", Icons.Filled.CreateNewFolder, "New Folder"), - ProfileIcon("insert_drive_file", Icons.AutoMirrored.Filled.InsertDriveFile, "File"), - ProfileIcon("description", Icons.Filled.Description, "Document"), - ProfileIcon("article", Icons.AutoMirrored.Filled.Article, "Article"), - ProfileIcon("picture_as_pdf", Icons.Filled.PictureAsPdf, "PDF"), - ProfileIcon("attach_file", Icons.Filled.AttachFile, "Attachment"), - ProfileIcon("file_download", Icons.Filled.FileDownload, "Download"), - ProfileIcon("file_upload", Icons.Filled.FileUpload, "Upload"), - ProfileIcon("file_copy", Icons.Filled.FileCopy, "Copy"), - ProfileIcon("content_copy", Icons.Filled.ContentCopy, "Copy Content"), - ProfileIcon("content_paste", Icons.Filled.ContentPaste, "Paste"), - ProfileIcon("save", Icons.Filled.Save, "Save"), - ProfileIcon("save_alt", Icons.Filled.SaveAlt, "Save Alt"), - ProfileIcon("archive", Icons.Filled.Archive, "Archive"), - ProfileIcon("inventory", Icons.Filled.Inventory, "Inventory"), - ProfileIcon("storage", Icons.Filled.Storage, "Storage"), - ), - ), - IconCategory( - "Actions & Tools", - listOf( - ProfileIcon("settings", Icons.Filled.Settings, "Settings"), - ProfileIcon("build", Icons.Filled.Build, "Build"), - ProfileIcon("extension", Icons.Filled.Extension, "Extension"), - ProfileIcon("search", Icons.Filled.Search, "Search"), - ProfileIcon("zoom_in", Icons.Filled.ZoomIn, "Zoom In"), - ProfileIcon("zoom_out", Icons.Filled.ZoomOut, "Zoom Out"), - ProfileIcon("info", Icons.Filled.Info, "Info"), - ProfileIcon("help", Icons.Filled.Help, "Help"), - ProfileIcon("help_center", Icons.Filled.HelpCenter, "Help Center"), - ProfileIcon("explore", Icons.Filled.Explore, "Explore"), - ProfileIcon("bookmark", Icons.Filled.Bookmark, "Bookmark"), - ProfileIcon("bookmarks", Icons.Filled.Bookmarks, "Bookmarks"), - ProfileIcon("history", Icons.Filled.History, "History"), - ProfileIcon("schedule", Icons.Filled.Schedule, "Schedule"), - ProfileIcon("alarm", Icons.Filled.Alarm, "Alarm"), - ProfileIcon("timer", Icons.Filled.Timer, "Timer"), - ProfileIcon("update", Icons.Filled.Update, "Update"), - ProfileIcon("upgrade", Icons.Filled.Upgrade, "Upgrade"), - ProfileIcon("autorenew", Icons.Filled.Autorenew, "Auto Renew"), - ProfileIcon("cached", Icons.Filled.Cached, "Cached"), - ProfileIcon("refresh", Icons.Filled.Refresh, "Refresh"), - ProfileIcon("sync_problem", Icons.Filled.SyncProblem, "Sync Problem"), - ProfileIcon("download", Icons.Filled.Download, "Download"), - ProfileIcon("upload", Icons.Filled.Upload, "Upload"), - ProfileIcon("print", Icons.Filled.Print, "Print"), - ProfileIcon("delete", Icons.Filled.Delete, "Delete"), - ), - ), - IconCategory( - "Status & Indicators", - listOf( - ProfileIcon("check", Icons.Filled.Check, "Check"), - ProfileIcon("check_circle", Icons.Filled.CheckCircle, "Check Circle"), - ProfileIcon("verified", Icons.Filled.Verified, "Verified"), - ProfileIcon("done", Icons.Filled.Done, "Done"), - ProfileIcon("done_all", Icons.Filled.DoneAll, "Done All"), - ProfileIcon("close", Icons.Filled.Close, "Close"), - ProfileIcon("cancel", Icons.Filled.Cancel, "Cancel"), - ProfileIcon("error", Icons.Filled.Error, "Error"), - ProfileIcon("warning", Icons.Filled.Warning, "Warning"), - ProfileIcon("report", Icons.Filled.Report, "Report"), - ProfileIcon("flag", Icons.Filled.Flag, "Flag"), - ProfileIcon("star", Icons.Filled.Star, "Star"), - ProfileIcon("star_half", Icons.Filled.StarHalf, "Half Star"), - ProfileIcon("star_outline", Icons.Filled.StarOutline, "Star Outline"), - ProfileIcon("favorite", Icons.Filled.Favorite, "Favorite"), - ProfileIcon("favorite_border", Icons.Filled.FavoriteBorder, "Favorite Border"), - ProfileIcon("thumb_up", Icons.Filled.ThumbUp, "Like"), - ProfileIcon("thumb_down", Icons.Filled.ThumbDown, "Dislike"), - ProfileIcon("priority_high", Icons.Filled.PriorityHigh, "High Priority"), - ProfileIcon("new_releases", Icons.Filled.NewReleases, "New"), - ProfileIcon("fiber_new", Icons.Filled.FiberNew, "New Badge"), - ProfileIcon("offline_pin", Icons.Filled.OfflinePin, "Offline"), - ProfileIcon("online_prediction", Icons.Filled.OnlinePrediction, "Online"), - ), - ), - IconCategory( - "Nature & Weather", - listOf( - ProfileIcon("wb_sunny", Icons.Filled.WbSunny, "Sunny"), - ProfileIcon("nights_stay", Icons.Filled.NightsStay, "Night"), - ProfileIcon("brightness_high", Icons.Filled.BrightnessHigh, "Bright"), - ProfileIcon("wb_cloudy", Icons.Filled.WbCloudy, "Cloudy"), - ProfileIcon("cloud", Icons.Filled.Cloud, "Cloud"), - ProfileIcon("ac_unit", Icons.Filled.AcUnit, "Snow"), - ProfileIcon("thunderstorm", Icons.Filled.Thunderstorm, "Storm"), - ProfileIcon("water_drop", Icons.Filled.WaterDrop, "Water"), - ProfileIcon("waves", Icons.Filled.Waves, "Waves"), - ProfileIcon("eco", Icons.Filled.Eco, "Eco"), - ProfileIcon("nature", Icons.Filled.Nature, "Nature"), - ProfileIcon("nature_people", Icons.Filled.NaturePeople, "Nature People"), - ProfileIcon("forest", Icons.Filled.Forest, "Forest"), - ProfileIcon("grass", Icons.Filled.Grass, "Grass"), - ProfileIcon("local_florist", Icons.Filled.LocalFlorist, "Flower"), - ProfileIcon("pets", Icons.Filled.Pets, "Pets"), - ProfileIcon("bug_report", Icons.Filled.BugReport, "Bug"), - ProfileIcon("spa", Icons.Filled.Spa, "Spa"), - ProfileIcon("pool", Icons.Filled.Pool, "Pool"), - ProfileIcon("hot_tub", Icons.Filled.HotTub, "Hot Tub"), - ), - ), - IconCategory( - "Transportation", - listOf( - ProfileIcon("local_shipping", Icons.Filled.LocalShipping, "Shipping"), - ProfileIcon("local_taxi", Icons.Filled.LocalTaxi, "Taxi"), - ProfileIcon("directions_bike", Icons.Filled.DirectionsBike, "Bike"), - ProfileIcon("directions_boat", Icons.Filled.DirectionsBoat, "Boat"), - ProfileIcon("directions_railway", Icons.Filled.DirectionsRailway, "Railway"), - ProfileIcon("directions_transit", Icons.Filled.DirectionsTransit, "Transit"), - ProfileIcon("directions_walk", Icons.Filled.DirectionsWalk, "Walk"), - ProfileIcon("directions_run", Icons.Filled.DirectionsRun, "Run"), - ProfileIcon("electric_car", Icons.Filled.ElectricCar, "Electric Car"), - ProfileIcon("electric_bike", Icons.Filled.ElectricBike, "E-Bike"), - ProfileIcon("electric_scooter", Icons.Filled.ElectricScooter, "E-Scooter"), - ProfileIcon("two_wheeler", Icons.Filled.TwoWheeler, "Two Wheeler"), - ProfileIcon("motorcycle", Icons.Filled.Motorcycle, "Motorcycle"), - ProfileIcon("airport_shuttle", Icons.Filled.AirportShuttle, "Shuttle"), - ProfileIcon("commute", Icons.Filled.Commute, "Commute"), - ProfileIcon("rocket", Icons.Filled.Rocket, "Rocket"), - ProfileIcon("rocket_launch", Icons.Filled.RocketLaunch, "Rocket Launch"), - ProfileIcon("sailing", Icons.Filled.Sailing, "Sailing"), - ), - ), - IconCategory( - "Shopping & Finance", - listOf( - ProfileIcon("shopping_cart", Icons.Filled.ShoppingCart, "Cart"), - ProfileIcon("shopping_bag", Icons.Filled.ShoppingBag, "Shopping Bag"), - ProfileIcon("shopping_basket", Icons.Filled.ShoppingBasket, "Basket"), - ProfileIcon("add_shopping_cart", Icons.Filled.AddShoppingCart, "Add to Cart"), - ProfileIcon("local_mall", Icons.Filled.LocalMall, "Mall"), - ProfileIcon("local_grocery_store", Icons.Filled.LocalGroceryStore, "Grocery"), - ProfileIcon("payment", Icons.Filled.Payment, "Payment"), - ProfileIcon("credit_card", Icons.Filled.CreditCard, "Credit Card"), - ProfileIcon("account_balance", Icons.Filled.AccountBalance, "Bank"), - ProfileIcon("account_balance_wallet", Icons.Filled.AccountBalanceWallet, "Wallet"), - ProfileIcon("wallet", Icons.Filled.Wallet, "Wallet"), - ProfileIcon("savings", Icons.Filled.Savings, "Savings"), - ProfileIcon("attach_money", Icons.Filled.AttachMoney, "Money"), - ProfileIcon("money", Icons.Filled.Money, "Cash"), - ProfileIcon("paid", Icons.Filled.Paid, "Paid"), - ProfileIcon("currency_bitcoin", Icons.Filled.CurrencyBitcoin, "Bitcoin"), - ProfileIcon("currency_exchange", Icons.Filled.CurrencyExchange, "Exchange"), - ProfileIcon("receipt", Icons.Filled.Receipt, "Receipt"), - ProfileIcon("receipt_long", Icons.Filled.ReceiptLong, "Receipt Long"), - ProfileIcon("sell", Icons.Filled.Sell, "Sell"), - ProfileIcon("discount", Icons.Filled.Discount, "Discount"), - ProfileIcon("redeem", Icons.Filled.Redeem, "Redeem"), - ), - ), - IconCategory( - "Health & Wellness", - listOf( - ProfileIcon("medical_services", Icons.Filled.MedicalServices, "Medical"), - ProfileIcon("medication", Icons.Filled.Medication, "Medication"), - ProfileIcon("vaccines", Icons.Filled.Vaccines, "Vaccine"), - ProfileIcon("healing", Icons.Filled.Healing, "Healing"), - ProfileIcon("health_and_safety", Icons.Filled.HealthAndSafety, "Health & Safety"), - ProfileIcon("local_hospital", Icons.Filled.LocalHospital, "Hospital"), - ProfileIcon("local_pharmacy", Icons.Filled.LocalPharmacy, "Pharmacy"), - ProfileIcon("monitor_heart", Icons.Filled.MonitorHeart, "Heart Monitor"), - ProfileIcon("bloodtype", Icons.Filled.Bloodtype, "Blood Type"), - ProfileIcon("emergency", Icons.Filled.Emergency, "Emergency"), - ProfileIcon("medical_information", Icons.Filled.MedicalInformation, "Medical Info"), - ProfileIcon("psychology", Icons.Filled.Psychology, "Psychology"), - ProfileIcon("self_improvement", Icons.Filled.SelfImprovement, "Self Improvement"), - ProfileIcon("mood", Icons.Filled.Mood, "Happy"), - ProfileIcon("mood_bad", Icons.Filled.MoodBad, "Sad"), - ProfileIcon("sentiment_satisfied", Icons.Filled.SentimentSatisfied, "Satisfied"), - ProfileIcon("sentiment_dissatisfied", Icons.Filled.SentimentDissatisfied, "Dissatisfied"), - ProfileIcon("sick", Icons.Filled.Sick, "Sick"), - ProfileIcon("masks", Icons.Filled.Masks, "Masks"), - ProfileIcon("sanitizer", Icons.Filled.Sanitizer, "Sanitizer"), - ProfileIcon("clean_hands", Icons.Filled.CleanHands, "Clean Hands"), - ProfileIcon("coronavirus", Icons.Filled.Coronavirus, "Virus"), - ), - ), - IconCategory( - "Food & Dining", - listOf( - ProfileIcon("restaurant_menu", Icons.Filled.RestaurantMenu, "Menu"), - ProfileIcon("fastfood", Icons.Filled.Fastfood, "Fast Food"), - ProfileIcon("lunch_dining", Icons.Filled.LunchDining, "Lunch"), - ProfileIcon("dinner_dining", Icons.Filled.DinnerDining, "Dinner"), - ProfileIcon("breakfast_dining", Icons.Filled.BreakfastDining, "Breakfast"), - ProfileIcon("brunch_dining", Icons.Filled.BrunchDining, "Brunch"), - ProfileIcon("bakery_dining", Icons.Filled.BakeryDining, "Bakery"), - ProfileIcon("icecream", Icons.Filled.Icecream, "Ice Cream"), - ProfileIcon("cake", Icons.Filled.Cake, "Cake"), - ProfileIcon("local_pizza", Icons.Filled.LocalPizza, "Pizza"), - ProfileIcon("local_bar", Icons.Filled.LocalBar, "Bar"), - ProfileIcon("local_drink", Icons.Filled.LocalDrink, "Drink"), - ProfileIcon("liquor", Icons.Filled.Liquor, "Liquor"), - ProfileIcon("wine_bar", Icons.Filled.WineBar, "Wine"), - ProfileIcon("sports_bar", Icons.Filled.SportsBar, "Sports Bar"), - ProfileIcon("kitchen", Icons.Filled.Kitchen, "Kitchen"), - ProfileIcon("dining", Icons.Filled.Dining, "Dining"), - ProfileIcon("food_bank", Icons.Filled.FoodBank, "Food Bank"), - ProfileIcon("ramen_dining", Icons.Filled.RamenDining, "Ramen"), - ProfileIcon("rice_bowl", Icons.Filled.RiceBowl, "Rice Bowl"), - ProfileIcon("soup_kitchen", Icons.Filled.SoupKitchen, "Soup"), - ProfileIcon("takeout_dining", Icons.Filled.TakeoutDining, "Takeout"), - ProfileIcon("delivery_dining", Icons.Filled.DeliveryDining, "Delivery"), - ), - ), - ) - - fun getAllIcons(): List = categories.flatMap { it.icons } - - fun getIconById(id: String?): ImageVector? { - if (id == null) return null - return getAllIcons().find { it.id == id }?.icon - } - - fun getCategoryForIcon(iconId: String): String? = categories.find { category -> - category.icons.any { it.id == iconId } - }?.name - - fun searchIcons(query: String): List { - val lowercaseQuery = query.lowercase() - return getAllIcons().filter { icon -> - icon.id.contains(lowercaseQuery) || - icon.label.lowercase().contains(lowercaseQuery) - } - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/ProfileIcons.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/ProfileIcons.kt deleted file mode 100644 index 6ec38ace2b..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/ProfileIcons.kt +++ /dev/null @@ -1,30 +0,0 @@ -package io.nekohasekai.sfa.compose.util - -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.InsertDriveFile -import androidx.compose.ui.graphics.vector.ImageVector -import io.nekohasekai.sfa.compose.util.icons.MaterialIconsLibrary - -data class ProfileIcon(val id: String, val icon: ImageVector, val label: String) - -object ProfileIcons { - // Use the complete Material Icons library with all available icons - val availableIcons: List - get() = MaterialIconsLibrary.getAllIcons() - - fun getIconById(id: String?): ImageVector? { - if (id == null) return null - return MaterialIconsLibrary.getIconById(id) - } - - fun getDefaultIconForType(isRemote: Boolean): ImageVector { - // Use the same default icon for all profile types - return Icons.AutoMirrored.Default.InsertDriveFile - } - - fun getCategoryForIcon(iconId: String): String? = MaterialIconsLibrary.getCategoryForIcon(iconId) - - fun searchIcons(query: String): List = MaterialIconsLibrary.searchIcons(query) - - fun getCategories() = MaterialIconsLibrary.categories -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/QRCodeGenerator.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/QRCodeGenerator.kt deleted file mode 100644 index e1f717e2f4..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/QRCodeGenerator.kt +++ /dev/null @@ -1,114 +0,0 @@ -package io.nekohasekai.sfa.compose.util - -import android.graphics.Bitmap -import android.graphics.Color -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.graphics.toArgb -import com.google.zxing.BarcodeFormat -import com.google.zxing.qrcode.QRCodeWriter - -object QRCodeGenerator { - - private fun luminance(color: Int): Float { - val r = Color.red(color) / 255f - val g = Color.green(color) / 255f - val b = Color.blue(color) / 255f - return 0.299f * r + 0.587f * g + 0.114f * b - } - - private fun adjustBrightness(color: Int, factor: Float): Int { - val a = Color.alpha(color) - val r = (Color.red(color) * factor).toInt().coerceIn(0, 255) - val g = (Color.green(color) * factor).toInt().coerceIn(0, 255) - val b = (Color.blue(color) * factor).toInt().coerceIn(0, 255) - return Color.argb(a, r, g, b) - } - - fun ensureContrast(foreground: Int, background: Int, minRatio: Float = 4.5f): Int { - val bgLum = luminance(background) - var fg = foreground - var fgLum = luminance(fg) - - var ratio = if (fgLum > bgLum) { - (fgLum + 0.05f) / (bgLum + 0.05f) - } else { - (bgLum + 0.05f) / (fgLum + 0.05f) - } - - if (ratio >= minRatio) return fg - - val shouldDarken = bgLum > 0.5f - repeat(10) { - fg = if (shouldDarken) { - adjustBrightness(fg, 0.8f) - } else { - adjustBrightness(fg, 1.25f) - } - fgLum = luminance(fg) - ratio = if (fgLum > bgLum) { - (fgLum + 0.05f) / (bgLum + 0.05f) - } else { - (bgLum + 0.05f) / (fgLum + 0.05f) - } - if (ratio >= minRatio) return fg - } - return fg - } - - @Composable - fun rememberBitmap(content: String, size: Int = 512): Bitmap { - val isDarkTheme = isSystemInDarkTheme() - return remember(content, isDarkTheme) { - generate( - content = content, - size = size, - foregroundColor = if (isDarkTheme) Color.WHITE else Color.BLACK, - backgroundColor = Color.TRANSPARENT, - ) - } - } - - @Composable - fun rememberPrimaryBitmap(content: String, size: Int = 512, backgroundColor: Int): Bitmap { - val primaryColor = MaterialTheme.colorScheme.primary.toArgb() - val safeColor = remember(primaryColor, backgroundColor) { - ensureContrast(primaryColor, backgroundColor) - } - return remember(content, safeColor) { - generate( - content = content, - size = size, - foregroundColor = safeColor, - backgroundColor = Color.TRANSPARENT, - ) - } - } - - fun generate(content: String, size: Int = 512, foregroundColor: Int = Color.BLACK, backgroundColor: Int = Color.WHITE): Bitmap { - val writer = QRCodeWriter() - val bitMatrix = writer.encode(content, BarcodeFormat.QR_CODE, size, size) - - val width = bitMatrix.width - val height = bitMatrix.height - val pixels = IntArray(width * height) - - for (y in 0 until height) { - val offset = y * width - for (x in 0 until width) { - pixels[offset + x] = - if (bitMatrix.get(x, y)) { - foregroundColor - } else { - backgroundColor - } - } - } - - return Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888).apply { - setPixels(pixels, 0, width, 0, 0, width, height) - } - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/RelativeTimeFormatter.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/RelativeTimeFormatter.kt deleted file mode 100644 index 3513a6c402..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/RelativeTimeFormatter.kt +++ /dev/null @@ -1,92 +0,0 @@ -package io.nekohasekai.sfa.compose.util - -import android.content.Context -import io.nekohasekai.sfa.R -import java.text.DateFormat -import java.util.Date -import java.util.concurrent.TimeUnit - -object RelativeTimeFormatter { - /** - * Formats a date as relative time for recent dates (within 7 days) - * or as full date/time for older dates. - */ - fun format(context: Context, date: Date?): String { - if (date == null) return "" - - val now = System.currentTimeMillis() - val diff = now - date.time - - // Handle negative differences (future dates) - if (diff < 0) { - return DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(date) - } - - val seconds = TimeUnit.MILLISECONDS.toSeconds(diff) - val minutes = TimeUnit.MILLISECONDS.toMinutes(diff) - val hours = TimeUnit.MILLISECONDS.toHours(diff) - val days = TimeUnit.MILLISECONDS.toDays(diff) - - return when { - seconds < 60 -> context.getString(R.string.time_just_now) - minutes < 60 -> - context.resources.getQuantityString( - R.plurals.time_minutes_ago, - minutes.toInt(), - minutes, - ) - hours < 24 -> - context.resources.getQuantityString( - R.plurals.time_hours_ago, - hours.toInt(), - hours, - ) - days == 1L -> context.getString(R.string.time_yesterday) - days < 7 -> - context.resources.getQuantityString( - R.plurals.time_days_ago, - days.toInt(), - days, - ) - else -> DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(date) - } - } - - /** - * Formats a date as short relative time for compact displays. - * Uses shorter format like "2h" instead of "2 hours ago". - */ - fun formatShort(context: Context, date: Date?): String { - if (date == null) return "" - - val now = System.currentTimeMillis() - val diff = now - date.time - - // Handle negative differences (future dates) - if (diff < 0) { - return DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT).format(date) - } - - val seconds = TimeUnit.MILLISECONDS.toSeconds(diff) - val minutes = TimeUnit.MILLISECONDS.toMinutes(diff) - val hours = TimeUnit.MILLISECONDS.toHours(diff) - val days = TimeUnit.MILLISECONDS.toDays(diff) - - return when { - seconds < 60 -> context.getString(R.string.time_now) - minutes < 60 -> context.getString(R.string.time_minutes_short, minutes) - hours < 24 -> context.getString(R.string.time_hours_short, hours) - days == 1L -> context.getString(R.string.time_yesterday_short) - days < 7 -> context.getString(R.string.time_days_short, days) - else -> DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT).format(date) - } - } - - /** - * Gets the exact date/time string for tooltips or detailed views. - */ - fun formatExact(date: Date?): String { - if (date == null) return "" - return DateFormat.getDateTimeInstance(DateFormat.FULL, DateFormat.MEDIUM).format(date) - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/SheetNestedScroll.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/SheetNestedScroll.kt deleted file mode 100644 index 3be3768aff..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/SheetNestedScroll.kt +++ /dev/null @@ -1,58 +0,0 @@ -package io.nekohasekai.sfa.compose.util - -import androidx.compose.foundation.gestures.awaitEachGesture -import androidx.compose.foundation.gestures.awaitFirstDown -import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.input.nestedscroll.NestedScrollConnection -import androidx.compose.ui.input.nestedscroll.NestedScrollSource -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.unit.Velocity - -@Composable -fun rememberSheetDismissFromContentOnlyIfGestureStartedAtTopModifier(isAtTop: () -> Boolean): Modifier { - val isAtTopState = rememberUpdatedState(isAtTop) - val gestureStartedAtTop = remember { mutableStateOf(true) } - - val nestedScrollConnection = - remember { - object : NestedScrollConnection { - override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset { - if (source != NestedScrollSource.UserInput) return Offset.Zero - val startedAtTop = gestureStartedAtTop.value - return when { - available.y < 0 -> available - available.y > 0 && !startedAtTop -> available - else -> Offset.Zero - } - } - - override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { - val startedAtTop = gestureStartedAtTop.value - return when { - available.y < 0 -> available - available.y > 0 && !startedAtTop -> available - else -> Velocity.Zero - } - } - } - } - - val gestureGateModifier = - Modifier.pointerInput(Unit) { - awaitEachGesture { - awaitFirstDown(requireUnconsumed = false) - gestureStartedAtTop.value = isAtTopState.value.invoke() - do { - val event = awaitPointerEvent() - } while (event.changes.any { it.pressed }) - } - } - - return gestureGateModifier.nestedScroll(nestedScrollConnection) -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/AVIcons.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/AVIcons.kt deleted file mode 100644 index 46fd80b48d..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/AVIcons.kt +++ /dev/null @@ -1,306 +0,0 @@ -package io.nekohasekai.sfa.compose.util.icons - -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.FeaturedPlayList -import androidx.compose.material.icons.automirrored.filled.FeaturedVideo -import androidx.compose.material.icons.automirrored.filled.Note -import androidx.compose.material.icons.automirrored.filled.QueueMusic -import androidx.compose.material.icons.automirrored.filled.VolumeDown -import androidx.compose.material.icons.automirrored.filled.VolumeMute -import androidx.compose.material.icons.automirrored.filled.VolumeOff -import androidx.compose.material.icons.automirrored.filled.VolumeUp -import androidx.compose.material.icons.filled.AddToQueue -import androidx.compose.material.icons.filled.Airplay -import androidx.compose.material.icons.filled.Album -import androidx.compose.material.icons.filled.ArtTrack -import androidx.compose.material.icons.filled.AudioFile -import androidx.compose.material.icons.filled.AvTimer -import androidx.compose.material.icons.filled.BrandingWatermark -import androidx.compose.material.icons.filled.CallToAction -import androidx.compose.material.icons.filled.ClosedCaption -import androidx.compose.material.icons.filled.ClosedCaptionDisabled -import androidx.compose.material.icons.filled.ClosedCaptionOff -import androidx.compose.material.icons.filled.ControlCamera -import androidx.compose.material.icons.filled.Equalizer -import androidx.compose.material.icons.filled.Explicit -import androidx.compose.material.icons.filled.FastForward -import androidx.compose.material.icons.filled.FastRewind -import androidx.compose.material.icons.filled.FiberDvr -import androidx.compose.material.icons.filled.FiberManualRecord -import androidx.compose.material.icons.filled.FiberNew -import androidx.compose.material.icons.filled.FiberPin -import androidx.compose.material.icons.filled.FiberSmartRecord -import androidx.compose.material.icons.filled.Forward10 -import androidx.compose.material.icons.filled.Forward30 -import androidx.compose.material.icons.filled.Forward5 -import androidx.compose.material.icons.filled.Games -import androidx.compose.material.icons.filled.Hd -import androidx.compose.material.icons.filled.Hearing -import androidx.compose.material.icons.filled.HearingDisabled -import androidx.compose.material.icons.filled.HighQuality -import androidx.compose.material.icons.filled.InterpreterMode -import androidx.compose.material.icons.filled.LibraryAdd -import androidx.compose.material.icons.filled.LibraryAddCheck -import androidx.compose.material.icons.filled.LibraryBooks -import androidx.compose.material.icons.filled.LibraryMusic -import androidx.compose.material.icons.filled.Loop -import androidx.compose.material.icons.filled.Lyrics -import androidx.compose.material.icons.filled.Mic -import androidx.compose.material.icons.filled.MicExternalOff -import androidx.compose.material.icons.filled.MicExternalOn -import androidx.compose.material.icons.filled.MicNone -import androidx.compose.material.icons.filled.MicOff -import androidx.compose.material.icons.filled.MissedVideoCall -import androidx.compose.material.icons.filled.Movie -import androidx.compose.material.icons.filled.MovieCreation -import androidx.compose.material.icons.filled.MovieFilter -import androidx.compose.material.icons.filled.MusicNote -import androidx.compose.material.icons.filled.MusicOff -import androidx.compose.material.icons.filled.MusicVideo -import androidx.compose.material.icons.filled.NewReleases -import androidx.compose.material.icons.filled.NotInterested -import androidx.compose.material.icons.filled.Pause -import androidx.compose.material.icons.filled.PauseCircle -import androidx.compose.material.icons.filled.PauseCircleFilled -import androidx.compose.material.icons.filled.PauseCircleOutline -import androidx.compose.material.icons.filled.PausePresentation -import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material.icons.filled.PlayCircle -import androidx.compose.material.icons.filled.PlayCircleFilled -import androidx.compose.material.icons.filled.PlayCircleOutline -import androidx.compose.material.icons.filled.PlayDisabled -import androidx.compose.material.icons.filled.PlayLesson -import androidx.compose.material.icons.filled.PlaylistAdd -import androidx.compose.material.icons.filled.PlaylistAddCheck -import androidx.compose.material.icons.filled.PlaylistAddCheckCircle -import androidx.compose.material.icons.filled.PlaylistAddCircle -import androidx.compose.material.icons.filled.PlaylistPlay -import androidx.compose.material.icons.filled.PlaylistRemove -import androidx.compose.material.icons.filled.Queue -import androidx.compose.material.icons.filled.QueuePlayNext -import androidx.compose.material.icons.filled.Radio -import androidx.compose.material.icons.filled.RecentActors -import androidx.compose.material.icons.filled.RemoveFromQueue -import androidx.compose.material.icons.filled.Repeat -import androidx.compose.material.icons.filled.RepeatOn -import androidx.compose.material.icons.filled.RepeatOne -import androidx.compose.material.icons.filled.RepeatOneOn -import androidx.compose.material.icons.filled.Replay -import androidx.compose.material.icons.filled.Replay10 -import androidx.compose.material.icons.filled.Replay30 -import androidx.compose.material.icons.filled.Replay5 -import androidx.compose.material.icons.filled.ReplayCircleFilled -import androidx.compose.material.icons.filled.Sd -import androidx.compose.material.icons.filled.SdCard -import androidx.compose.material.icons.filled.Shuffle -import androidx.compose.material.icons.filled.ShuffleOn -import androidx.compose.material.icons.filled.SkipNext -import androidx.compose.material.icons.filled.SkipPrevious -import androidx.compose.material.icons.filled.SlowMotionVideo -import androidx.compose.material.icons.filled.Snooze -import androidx.compose.material.icons.filled.SortByAlpha -import androidx.compose.material.icons.filled.Speed -import androidx.compose.material.icons.filled.Stop -import androidx.compose.material.icons.filled.StopCircle -import androidx.compose.material.icons.filled.StopScreenShare -import androidx.compose.material.icons.filled.Subscriptions -import androidx.compose.material.icons.filled.Subtitles -import androidx.compose.material.icons.filled.SurroundSound -import androidx.compose.material.icons.filled.VideoCall -import androidx.compose.material.icons.filled.VideoCameraBack -import androidx.compose.material.icons.filled.VideoCameraFront -import androidx.compose.material.icons.filled.VideoFile -import androidx.compose.material.icons.filled.VideoLabel -import androidx.compose.material.icons.filled.VideoLibrary -import androidx.compose.material.icons.filled.VideoSettings -import androidx.compose.material.icons.filled.VideoStable -import androidx.compose.material.icons.filled.Videocam -import androidx.compose.material.icons.filled.VideocamOff -import androidx.compose.material.icons.filled.VideogameAsset -import androidx.compose.material.icons.filled.VideogameAssetOff -import androidx.compose.material.icons.filled.Web -import androidx.compose.material.icons.filled.WebAsset -import androidx.compose.material.icons.filled.WebAssetOff -import io.nekohasekai.sfa.compose.util.ProfileIcon - -/** - * AV (Audio/Video) category icons - Media controls and playback - * Based on Google's Material Design Icons taxonomy - */ -object AVIcons { - val icons = - listOf( - // ProfileIcon("10k", Icons.Filled.TenK, "10K"), // Not available in compose-material-icons-extended - // ProfileIcon("10mp", Icons.Filled.TenMp, "10MP"), - // ProfileIcon("11mp", Icons.Filled.ElevenMp, "11MP"), - // ProfileIcon("12mp", Icons.Filled.TwelveMp, "12MP"), - // ProfileIcon("13mp", Icons.Filled.ThirteenMp, "13MP"), - // ProfileIcon("14mp", Icons.Filled.FourteenMp, "14MP"), - // ProfileIcon("15mp", Icons.Filled.FifteenMp, "15MP"), - // ProfileIcon("16mp", Icons.Filled.SixteenMp, "16MP"), - // ProfileIcon("17mp", Icons.Filled.SeventeenMp, "17MP"), - // ProfileIcon("18mp", Icons.Filled.EighteenMp, "18MP"), - // ProfileIcon("19mp", Icons.Filled.NineteenMp, "19MP"), - // ProfileIcon("1k", Icons.Filled.OneK, "1K"), - // ProfileIcon("1k_plus", Icons.Filled.OneKPlus, "1K+"), - // ProfileIcon("20mp", Icons.Filled.TwentyMp, "20MP"), - // ProfileIcon("21mp", Icons.Filled.TwentyOneMp, "21MP"), - // ProfileIcon("22mp", Icons.Filled.TwentyTwoMp, "22MP"), - // ProfileIcon("23mp", Icons.Filled.TwentyThreeMp, "23MP"), - // ProfileIcon("24mp", Icons.Filled.TwentyFourMp, "24MP"), - // ProfileIcon("2k", Icons.Filled.TwoK, "2K"), - // ProfileIcon("2k_plus", Icons.Filled.TwoKPlus, "2K+"), - // ProfileIcon("2mp", Icons.Filled.TwoMp, "2MP"), - // ProfileIcon("3k", Icons.Filled.ThreeK, "3K"), - // ProfileIcon("3k_plus", Icons.Filled.ThreeKPlus, "3K+"), - // ProfileIcon("3mp", Icons.Filled.ThreeMp, "3MP"), - // ProfileIcon("4k", Icons.Filled.FourK, "4K"), // Not available - // ProfileIcon("4k_plus", Icons.Filled.FourKPlus, "4K+"), // Not available - // ProfileIcon("4mp", Icons.Filled.FourMp, "4MP"), - // ProfileIcon("5g", Icons.Filled.FiveG, "5G"), - // ProfileIcon("5k", Icons.Filled.FiveK, "5K"), - // ProfileIcon("5k_plus", Icons.Filled.FiveKPlus, "5K+"), - // ProfileIcon("5mp", Icons.Filled.FiveMp, "5MP"), - // ProfileIcon("6k", Icons.Filled.SixK, "6K"), - // ProfileIcon("6k_plus", Icons.Filled.SixKPlus, "6K+"), - // ProfileIcon("6mp", Icons.Filled.SixMp, "6MP"), - // ProfileIcon("7k", Icons.Filled.SevenK, "7K"), - // ProfileIcon("7k_plus", Icons.Filled.SevenKPlus, "7K+"), - // ProfileIcon("7mp", Icons.Filled.SevenMp, "7MP"), - // ProfileIcon("8k", Icons.Filled.EightK, "8K"), - // ProfileIcon("8k_plus", Icons.Filled.EightKPlus, "8K+"), - // ProfileIcon("8mp", Icons.Filled.EightMp, "8MP"), - // ProfileIcon("9k", Icons.Filled.NineK, "9K"), - // ProfileIcon("9k_plus", Icons.Filled.NineKPlus, "9K+"), - // ProfileIcon("9mp", Icons.Filled.NineMp, "9MP"), - ProfileIcon("add_to_queue", Icons.Filled.AddToQueue, "Add to Queue"), - ProfileIcon("airplay", Icons.Filled.Airplay, "Airplay"), - ProfileIcon("album", Icons.Filled.Album, "Album"), - ProfileIcon("art_track", Icons.Filled.ArtTrack, "Art Track"), - ProfileIcon("audio_file", Icons.Filled.AudioFile, "Audio File"), - ProfileIcon("av_timer", Icons.Filled.AvTimer, "AV Timer"), - ProfileIcon("branding_watermark", Icons.Filled.BrandingWatermark, "Watermark"), - ProfileIcon("call_to_action", Icons.Filled.CallToAction, "Call to Action"), - ProfileIcon("closed_caption", Icons.Filled.ClosedCaption, "Closed Caption"), - ProfileIcon("closed_caption_disabled", Icons.Filled.ClosedCaptionDisabled, "CC Disabled"), - ProfileIcon("closed_caption_off", Icons.Filled.ClosedCaptionOff, "CC Off"), - ProfileIcon("control_camera", Icons.Filled.ControlCamera, "Control Camera"), - ProfileIcon("equalizer", Icons.Filled.Equalizer, "Equalizer"), - ProfileIcon("explicit", Icons.Filled.Explicit, "Explicit"), - ProfileIcon("fast_forward", Icons.Filled.FastForward, "Fast Forward"), - ProfileIcon("fast_rewind", Icons.Filled.FastRewind, "Fast Rewind"), - ProfileIcon( - "featured_play_list", - Icons.AutoMirrored.Filled.FeaturedPlayList, - "Featured Playlist", - ), - ProfileIcon("featured_video", Icons.AutoMirrored.Filled.FeaturedVideo, "Featured Video"), - ProfileIcon("fiber_dvr", Icons.Filled.FiberDvr, "DVR"), - ProfileIcon("fiber_manual_record", Icons.Filled.FiberManualRecord, "Record"), - ProfileIcon("fiber_new", Icons.Filled.FiberNew, "New"), - ProfileIcon("fiber_pin", Icons.Filled.FiberPin, "Pin"), - ProfileIcon("fiber_smart_record", Icons.Filled.FiberSmartRecord, "Smart Record"), - ProfileIcon("forward_10", Icons.Filled.Forward10, "Forward 10"), - ProfileIcon("forward_30", Icons.Filled.Forward30, "Forward 30"), - ProfileIcon("forward_5", Icons.Filled.Forward5, "Forward 5"), - ProfileIcon("games", Icons.Filled.Games, "Games"), - ProfileIcon("hd", Icons.Filled.Hd, "HD"), - ProfileIcon("hearing", Icons.Filled.Hearing, "Hearing"), - ProfileIcon("hearing_disabled", Icons.Filled.HearingDisabled, "Hearing Disabled"), - ProfileIcon("high_quality", Icons.Filled.HighQuality, "High Quality"), - ProfileIcon("interpreter_mode", Icons.Filled.InterpreterMode, "Interpreter Mode"), - ProfileIcon("library_add", Icons.Filled.LibraryAdd, "Library Add"), - ProfileIcon("library_add_check", Icons.Filled.LibraryAddCheck, "Library Check"), - ProfileIcon("library_books", Icons.Filled.LibraryBooks, "Library Books"), - ProfileIcon("library_music", Icons.Filled.LibraryMusic, "Library Music"), - ProfileIcon("loop", Icons.Filled.Loop, "Loop"), - ProfileIcon("lyrics", Icons.Filled.Lyrics, "Lyrics"), - ProfileIcon("mic", Icons.Filled.Mic, "Mic"), - ProfileIcon("mic_external_off", Icons.Filled.MicExternalOff, "Mic External Off"), - ProfileIcon("mic_external_on", Icons.Filled.MicExternalOn, "Mic External On"), - ProfileIcon("mic_none", Icons.Filled.MicNone, "Mic None"), - ProfileIcon("mic_off", Icons.Filled.MicOff, "Mic Off"), - ProfileIcon("missed_video_call", Icons.Filled.MissedVideoCall, "Missed Video Call"), - ProfileIcon("movie", Icons.Filled.Movie, "Movie"), - ProfileIcon("movie_creation", Icons.Filled.MovieCreation, "Movie Creation"), - ProfileIcon("movie_filter", Icons.Filled.MovieFilter, "Movie Filter"), - ProfileIcon("music_note", Icons.Filled.MusicNote, "Music Note"), - ProfileIcon("music_off", Icons.Filled.MusicOff, "Music Off"), - ProfileIcon("music_video", Icons.Filled.MusicVideo, "Music Video"), - ProfileIcon("new_releases", Icons.Filled.NewReleases, "New Releases"), - ProfileIcon("not_interested", Icons.Filled.NotInterested, "Not Interested"), - ProfileIcon("note", Icons.AutoMirrored.Filled.Note, "Note"), - ProfileIcon("pause", Icons.Filled.Pause, "Pause"), - ProfileIcon("pause_circle", Icons.Filled.PauseCircle, "Pause Circle"), - ProfileIcon("pause_circle_filled", Icons.Filled.PauseCircleFilled, "Pause Filled"), - ProfileIcon("pause_circle_outline", Icons.Filled.PauseCircleOutline, "Pause Outline"), - ProfileIcon("pause_presentation", Icons.Filled.PausePresentation, "Pause Presentation"), - ProfileIcon("play_arrow", Icons.Filled.PlayArrow, "Play"), - ProfileIcon("play_circle", Icons.Filled.PlayCircle, "Play Circle"), - ProfileIcon("play_circle_filled", Icons.Filled.PlayCircleFilled, "Play Filled"), - ProfileIcon("play_circle_outline", Icons.Filled.PlayCircleOutline, "Play Outline"), - ProfileIcon("play_disabled", Icons.Filled.PlayDisabled, "Play Disabled"), - ProfileIcon("play_lesson", Icons.Filled.PlayLesson, "Play Lesson"), - ProfileIcon("playlist_add", Icons.Filled.PlaylistAdd, "Playlist Add"), - ProfileIcon("playlist_add_check", Icons.Filled.PlaylistAddCheck, "Playlist Check"), - ProfileIcon( - "playlist_add_check_circle", - Icons.Filled.PlaylistAddCheckCircle, - "Playlist Circle", - ), - ProfileIcon("playlist_add_circle", Icons.Filled.PlaylistAddCircle, "Add Circle"), - ProfileIcon("playlist_play", Icons.Filled.PlaylistPlay, "Playlist Play"), - ProfileIcon("playlist_remove", Icons.Filled.PlaylistRemove, "Playlist Remove"), - ProfileIcon("queue", Icons.Filled.Queue, "Queue"), - ProfileIcon("queue_music", Icons.AutoMirrored.Filled.QueueMusic, "Queue Music"), - ProfileIcon("queue_play_next", Icons.Filled.QueuePlayNext, "Play Next"), - ProfileIcon("radio", Icons.Filled.Radio, "Radio"), - ProfileIcon("recent_actors", Icons.Filled.RecentActors, "Recent Actors"), - ProfileIcon("remove_from_queue", Icons.Filled.RemoveFromQueue, "Remove Queue"), - ProfileIcon("repeat", Icons.Filled.Repeat, "Repeat"), - ProfileIcon("repeat_on", Icons.Filled.RepeatOn, "Repeat On"), - ProfileIcon("repeat_one", Icons.Filled.RepeatOne, "Repeat One"), - ProfileIcon("repeat_one_on", Icons.Filled.RepeatOneOn, "Repeat One On"), - ProfileIcon("replay", Icons.Filled.Replay, "Replay"), - ProfileIcon("replay_10", Icons.Filled.Replay10, "Replay 10"), - ProfileIcon("replay_30", Icons.Filled.Replay30, "Replay 30"), - ProfileIcon("replay_5", Icons.Filled.Replay5, "Replay 5"), - ProfileIcon("replay_circle_filled", Icons.Filled.ReplayCircleFilled, "Replay Circle"), - ProfileIcon("sd", Icons.Filled.Sd, "SD"), - ProfileIcon("sd_card", Icons.Filled.SdCard, "SD Card"), - ProfileIcon("shuffle", Icons.Filled.Shuffle, "Shuffle"), - ProfileIcon("shuffle_on", Icons.Filled.ShuffleOn, "Shuffle On"), - ProfileIcon("skip_next", Icons.Filled.SkipNext, "Skip Next"), - ProfileIcon("skip_previous", Icons.Filled.SkipPrevious, "Skip Previous"), - ProfileIcon("slow_motion_video", Icons.Filled.SlowMotionVideo, "Slow Motion"), - ProfileIcon("snooze", Icons.Filled.Snooze, "Snooze"), - ProfileIcon("sort_by_alpha", Icons.Filled.SortByAlpha, "Sort Alpha"), - ProfileIcon("speed", Icons.Filled.Speed, "Speed"), - ProfileIcon("stop", Icons.Filled.Stop, "Stop"), - ProfileIcon("stop_circle", Icons.Filled.StopCircle, "Stop Circle"), - ProfileIcon("stop_screen_share", Icons.Filled.StopScreenShare, "Stop Share"), - ProfileIcon("subscriptions", Icons.Filled.Subscriptions, "Subscriptions"), - ProfileIcon("subtitles", Icons.Filled.Subtitles, "Subtitles"), - ProfileIcon("surround_sound", Icons.Filled.SurroundSound, "Surround Sound"), - ProfileIcon("video_call", Icons.Filled.VideoCall, "Video Call"), - ProfileIcon("video_camera_back", Icons.Filled.VideoCameraBack, "Camera Back"), - ProfileIcon("video_camera_front", Icons.Filled.VideoCameraFront, "Camera Front"), - // ProfileIcon("video_collection", Icons.Filled.VideoCollection, "Video Collection"), - ProfileIcon("video_file", Icons.Filled.VideoFile, "Video File"), - ProfileIcon("video_label", Icons.Filled.VideoLabel, "Video Label"), - ProfileIcon("video_library", Icons.Filled.VideoLibrary, "Video Library"), - ProfileIcon("video_settings", Icons.Filled.VideoSettings, "Video Settings"), - ProfileIcon("video_stable", Icons.Filled.VideoStable, "Video Stable"), - ProfileIcon("videocam", Icons.Filled.Videocam, "Videocam"), - ProfileIcon("videocam_off", Icons.Filled.VideocamOff, "Videocam Off"), - ProfileIcon("videogame_asset", Icons.Filled.VideogameAsset, "Videogame"), - ProfileIcon("videogame_asset_off", Icons.Filled.VideogameAssetOff, "Videogame Off"), - ProfileIcon("volume_down", Icons.AutoMirrored.Filled.VolumeDown, "Volume Down"), - ProfileIcon("volume_mute", Icons.AutoMirrored.Filled.VolumeMute, "Mute"), - ProfileIcon("volume_off", Icons.AutoMirrored.Filled.VolumeOff, "Volume Off"), - ProfileIcon("volume_up", Icons.AutoMirrored.Filled.VolumeUp, "Volume Up"), - ProfileIcon("web", Icons.Filled.Web, "Web"), - ProfileIcon("web_asset", Icons.Filled.WebAsset, "Web Asset"), - ProfileIcon("web_asset_off", Icons.Filled.WebAssetOff, "Web Asset Off"), - ) -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/ActionIcons.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/ActionIcons.kt deleted file mode 100644 index 7512b0dc95..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/ActionIcons.kt +++ /dev/null @@ -1,983 +0,0 @@ -package io.nekohasekai.sfa.compose.util.icons - -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.Announcement -import androidx.compose.material.icons.automirrored.filled.Article -import androidx.compose.material.icons.automirrored.filled.AssignmentReturn -import androidx.compose.material.icons.automirrored.filled.ExitToApp -import androidx.compose.material.icons.automirrored.filled.FactCheck -import androidx.compose.material.icons.automirrored.filled.Grading -import androidx.compose.material.icons.automirrored.filled.Help -import androidx.compose.material.icons.automirrored.filled.HelpCenter -import androidx.compose.material.icons.automirrored.filled.HelpOutline -import androidx.compose.material.icons.automirrored.filled.Input -import androidx.compose.material.icons.automirrored.filled.Label -import androidx.compose.material.icons.automirrored.filled.LabelImportant -import androidx.compose.material.icons.automirrored.filled.LabelOff -import androidx.compose.material.icons.automirrored.filled.Launch -import androidx.compose.material.icons.automirrored.filled.List -import androidx.compose.material.icons.automirrored.filled.ListAlt -import androidx.compose.material.icons.automirrored.filled.Login -import androidx.compose.material.icons.automirrored.filled.Logout -import androidx.compose.material.icons.automirrored.filled.NextPlan -import androidx.compose.material.icons.automirrored.filled.NoteAdd -import androidx.compose.material.icons.automirrored.filled.ReceiptLong -import androidx.compose.material.icons.automirrored.filled.Send -import androidx.compose.material.icons.automirrored.filled.Subject -import androidx.compose.material.icons.automirrored.filled.Toc -import androidx.compose.material.icons.automirrored.filled.TrendingDown -import androidx.compose.material.icons.automirrored.filled.TrendingFlat -import androidx.compose.material.icons.automirrored.filled.TrendingUp -import androidx.compose.material.icons.automirrored.filled.ViewList -import androidx.compose.material.icons.automirrored.filled.ViewQuilt -import androidx.compose.material.icons.automirrored.filled.ViewSidebar -import androidx.compose.material.icons.filled.Accessibility -import androidx.compose.material.icons.filled.AccessibilityNew -import androidx.compose.material.icons.filled.Accessible -import androidx.compose.material.icons.filled.AccessibleForward -import androidx.compose.material.icons.filled.AccountBalance -import androidx.compose.material.icons.filled.AccountBalanceWallet -import androidx.compose.material.icons.filled.AccountBox -import androidx.compose.material.icons.filled.AccountCircle -import androidx.compose.material.icons.filled.AddShoppingCart -import androidx.compose.material.icons.filled.AddTask -import androidx.compose.material.icons.filled.AddToDrive -import androidx.compose.material.icons.filled.Addchart -import androidx.compose.material.icons.filled.AdminPanelSettings -import androidx.compose.material.icons.filled.AdsClick -import androidx.compose.material.icons.filled.Alarm -import androidx.compose.material.icons.filled.AlarmAdd -import androidx.compose.material.icons.filled.AlarmOff -import androidx.compose.material.icons.filled.AlarmOn -import androidx.compose.material.icons.filled.AllInbox -import androidx.compose.material.icons.filled.AllOut -import androidx.compose.material.icons.filled.Analytics -import androidx.compose.material.icons.filled.Anchor -import androidx.compose.material.icons.filled.Android -import androidx.compose.material.icons.filled.Api -import androidx.compose.material.icons.filled.AppBlocking -import androidx.compose.material.icons.filled.AppRegistration -import androidx.compose.material.icons.filled.AppSettingsAlt -import androidx.compose.material.icons.filled.AppShortcut -import androidx.compose.material.icons.filled.Approval -import androidx.compose.material.icons.filled.Apps -import androidx.compose.material.icons.filled.AppsOutage -import androidx.compose.material.icons.filled.ArrowCircleDown -import androidx.compose.material.icons.filled.ArrowCircleLeft -import androidx.compose.material.icons.filled.ArrowCircleRight -import androidx.compose.material.icons.filled.ArrowCircleUp -import androidx.compose.material.icons.filled.ArrowOutward -import androidx.compose.material.icons.filled.AspectRatio -import androidx.compose.material.icons.filled.Assessment -import androidx.compose.material.icons.filled.Assignment -import androidx.compose.material.icons.filled.AssignmentInd -import androidx.compose.material.icons.filled.AssignmentLate -import androidx.compose.material.icons.filled.AssignmentReturned -import androidx.compose.material.icons.filled.AssignmentTurnedIn -import androidx.compose.material.icons.filled.AssuredWorkload -import androidx.compose.material.icons.filled.Attachment -import androidx.compose.material.icons.filled.Autorenew -import androidx.compose.material.icons.filled.Backup -import androidx.compose.material.icons.filled.BackupTable -import androidx.compose.material.icons.filled.Balance -import androidx.compose.material.icons.filled.BatchPrediction -import androidx.compose.material.icons.filled.Book -import androidx.compose.material.icons.filled.BookOnline -import androidx.compose.material.icons.filled.Bookmark -import androidx.compose.material.icons.filled.BookmarkAdd -import androidx.compose.material.icons.filled.BookmarkAdded -import androidx.compose.material.icons.filled.BookmarkBorder -import androidx.compose.material.icons.filled.BookmarkRemove -import androidx.compose.material.icons.filled.Bookmarks -import androidx.compose.material.icons.filled.BugReport -import androidx.compose.material.icons.filled.Build -import androidx.compose.material.icons.filled.BuildCircle -import androidx.compose.material.icons.filled.Cached -import androidx.compose.material.icons.filled.CalendarMonth -import androidx.compose.material.icons.filled.CalendarToday -import androidx.compose.material.icons.filled.CalendarViewDay -import androidx.compose.material.icons.filled.CalendarViewMonth -import androidx.compose.material.icons.filled.CalendarViewWeek -import androidx.compose.material.icons.filled.CameraEnhance -import androidx.compose.material.icons.filled.CancelScheduleSend -import androidx.compose.material.icons.filled.CardGiftcard -import androidx.compose.material.icons.filled.CardMembership -import androidx.compose.material.icons.filled.CardTravel -import androidx.compose.material.icons.filled.ChangeCircle -import androidx.compose.material.icons.filled.ChangeHistory -import androidx.compose.material.icons.filled.CheckCircle -import androidx.compose.material.icons.filled.CheckCircleOutline -import androidx.compose.material.icons.filled.ChromeReaderMode -import androidx.compose.material.icons.filled.CircleNotifications -import androidx.compose.material.icons.filled.Class -import androidx.compose.material.icons.filled.CloseFullscreen -import androidx.compose.material.icons.filled.Code -import androidx.compose.material.icons.filled.CodeOff -import androidx.compose.material.icons.filled.CommentBank -import androidx.compose.material.icons.filled.Commute -import androidx.compose.material.icons.filled.CompareArrows -import androidx.compose.material.icons.filled.Compress -import androidx.compose.material.icons.filled.ContactPage -import androidx.compose.material.icons.filled.ContactSupport -import androidx.compose.material.icons.filled.Contactless -import androidx.compose.material.icons.filled.Copyright -import androidx.compose.material.icons.filled.CreditCard -import androidx.compose.material.icons.filled.CreditCardOff -import androidx.compose.material.icons.filled.CreditScore -import androidx.compose.material.icons.filled.Css -import androidx.compose.material.icons.filled.CurrencyExchange -import androidx.compose.material.icons.filled.Dangerous -import androidx.compose.material.icons.filled.Dashboard -import androidx.compose.material.icons.filled.DashboardCustomize -import androidx.compose.material.icons.filled.DataExploration -import androidx.compose.material.icons.filled.DataThresholding -import androidx.compose.material.icons.filled.DateRange -import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.DeleteForever -import androidx.compose.material.icons.filled.DeleteOutline -import androidx.compose.material.icons.filled.DeleteSweep -import androidx.compose.material.icons.filled.DensityLarge -import androidx.compose.material.icons.filled.DensityMedium -import androidx.compose.material.icons.filled.DensitySmall -import androidx.compose.material.icons.filled.Description -import androidx.compose.material.icons.filled.DisabledByDefault -import androidx.compose.material.icons.filled.DisabledVisible -import androidx.compose.material.icons.filled.DisplaySettings -import androidx.compose.material.icons.filled.Dns -import androidx.compose.material.icons.filled.Done -import androidx.compose.material.icons.filled.DoneAll -import androidx.compose.material.icons.filled.DoneOutline -import androidx.compose.material.icons.filled.DonutLarge -import androidx.compose.material.icons.filled.DonutSmall -import androidx.compose.material.icons.filled.DragIndicator -import androidx.compose.material.icons.filled.DynamicForm -import androidx.compose.material.icons.filled.Eco -import androidx.compose.material.icons.filled.EditCalendar -import androidx.compose.material.icons.filled.EditNote -import androidx.compose.material.icons.filled.EditOff -import androidx.compose.material.icons.filled.Eject -import androidx.compose.material.icons.filled.Euro -import androidx.compose.material.icons.filled.Event -import androidx.compose.material.icons.filled.EventRepeat -import androidx.compose.material.icons.filled.EventSeat -import androidx.compose.material.icons.filled.Expand -import androidx.compose.material.icons.filled.Explore -import androidx.compose.material.icons.filled.ExploreOff -import androidx.compose.material.icons.filled.Extension -import androidx.compose.material.icons.filled.ExtensionOff -import androidx.compose.material.icons.filled.Face -import androidx.compose.material.icons.filled.Favorite -import androidx.compose.material.icons.filled.FavoriteBorder -import androidx.compose.material.icons.filled.Fax -import androidx.compose.material.icons.filled.Feedback -import androidx.compose.material.icons.filled.FileDownload -import androidx.compose.material.icons.filled.FileDownloadDone -import androidx.compose.material.icons.filled.FileDownloadOff -import androidx.compose.material.icons.filled.FileOpen -import androidx.compose.material.icons.filled.FilePresent -import androidx.compose.material.icons.filled.FileUpload -import androidx.compose.material.icons.filled.FilterAlt -import androidx.compose.material.icons.filled.FilterAltOff -import androidx.compose.material.icons.filled.FilterList -import androidx.compose.material.icons.filled.FilterListOff -import androidx.compose.material.icons.filled.FindInPage -import androidx.compose.material.icons.filled.FindReplace -import androidx.compose.material.icons.filled.Fingerprint -import androidx.compose.material.icons.filled.FitScreen -import androidx.compose.material.icons.filled.Flaky -import androidx.compose.material.icons.filled.FlightLand -import androidx.compose.material.icons.filled.FlightTakeoff -import androidx.compose.material.icons.filled.FlipToBack -import androidx.compose.material.icons.filled.FlipToFront -import androidx.compose.material.icons.filled.FlutterDash -import androidx.compose.material.icons.filled.FreeCancellation -import androidx.compose.material.icons.filled.GTranslate -import androidx.compose.material.icons.filled.Gavel -import androidx.compose.material.icons.filled.GeneratingTokens -import androidx.compose.material.icons.filled.GetApp -import androidx.compose.material.icons.filled.Gif -import androidx.compose.material.icons.filled.GifBox -import androidx.compose.material.icons.filled.Grade -import androidx.compose.material.icons.filled.GroupWork -import androidx.compose.material.icons.filled.HideSource -import androidx.compose.material.icons.filled.HighlightAlt -import androidx.compose.material.icons.filled.HighlightOff -import androidx.compose.material.icons.filled.History -import androidx.compose.material.icons.filled.HistoryToggleOff -import androidx.compose.material.icons.filled.Hls -import androidx.compose.material.icons.filled.HlsOff -import androidx.compose.material.icons.filled.Home -import androidx.compose.material.icons.filled.HorizontalSplit -import androidx.compose.material.icons.filled.HourglassDisabled -import androidx.compose.material.icons.filled.HourglassEmpty -import androidx.compose.material.icons.filled.HourglassFull -import androidx.compose.material.icons.filled.Html -import androidx.compose.material.icons.filled.Http -import androidx.compose.material.icons.filled.Https -import androidx.compose.material.icons.filled.ImportantDevices -import androidx.compose.material.icons.filled.Info -import androidx.compose.material.icons.filled.InstallDesktop -import androidx.compose.material.icons.filled.InstallMobile -import androidx.compose.material.icons.filled.IntegrationInstructions -import androidx.compose.material.icons.filled.InvertColors -import androidx.compose.material.icons.filled.Javascript -import androidx.compose.material.icons.filled.JoinFull -import androidx.compose.material.icons.filled.JoinInner -import androidx.compose.material.icons.filled.JoinLeft -import androidx.compose.material.icons.filled.JoinRight -import androidx.compose.material.icons.filled.Language -import androidx.compose.material.icons.filled.Leaderboard -import androidx.compose.material.icons.filled.Lightbulb -import androidx.compose.material.icons.filled.LightbulbCircle -import androidx.compose.material.icons.filled.LineStyle -import androidx.compose.material.icons.filled.LineWeight -import androidx.compose.material.icons.filled.Lock -import androidx.compose.material.icons.filled.LockClock -import androidx.compose.material.icons.filled.LockOpen -import androidx.compose.material.icons.filled.LockPerson -import androidx.compose.material.icons.filled.LockReset -import androidx.compose.material.icons.filled.Loyalty -import androidx.compose.material.icons.filled.ManageAccounts -import androidx.compose.material.icons.filled.ManageHistory -import androidx.compose.material.icons.filled.ManageSearch -import androidx.compose.material.icons.filled.MarkAsUnread -import androidx.compose.material.icons.filled.MarkunreadMailbox -import androidx.compose.material.icons.filled.Maximize -import androidx.compose.material.icons.filled.Mediation -import androidx.compose.material.icons.filled.Minimize -import androidx.compose.material.icons.filled.ModelTraining -import androidx.compose.material.icons.filled.Nightlight -import androidx.compose.material.icons.filled.NightlightRound -import androidx.compose.material.icons.filled.NoAccounts -import androidx.compose.material.icons.filled.NotStarted -import androidx.compose.material.icons.filled.OfflineBolt -import androidx.compose.material.icons.filled.OfflinePin -import androidx.compose.material.icons.filled.OnlinePrediction -import androidx.compose.material.icons.filled.Opacity -import androidx.compose.material.icons.filled.OpenInBrowser -import androidx.compose.material.icons.filled.OpenInFull -import androidx.compose.material.icons.filled.OpenInNew -import androidx.compose.material.icons.filled.OpenInNewOff -import androidx.compose.material.icons.filled.OpenWith -import androidx.compose.material.icons.filled.Outbond -import androidx.compose.material.icons.filled.Outlet -import androidx.compose.material.icons.filled.Output -import androidx.compose.material.icons.filled.Pageview -import androidx.compose.material.icons.filled.Paid -import androidx.compose.material.icons.filled.PanTool -import androidx.compose.material.icons.filled.PanToolAlt -import androidx.compose.material.icons.filled.Payment -import androidx.compose.material.icons.filled.Pending -import androidx.compose.material.icons.filled.PendingActions -import androidx.compose.material.icons.filled.Percent -import androidx.compose.material.icons.filled.PermCameraMic -import androidx.compose.material.icons.filled.PermContactCalendar -import androidx.compose.material.icons.filled.PermDataSetting -import androidx.compose.material.icons.filled.PermDeviceInformation -import androidx.compose.material.icons.filled.PermIdentity -import androidx.compose.material.icons.filled.PermMedia -import androidx.compose.material.icons.filled.PermPhoneMsg -import androidx.compose.material.icons.filled.PermScanWifi -import androidx.compose.material.icons.filled.Pets -import androidx.compose.material.icons.filled.Php -import androidx.compose.material.icons.filled.PictureInPicture -import androidx.compose.material.icons.filled.PictureInPictureAlt -import androidx.compose.material.icons.filled.PinEnd -import androidx.compose.material.icons.filled.PinInvoke -import androidx.compose.material.icons.filled.Plagiarism -import androidx.compose.material.icons.filled.PlayForWork -import androidx.compose.material.icons.filled.Polymer -import androidx.compose.material.icons.filled.PowerSettingsNew -import androidx.compose.material.icons.filled.PregnantWoman -import androidx.compose.material.icons.filled.Preview -import androidx.compose.material.icons.filled.Print -import androidx.compose.material.icons.filled.PrintDisabled -import androidx.compose.material.icons.filled.PrivacyTip -import androidx.compose.material.icons.filled.ProductionQuantityLimits -import androidx.compose.material.icons.filled.PublishedWithChanges -import androidx.compose.material.icons.filled.QueryBuilder -import androidx.compose.material.icons.filled.QuestionAnswer -import androidx.compose.material.icons.filled.QuestionMark -import androidx.compose.material.icons.filled.Quickreply -import androidx.compose.material.icons.filled.Receipt -import androidx.compose.material.icons.filled.RecordVoiceOver -import androidx.compose.material.icons.filled.Redeem -import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material.icons.filled.RemoveDone -import androidx.compose.material.icons.filled.RemoveShoppingCart -import androidx.compose.material.icons.filled.Reorder -import androidx.compose.material.icons.filled.Repartition -import androidx.compose.material.icons.filled.ReportProblem -import androidx.compose.material.icons.filled.RequestPage -import androidx.compose.material.icons.filled.RequestQuote -import androidx.compose.material.icons.filled.Restore -import androidx.compose.material.icons.filled.RestoreFromTrash -import androidx.compose.material.icons.filled.RestorePage -import androidx.compose.material.icons.filled.Rocket -import androidx.compose.material.icons.filled.RocketLaunch -import androidx.compose.material.icons.filled.Room -import androidx.compose.material.icons.filled.RoundedCorner -import androidx.compose.material.icons.filled.Rowing -import androidx.compose.material.icons.filled.Rule -import androidx.compose.material.icons.filled.SatelliteAlt -import androidx.compose.material.icons.filled.Save -import androidx.compose.material.icons.filled.SaveAlt -import androidx.compose.material.icons.filled.SaveAs -import androidx.compose.material.icons.filled.SavedSearch -import androidx.compose.material.icons.filled.Savings -import androidx.compose.material.icons.filled.Schedule -import androidx.compose.material.icons.filled.ScheduleSend -import androidx.compose.material.icons.filled.Search -import androidx.compose.material.icons.filled.SearchOff -import androidx.compose.material.icons.filled.Segment -import androidx.compose.material.icons.filled.SendAndArchive -import androidx.compose.material.icons.filled.Sensors -import androidx.compose.material.icons.filled.SensorsOff -import androidx.compose.material.icons.filled.Settings -import androidx.compose.material.icons.filled.SettingsAccessibility -import androidx.compose.material.icons.filled.SettingsApplications -import androidx.compose.material.icons.filled.SettingsBackupRestore -import androidx.compose.material.icons.filled.SettingsBluetooth -import androidx.compose.material.icons.filled.SettingsBrightness -import androidx.compose.material.icons.filled.SettingsCell -import androidx.compose.material.icons.filled.SettingsEthernet -import androidx.compose.material.icons.filled.SettingsInputAntenna -import androidx.compose.material.icons.filled.SettingsInputComponent -import androidx.compose.material.icons.filled.SettingsInputComposite -import androidx.compose.material.icons.filled.SettingsInputHdmi -import androidx.compose.material.icons.filled.SettingsInputSvideo -import androidx.compose.material.icons.filled.SettingsOverscan -import androidx.compose.material.icons.filled.SettingsPhone -import androidx.compose.material.icons.filled.SettingsPower -import androidx.compose.material.icons.filled.SettingsRemote -import androidx.compose.material.icons.filled.SettingsVoice -import androidx.compose.material.icons.filled.Shop -import androidx.compose.material.icons.filled.Shop2 -import androidx.compose.material.icons.filled.ShopTwo -import androidx.compose.material.icons.filled.ShoppingBag -import androidx.compose.material.icons.filled.ShoppingBasket -import androidx.compose.material.icons.filled.ShoppingCart -import androidx.compose.material.icons.filled.ShoppingCartCheckout -import androidx.compose.material.icons.filled.SmartButton -import androidx.compose.material.icons.filled.Source -import androidx.compose.material.icons.filled.SpaceDashboard -import androidx.compose.material.icons.filled.SpatialAudio -import androidx.compose.material.icons.filled.SpatialAudioOff -import androidx.compose.material.icons.filled.SpatialTracking -import androidx.compose.material.icons.filled.SpeakerNotes -import androidx.compose.material.icons.filled.SpeakerNotesOff -import androidx.compose.material.icons.filled.Spellcheck -import androidx.compose.material.icons.filled.StarRate -import androidx.compose.material.icons.filled.Stars -import androidx.compose.material.icons.filled.StickyNote2 -import androidx.compose.material.icons.filled.Store -import androidx.compose.material.icons.filled.SubtitlesOff -import androidx.compose.material.icons.filled.SupervisedUserCircle -import androidx.compose.material.icons.filled.SupervisorAccount -import androidx.compose.material.icons.filled.Support -import androidx.compose.material.icons.filled.SwapHoriz -import androidx.compose.material.icons.filled.SwapHorizontalCircle -import androidx.compose.material.icons.filled.SwapVert -import androidx.compose.material.icons.filled.SwapVerticalCircle -import androidx.compose.material.icons.filled.Swipe -import androidx.compose.material.icons.filled.SwipeDown -import androidx.compose.material.icons.filled.SwipeDownAlt -import androidx.compose.material.icons.filled.SwipeLeft -import androidx.compose.material.icons.filled.SwipeLeftAlt -import androidx.compose.material.icons.filled.SwipeRight -import androidx.compose.material.icons.filled.SwipeRightAlt -import androidx.compose.material.icons.filled.SwipeUp -import androidx.compose.material.icons.filled.SwipeUpAlt -import androidx.compose.material.icons.filled.SwipeVertical -import androidx.compose.material.icons.filled.SwitchAccessShortcut -import androidx.compose.material.icons.filled.SwitchAccessShortcutAdd -import androidx.compose.material.icons.filled.SyncAlt -import androidx.compose.material.icons.filled.SystemUpdateAlt -import androidx.compose.material.icons.filled.Tab -import androidx.compose.material.icons.filled.TabUnselected -import androidx.compose.material.icons.filled.TableView -import androidx.compose.material.icons.filled.TagFaces -import androidx.compose.material.icons.filled.TaskAlt -import androidx.compose.material.icons.filled.Terminal -import androidx.compose.material.icons.filled.TextRotateUp -import androidx.compose.material.icons.filled.TextRotateVertical -import androidx.compose.material.icons.filled.TextRotationAngledown -import androidx.compose.material.icons.filled.TextRotationAngleup -import androidx.compose.material.icons.filled.TextRotationDown -import androidx.compose.material.icons.filled.TextRotationNone -import androidx.compose.material.icons.filled.Theaters -import androidx.compose.material.icons.filled.ThumbDown -import androidx.compose.material.icons.filled.ThumbDownOffAlt -import androidx.compose.material.icons.filled.ThumbUp -import androidx.compose.material.icons.filled.ThumbUpOffAlt -import androidx.compose.material.icons.filled.ThumbsUpDown -import androidx.compose.material.icons.filled.Timeline -import androidx.compose.material.icons.filled.TipsAndUpdates -import androidx.compose.material.icons.filled.Today -import androidx.compose.material.icons.filled.Token -import androidx.compose.material.icons.filled.Toll -import androidx.compose.material.icons.filled.TouchApp -import androidx.compose.material.icons.filled.Tour -import androidx.compose.material.icons.filled.TrackChanges -import androidx.compose.material.icons.filled.Transcribe -import androidx.compose.material.icons.filled.Translate -import androidx.compose.material.icons.filled.Troubleshoot -import androidx.compose.material.icons.filled.TurnedIn -import androidx.compose.material.icons.filled.TurnedInNot -import androidx.compose.material.icons.filled.UnfoldLessDouble -import androidx.compose.material.icons.filled.UnfoldMoreDouble -import androidx.compose.material.icons.filled.Unpublished -import androidx.compose.material.icons.filled.Update -import androidx.compose.material.icons.filled.UpdateDisabled -import androidx.compose.material.icons.filled.Upgrade -import androidx.compose.material.icons.filled.Verified -import androidx.compose.material.icons.filled.VerifiedUser -import androidx.compose.material.icons.filled.VerticalSplit -import androidx.compose.material.icons.filled.ViewAgenda -import androidx.compose.material.icons.filled.ViewArray -import androidx.compose.material.icons.filled.ViewCarousel -import androidx.compose.material.icons.filled.ViewColumn -import androidx.compose.material.icons.filled.ViewComfy -import androidx.compose.material.icons.filled.ViewComfyAlt -import androidx.compose.material.icons.filled.ViewCompact -import androidx.compose.material.icons.filled.ViewCompactAlt -import androidx.compose.material.icons.filled.ViewCozy -import androidx.compose.material.icons.filled.ViewDay -import androidx.compose.material.icons.filled.ViewHeadline -import androidx.compose.material.icons.filled.ViewInAr -import androidx.compose.material.icons.filled.ViewKanban -import androidx.compose.material.icons.filled.ViewModule -import androidx.compose.material.icons.filled.ViewStream -import androidx.compose.material.icons.filled.ViewTimeline -import androidx.compose.material.icons.filled.ViewWeek -import androidx.compose.material.icons.filled.Visibility -import androidx.compose.material.icons.filled.VisibilityOff -import androidx.compose.material.icons.filled.VoiceOverOff -import androidx.compose.material.icons.filled.WatchLater -import androidx.compose.material.icons.filled.Webhook -import androidx.compose.material.icons.filled.WidthFull -import androidx.compose.material.icons.filled.WidthNormal -import androidx.compose.material.icons.filled.WidthWide -import androidx.compose.material.icons.filled.WifiProtectedSetup -import androidx.compose.material.icons.filled.Work -import androidx.compose.material.icons.filled.WorkHistory -import androidx.compose.material.icons.filled.WorkOff -import androidx.compose.material.icons.filled.WorkOutline -import androidx.compose.material.icons.filled.Wysiwyg -import androidx.compose.material.icons.filled.ZoomIn -import androidx.compose.material.icons.filled.ZoomOut -import io.nekohasekai.sfa.compose.util.ProfileIcon - -/** - * Action category icons - User actions and common UI operations - * Based on Google's Material Design Icons taxonomy - */ -object ActionIcons { - val icons = - listOf( - // ProfileIcon("3d_rotation", Icons.Filled.ThreeDRotation, "3D Rotation"), - ProfileIcon("accessibility", Icons.Filled.Accessibility, "Accessibility"), - ProfileIcon("accessibility_new", Icons.Filled.AccessibilityNew, "Accessibility New"), - ProfileIcon("accessible", Icons.Filled.Accessible, "Accessible"), - ProfileIcon("accessible_forward", Icons.Filled.AccessibleForward, "Accessible Forward"), - ProfileIcon("account_balance", Icons.Filled.AccountBalance, "Account Balance"), - ProfileIcon("account_balance_wallet", Icons.Filled.AccountBalanceWallet, "Wallet"), - ProfileIcon("account_box", Icons.Filled.AccountBox, "Account Box"), - ProfileIcon("account_circle", Icons.Filled.AccountCircle, "Account"), - ProfileIcon("add_shopping_cart", Icons.Filled.AddShoppingCart, "Add Cart"), - ProfileIcon("add_task", Icons.Filled.AddTask, "Add Task"), - ProfileIcon("add_to_drive", Icons.Filled.AddToDrive, "Add to Drive"), - ProfileIcon("addchart", Icons.Filled.Addchart, "Add Chart"), - ProfileIcon("admin_panel_settings", Icons.Filled.AdminPanelSettings, "Admin Panel"), - ProfileIcon("ads_click", Icons.Filled.AdsClick, "Ads Click"), - ProfileIcon("alarm", Icons.Filled.Alarm, "Alarm"), - ProfileIcon("alarm_add", Icons.Filled.AlarmAdd, "Add Alarm"), - ProfileIcon("alarm_off", Icons.Filled.AlarmOff, "Alarm Off"), - ProfileIcon("alarm_on", Icons.Filled.AlarmOn, "Alarm On"), - ProfileIcon("all_inbox", Icons.Filled.AllInbox, "All Inbox"), - ProfileIcon("all_out", Icons.Filled.AllOut, "All Out"), - ProfileIcon("analytics", Icons.Filled.Analytics, "Analytics"), - ProfileIcon("anchor", Icons.Filled.Anchor, "Anchor"), - ProfileIcon("android", Icons.Filled.Android, "Android"), - ProfileIcon("announcement", Icons.AutoMirrored.Filled.Announcement, "Announcement"), - ProfileIcon("api", Icons.Filled.Api, "API"), - ProfileIcon("app_blocking", Icons.Filled.AppBlocking, "App Blocking"), - ProfileIcon("app_registration", Icons.Filled.AppRegistration, "App Registration"), - ProfileIcon("app_settings_alt", Icons.Filled.AppSettingsAlt, "App Settings"), - ProfileIcon("app_shortcut", Icons.Filled.AppShortcut, "App Shortcut"), - ProfileIcon("approval", Icons.Filled.Approval, "Approval"), - ProfileIcon("apps", Icons.Filled.Apps, "Apps"), - ProfileIcon("apps_outage", Icons.Filled.AppsOutage, "Apps Outage"), - ProfileIcon("arrow_circle_down", Icons.Filled.ArrowCircleDown, "Arrow Down"), - ProfileIcon("arrow_circle_left", Icons.Filled.ArrowCircleLeft, "Arrow Left"), - ProfileIcon("arrow_circle_right", Icons.Filled.ArrowCircleRight, "Arrow Right"), - ProfileIcon("arrow_circle_up", Icons.Filled.ArrowCircleUp, "Arrow Up"), - ProfileIcon("arrow_outward", Icons.Filled.ArrowOutward, "Arrow Outward"), - ProfileIcon("article", Icons.AutoMirrored.Filled.Article, "Article"), - ProfileIcon("aspect_ratio", Icons.Filled.AspectRatio, "Aspect Ratio"), - ProfileIcon("assessment", Icons.Filled.Assessment, "Assessment"), - ProfileIcon("assignment", Icons.Filled.Assignment, "Assignment"), - ProfileIcon("assignment_ind", Icons.Filled.AssignmentInd, "Assignment Ind"), - ProfileIcon("assignment_late", Icons.Filled.AssignmentLate, "Assignment Late"), - ProfileIcon( - "assignment_return", - Icons.AutoMirrored.Filled.AssignmentReturn, - "Assignment Return", - ), - ProfileIcon("assignment_returned", Icons.Filled.AssignmentReturned, "Assignment Returned"), - ProfileIcon("assignment_turned_in", Icons.Filled.AssignmentTurnedIn, "Done"), - ProfileIcon("assured_workload", Icons.Filled.AssuredWorkload, "Assured Workload"), - ProfileIcon("attachment", Icons.Filled.Attachment, "Attachment"), - ProfileIcon("autorenew", Icons.Filled.Autorenew, "Auto Renew"), - ProfileIcon("backup", Icons.Filled.Backup, "Backup"), - ProfileIcon("backup_table", Icons.Filled.BackupTable, "Backup Table"), - ProfileIcon("balance", Icons.Filled.Balance, "Balance"), - ProfileIcon("batch_prediction", Icons.Filled.BatchPrediction, "Batch Prediction"), - ProfileIcon("book", Icons.Filled.Book, "Book"), - ProfileIcon("book_online", Icons.Filled.BookOnline, "Book Online"), - ProfileIcon("bookmark", Icons.Filled.Bookmark, "Bookmark"), - ProfileIcon("bookmark_add", Icons.Filled.BookmarkAdd, "Bookmark Add"), - ProfileIcon("bookmark_added", Icons.Filled.BookmarkAdded, "Bookmark Added"), - ProfileIcon("bookmark_border", Icons.Filled.BookmarkBorder, "Bookmark Border"), - ProfileIcon("bookmark_remove", Icons.Filled.BookmarkRemove, "Bookmark Remove"), - ProfileIcon("bookmarks", Icons.Filled.Bookmarks, "Bookmarks"), - ProfileIcon("bug_report", Icons.Filled.BugReport, "Bug Report"), - ProfileIcon("build", Icons.Filled.Build, "Build"), - ProfileIcon("build_circle", Icons.Filled.BuildCircle, "Build Circle"), - ProfileIcon("cached", Icons.Filled.Cached, "Cached"), - ProfileIcon("calendar_month", Icons.Filled.CalendarMonth, "Calendar Month"), - ProfileIcon("calendar_today", Icons.Filled.CalendarToday, "Calendar Today"), - ProfileIcon("calendar_view_day", Icons.Filled.CalendarViewDay, "Calendar Day"), - ProfileIcon("calendar_view_month", Icons.Filled.CalendarViewMonth, "Calendar Month View"), - ProfileIcon("calendar_view_week", Icons.Filled.CalendarViewWeek, "Calendar Week"), - ProfileIcon("camera_enhance", Icons.Filled.CameraEnhance, "Camera Enhance"), - ProfileIcon("cancel_schedule_send", Icons.Filled.CancelScheduleSend, "Cancel Schedule"), - ProfileIcon("card_giftcard", Icons.Filled.CardGiftcard, "Gift Card"), - ProfileIcon("card_membership", Icons.Filled.CardMembership, "Membership"), - ProfileIcon("card_travel", Icons.Filled.CardTravel, "Travel Card"), - ProfileIcon("change_circle", Icons.Filled.ChangeCircle, "Change Circle"), - ProfileIcon("change_history", Icons.Filled.ChangeHistory, "Change History"), - ProfileIcon("check_circle", Icons.Filled.CheckCircle, "Check Circle"), - ProfileIcon( - "check_circle_outline", - Icons.Filled.CheckCircleOutline, - "Check Circle Outline", - ), - ProfileIcon("chrome_reader_mode", Icons.Filled.ChromeReaderMode, "Reader Mode"), - ProfileIcon( - "circle_notifications", - Icons.Filled.CircleNotifications, - "Circle Notifications", - ), - ProfileIcon("class", Icons.Filled.Class, "Class"), - ProfileIcon("close_fullscreen", Icons.Filled.CloseFullscreen, "Close Fullscreen"), - ProfileIcon("code", Icons.Filled.Code, "Code"), - ProfileIcon("code_off", Icons.Filled.CodeOff, "Code Off"), - ProfileIcon("comment_bank", Icons.Filled.CommentBank, "Comment Bank"), - ProfileIcon("commute", Icons.Filled.Commute, "Commute"), - ProfileIcon("compare_arrows", Icons.Filled.CompareArrows, "Compare"), - ProfileIcon("compress", Icons.Filled.Compress, "Compress"), - ProfileIcon("contact_page", Icons.Filled.ContactPage, "Contact Page"), - ProfileIcon("contact_support", Icons.Filled.ContactSupport, "Contact Support"), - ProfileIcon("contactless", Icons.Filled.Contactless, "Contactless"), - ProfileIcon("copyright", Icons.Filled.Copyright, "Copyright"), - ProfileIcon("credit_card", Icons.Filled.CreditCard, "Credit Card"), - ProfileIcon("credit_card_off", Icons.Filled.CreditCardOff, "Credit Card Off"), - ProfileIcon("credit_score", Icons.Filled.CreditScore, "Credit Score"), - ProfileIcon("css", Icons.Filled.Css, "CSS"), - ProfileIcon("currency_exchange", Icons.Filled.CurrencyExchange, "Currency Exchange"), - ProfileIcon("dangerous", Icons.Filled.Dangerous, "Dangerous"), - ProfileIcon("dashboard", Icons.Filled.Dashboard, "Dashboard"), - ProfileIcon("dashboard_customize", Icons.Filled.DashboardCustomize, "Dashboard Customize"), - ProfileIcon("data_exploration", Icons.Filled.DataExploration, "Data Exploration"), - ProfileIcon("data_thresholding", Icons.Filled.DataThresholding, "Data Thresholding"), - ProfileIcon("date_range", Icons.Filled.DateRange, "Date Range"), - ProfileIcon("delete", Icons.Filled.Delete, "Delete"), - ProfileIcon("delete_forever", Icons.Filled.DeleteForever, "Delete Forever"), - ProfileIcon("delete_outline", Icons.Filled.DeleteOutline, "Delete Outline"), - ProfileIcon("delete_sweep", Icons.Filled.DeleteSweep, "Delete Sweep"), - ProfileIcon("density_large", Icons.Filled.DensityLarge, "Density Large"), - ProfileIcon("density_medium", Icons.Filled.DensityMedium, "Density Medium"), - ProfileIcon("density_small", Icons.Filled.DensitySmall, "Density Small"), - ProfileIcon("description", Icons.Filled.Description, "Description"), - ProfileIcon("disabled_by_default", Icons.Filled.DisabledByDefault, "Disabled"), - ProfileIcon("disabled_visible", Icons.Filled.DisabledVisible, "Disabled Visible"), - ProfileIcon("display_settings", Icons.Filled.DisplaySettings, "Display Settings"), - ProfileIcon("dns", Icons.Filled.Dns, "DNS"), - ProfileIcon("done", Icons.Filled.Done, "Done"), - ProfileIcon("done_all", Icons.Filled.DoneAll, "Done All"), - ProfileIcon("done_outline", Icons.Filled.DoneOutline, "Done Outline"), - ProfileIcon("donut_large", Icons.Filled.DonutLarge, "Donut Large"), - ProfileIcon("donut_small", Icons.Filled.DonutSmall, "Donut Small"), - ProfileIcon("drag_indicator", Icons.Filled.DragIndicator, "Drag"), - ProfileIcon("dynamic_form", Icons.Filled.DynamicForm, "Dynamic Form"), - ProfileIcon("eco", Icons.Filled.Eco, "Eco"), - ProfileIcon("edit_calendar", Icons.Filled.EditCalendar, "Edit Calendar"), - ProfileIcon("edit_note", Icons.Filled.EditNote, "Edit Note"), - ProfileIcon("edit_off", Icons.Filled.EditOff, "Edit Off"), - ProfileIcon("eject", Icons.Filled.Eject, "Eject"), - ProfileIcon("euro_symbol", Icons.Filled.Euro, "Euro"), - ProfileIcon("event", Icons.Filled.Event, "Event"), - ProfileIcon("event_repeat", Icons.Filled.EventRepeat, "Event Repeat"), - ProfileIcon("event_seat", Icons.Filled.EventSeat, "Event Seat"), - ProfileIcon("exit_to_app", Icons.AutoMirrored.Filled.ExitToApp, "Exit"), - ProfileIcon("expand", Icons.Filled.Expand, "Expand"), - ProfileIcon("explore", Icons.Filled.Explore, "Explore"), - ProfileIcon("explore_off", Icons.Filled.ExploreOff, "Explore Off"), - ProfileIcon("extension", Icons.Filled.Extension, "Extension"), - ProfileIcon("extension_off", Icons.Filled.ExtensionOff, "Extension Off"), - ProfileIcon("face", Icons.Filled.Face, "Face"), - // ProfileIcon("face_unlock", Icons.Filled.FaceUnlock, "Face Unlock"), - ProfileIcon("fact_check", Icons.AutoMirrored.Filled.FactCheck, "Fact Check"), - ProfileIcon("favorite", Icons.Filled.Favorite, "Favorite"), - ProfileIcon("favorite_border", Icons.Filled.FavoriteBorder, "Favorite Border"), - ProfileIcon("fax", Icons.Filled.Fax, "Fax"), - ProfileIcon("feedback", Icons.Filled.Feedback, "Feedback"), - ProfileIcon("file_download", Icons.Filled.FileDownload, "Download"), - ProfileIcon("file_download_done", Icons.Filled.FileDownloadDone, "Download Done"), - ProfileIcon("file_download_off", Icons.Filled.FileDownloadOff, "Download Off"), - ProfileIcon("file_open", Icons.Filled.FileOpen, "File Open"), - ProfileIcon("file_present", Icons.Filled.FilePresent, "File Present"), - ProfileIcon("file_upload", Icons.Filled.FileUpload, "Upload"), - ProfileIcon("filter_alt", Icons.Filled.FilterAlt, "Filter Alt"), - ProfileIcon("filter_alt_off", Icons.Filled.FilterAltOff, "Filter Alt Off"), - ProfileIcon("filter_list", Icons.Filled.FilterList, "Filter"), - ProfileIcon("filter_list_off", Icons.Filled.FilterListOff, "Filter Off"), - ProfileIcon("find_in_page", Icons.Filled.FindInPage, "Find"), - ProfileIcon("find_replace", Icons.Filled.FindReplace, "Find Replace"), - ProfileIcon("fingerprint", Icons.Filled.Fingerprint, "Fingerprint"), - ProfileIcon("fit_screen", Icons.Filled.FitScreen, "Fit Screen"), - ProfileIcon("flaky", Icons.Filled.Flaky, "Flaky"), - ProfileIcon("flight_land", Icons.Filled.FlightLand, "Landing"), - ProfileIcon("flight_takeoff", Icons.Filled.FlightTakeoff, "Takeoff"), - ProfileIcon("flip_to_back", Icons.Filled.FlipToBack, "Flip Back"), - ProfileIcon("flip_to_front", Icons.Filled.FlipToFront, "Flip Front"), - ProfileIcon("flutter_dash", Icons.Filled.FlutterDash, "Flutter Dash"), - ProfileIcon("free_cancellation", Icons.Filled.FreeCancellation, "Free Cancellation"), - ProfileIcon("g_translate", Icons.Filled.GTranslate, "Translate"), - ProfileIcon("gavel", Icons.Filled.Gavel, "Gavel"), - ProfileIcon("generating_tokens", Icons.Filled.GeneratingTokens, "Generating Tokens"), - ProfileIcon("get_app", Icons.Filled.GetApp, "Get App"), - ProfileIcon("gif", Icons.Filled.Gif, "GIF"), - ProfileIcon("gif_box", Icons.Filled.GifBox, "GIF Box"), - ProfileIcon("grade", Icons.Filled.Grade, "Grade"), - ProfileIcon("grading", Icons.AutoMirrored.Filled.Grading, "Grading"), - ProfileIcon("group_work", Icons.Filled.GroupWork, "Group Work"), - ProfileIcon("help", Icons.AutoMirrored.Filled.Help, "Help"), - ProfileIcon("help_center", Icons.AutoMirrored.Filled.HelpCenter, "Help Center"), - ProfileIcon("help_outline", Icons.AutoMirrored.Filled.HelpOutline, "Help Outline"), - ProfileIcon("hide_source", Icons.Filled.HideSource, "Hide Source"), - ProfileIcon("highlight_alt", Icons.Filled.HighlightAlt, "Highlight Alt"), - ProfileIcon("highlight_off", Icons.Filled.HighlightOff, "Highlight Off"), - ProfileIcon("history", Icons.Filled.History, "History"), - ProfileIcon("history_toggle_off", Icons.Filled.HistoryToggleOff, "History Off"), - ProfileIcon("hls", Icons.Filled.Hls, "HLS"), - ProfileIcon("hls_off", Icons.Filled.HlsOff, "HLS Off"), - ProfileIcon("home", Icons.Filled.Home, "Home"), - ProfileIcon("home_filled", Icons.Filled.Home, "Home Filled"), - ProfileIcon("horizontal_split", Icons.Filled.HorizontalSplit, "Horizontal Split"), - ProfileIcon("hourglass_disabled", Icons.Filled.HourglassDisabled, "Hourglass Disabled"), - ProfileIcon("hourglass_empty", Icons.Filled.HourglassEmpty, "Hourglass Empty"), - ProfileIcon("hourglass_full", Icons.Filled.HourglassFull, "Hourglass Full"), - ProfileIcon("html", Icons.Filled.Html, "HTML"), - ProfileIcon("http", Icons.Filled.Http, "HTTP"), - ProfileIcon("https", Icons.Filled.Https, "HTTPS"), - ProfileIcon("important_devices", Icons.Filled.ImportantDevices, "Important Devices"), - ProfileIcon("info", Icons.Filled.Info, "Info"), - // ProfileIcon("info_outline", Icons.Filled.InfoOutline, "Info Outline"), - ProfileIcon("input", Icons.AutoMirrored.Filled.Input, "Input"), - ProfileIcon("install_desktop", Icons.Filled.InstallDesktop, "Install Desktop"), - ProfileIcon("install_mobile", Icons.Filled.InstallMobile, "Install Mobile"), - ProfileIcon( - "integration_instructions", - Icons.Filled.IntegrationInstructions, - "Integration", - ), - ProfileIcon("invert_colors", Icons.Filled.InvertColors, "Invert Colors"), - ProfileIcon("javascript", Icons.Filled.Javascript, "JavaScript"), - ProfileIcon("join_full", Icons.Filled.JoinFull, "Join Full"), - ProfileIcon("join_inner", Icons.Filled.JoinInner, "Join Inner"), - ProfileIcon("join_left", Icons.Filled.JoinLeft, "Join Left"), - ProfileIcon("join_right", Icons.Filled.JoinRight, "Join Right"), - ProfileIcon("label", Icons.AutoMirrored.Filled.Label, "Label"), - ProfileIcon("label_important", Icons.AutoMirrored.Filled.LabelImportant, "Important"), - ProfileIcon("label_off", Icons.AutoMirrored.Filled.LabelOff, "Label Off"), - ProfileIcon("language", Icons.Filled.Language, "Language"), - ProfileIcon("launch", Icons.AutoMirrored.Filled.Launch, "Launch"), - ProfileIcon("leaderboard", Icons.Filled.Leaderboard, "Leaderboard"), - ProfileIcon("lightbulb", Icons.Filled.Lightbulb, "Lightbulb"), - ProfileIcon("lightbulb_circle", Icons.Filled.LightbulbCircle, "Lightbulb Circle"), - // ProfileIcon("lightbulb_outline", Icons.Filled.LightbulbOutline, "Lightbulb Outline"), - ProfileIcon("line_style", Icons.Filled.LineStyle, "Line Style"), - ProfileIcon("line_weight", Icons.Filled.LineWeight, "Line Weight"), - ProfileIcon("list", Icons.AutoMirrored.Filled.List, "List"), - ProfileIcon("list_alt", Icons.AutoMirrored.Filled.ListAlt, "List Alt"), - ProfileIcon("lock", Icons.Filled.Lock, "Lock"), - ProfileIcon("lock_clock", Icons.Filled.LockClock, "Lock Clock"), - ProfileIcon("lock_open", Icons.Filled.LockOpen, "Lock Open"), - // ProfileIcon("lock_outline", Icons.Filled.LockOutline, "Lock Outline"), - ProfileIcon("lock_person", Icons.Filled.LockPerson, "Lock Person"), - ProfileIcon("lock_reset", Icons.Filled.LockReset, "Lock Reset"), - ProfileIcon("login", Icons.AutoMirrored.Filled.Login, "Login"), - ProfileIcon("logout", Icons.AutoMirrored.Filled.Logout, "Logout"), - ProfileIcon("loyalty", Icons.Filled.Loyalty, "Loyalty"), - ProfileIcon("manage_accounts", Icons.Filled.ManageAccounts, "Manage Accounts"), - ProfileIcon("manage_history", Icons.Filled.ManageHistory, "Manage History"), - ProfileIcon("manage_search", Icons.Filled.ManageSearch, "Manage Search"), - ProfileIcon("mark_as_unread", Icons.Filled.MarkAsUnread, "Mark Unread"), - ProfileIcon("markunread_mailbox", Icons.Filled.MarkunreadMailbox, "Unread Mailbox"), - ProfileIcon("maximize", Icons.Filled.Maximize, "Maximize"), - ProfileIcon("mediation", Icons.Filled.Mediation, "Mediation"), - ProfileIcon("minimize", Icons.Filled.Minimize, "Minimize"), - ProfileIcon("model_training", Icons.Filled.ModelTraining, "Model Training"), - ProfileIcon("next_plan", Icons.AutoMirrored.Filled.NextPlan, "Next Plan"), - ProfileIcon("nightlight", Icons.Filled.Nightlight, "Nightlight"), - ProfileIcon("nightlight_round", Icons.Filled.NightlightRound, "Nightlight Round"), - ProfileIcon("no_accounts", Icons.Filled.NoAccounts, "No Accounts"), - ProfileIcon("not_started", Icons.Filled.NotStarted, "Not Started"), - ProfileIcon("note_add", Icons.AutoMirrored.Filled.NoteAdd, "Note Add"), - ProfileIcon("offline_bolt", Icons.Filled.OfflineBolt, "Offline Bolt"), - ProfileIcon("offline_pin", Icons.Filled.OfflinePin, "Offline Pin"), - ProfileIcon("online_prediction", Icons.Filled.OnlinePrediction, "Online Prediction"), - ProfileIcon("opacity", Icons.Filled.Opacity, "Opacity"), - ProfileIcon("open_in_browser", Icons.Filled.OpenInBrowser, "Open Browser"), - ProfileIcon("open_in_full", Icons.Filled.OpenInFull, "Open Full"), - ProfileIcon("open_in_new", Icons.Filled.OpenInNew, "Open New"), - ProfileIcon("open_in_new_off", Icons.Filled.OpenInNewOff, "Open New Off"), - ProfileIcon("open_with", Icons.Filled.OpenWith, "Open With"), - ProfileIcon("outbond", Icons.Filled.Outbond, "Outbond"), - ProfileIcon("outlet", Icons.Filled.Outlet, "Outlet"), - ProfileIcon("output", Icons.Filled.Output, "Output"), - ProfileIcon("pageview", Icons.Filled.Pageview, "Pageview"), - ProfileIcon("paid", Icons.Filled.Paid, "Paid"), - ProfileIcon("pan_tool", Icons.Filled.PanTool, "Pan Tool"), - ProfileIcon("pan_tool_alt", Icons.Filled.PanToolAlt, "Pan Tool Alt"), - ProfileIcon("payment", Icons.Filled.Payment, "Payment"), - ProfileIcon("pending", Icons.Filled.Pending, "Pending"), - ProfileIcon("pending_actions", Icons.Filled.PendingActions, "Pending Actions"), - ProfileIcon("percent", Icons.Filled.Percent, "Percent"), - ProfileIcon("perm_camera_mic", Icons.Filled.PermCameraMic, "Camera Mic"), - ProfileIcon("perm_contact_calendar", Icons.Filled.PermContactCalendar, "Contact Calendar"), - ProfileIcon("perm_data_setting", Icons.Filled.PermDataSetting, "Data Setting"), - ProfileIcon("perm_device_information", Icons.Filled.PermDeviceInformation, "Device Info"), - ProfileIcon("perm_identity", Icons.Filled.PermIdentity, "Identity"), - ProfileIcon("perm_media", Icons.Filled.PermMedia, "Media"), - ProfileIcon("perm_phone_msg", Icons.Filled.PermPhoneMsg, "Phone Message"), - ProfileIcon("perm_scan_wifi", Icons.Filled.PermScanWifi, "Scan WiFi"), - ProfileIcon("pets", Icons.Filled.Pets, "Pets"), - ProfileIcon("php", Icons.Filled.Php, "PHP"), - ProfileIcon("picture_in_picture", Icons.Filled.PictureInPicture, "Picture in Picture"), - ProfileIcon("picture_in_picture_alt", Icons.Filled.PictureInPictureAlt, "PiP Alt"), - ProfileIcon("pin_end", Icons.Filled.PinEnd, "Pin End"), - ProfileIcon("pin_invoke", Icons.Filled.PinInvoke, "Pin Invoke"), - ProfileIcon("plagiarism", Icons.Filled.Plagiarism, "Plagiarism"), - ProfileIcon("play_for_work", Icons.Filled.PlayForWork, "Play Work"), - ProfileIcon("polymer", Icons.Filled.Polymer, "Polymer"), - ProfileIcon("power_settings_new", Icons.Filled.PowerSettingsNew, "Power"), - ProfileIcon("pregnant_woman", Icons.Filled.PregnantWoman, "Pregnant Woman"), - ProfileIcon("preview", Icons.Filled.Preview, "Preview"), - ProfileIcon("print", Icons.Filled.Print, "Print"), - ProfileIcon("print_disabled", Icons.Filled.PrintDisabled, "Print Disabled"), - ProfileIcon("privacy_tip", Icons.Filled.PrivacyTip, "Privacy Tip"), - ProfileIcon( - "production_quantity_limits", - Icons.Filled.ProductionQuantityLimits, - "Quantity Limits", - ), - ProfileIcon("published_with_changes", Icons.Filled.PublishedWithChanges, "Published"), - ProfileIcon("query_builder", Icons.Filled.QueryBuilder, "Query Builder"), - ProfileIcon("question_answer", Icons.Filled.QuestionAnswer, "Q&A"), - ProfileIcon("question_mark", Icons.Filled.QuestionMark, "Question Mark"), - ProfileIcon("quickreply", Icons.Filled.Quickreply, "Quick Reply"), - ProfileIcon("receipt", Icons.Filled.Receipt, "Receipt"), - ProfileIcon("receipt_long", Icons.AutoMirrored.Filled.ReceiptLong, "Receipt Long"), - ProfileIcon("record_voice_over", Icons.Filled.RecordVoiceOver, "Voice Over"), - ProfileIcon("redeem", Icons.Filled.Redeem, "Redeem"), - ProfileIcon("refresh", Icons.Filled.Refresh, "Refresh"), - ProfileIcon("remove_done", Icons.Filled.RemoveDone, "Remove Done"), - ProfileIcon("remove_shopping_cart", Icons.Filled.RemoveShoppingCart, "Remove Cart"), - ProfileIcon("reorder", Icons.Filled.Reorder, "Reorder"), - ProfileIcon("repartition", Icons.Filled.Repartition, "Repartition"), - ProfileIcon("report_problem", Icons.Filled.ReportProblem, "Report Problem"), - ProfileIcon("request_page", Icons.Filled.RequestPage, "Request Page"), - ProfileIcon("request_quote", Icons.Filled.RequestQuote, "Request Quote"), - ProfileIcon("restore", Icons.Filled.Restore, "Restore"), - ProfileIcon("restore_from_trash", Icons.Filled.RestoreFromTrash, "Restore Trash"), - ProfileIcon("restore_page", Icons.Filled.RestorePage, "Restore Page"), - ProfileIcon("rocket", Icons.Filled.Rocket, "Rocket"), - ProfileIcon("rocket_launch", Icons.Filled.RocketLaunch, "Rocket Launch"), - ProfileIcon("room", Icons.Filled.Room, "Room"), - ProfileIcon("rounded_corner", Icons.Filled.RoundedCorner, "Rounded Corner"), - ProfileIcon("rowing", Icons.Filled.Rowing, "Rowing"), - ProfileIcon("rule", Icons.Filled.Rule, "Rule"), - ProfileIcon("satellite_alt", Icons.Filled.SatelliteAlt, "Satellite"), - ProfileIcon("save", Icons.Filled.Save, "Save"), - ProfileIcon("save_alt", Icons.Filled.SaveAlt, "Save Alt"), - ProfileIcon("save_as", Icons.Filled.SaveAs, "Save As"), - ProfileIcon("saved_search", Icons.Filled.SavedSearch, "Saved Search"), - ProfileIcon("savings", Icons.Filled.Savings, "Savings"), - ProfileIcon("schedule", Icons.Filled.Schedule, "Schedule"), - ProfileIcon("schedule_send", Icons.Filled.ScheduleSend, "Schedule Send"), - ProfileIcon("search", Icons.Filled.Search, "Search"), - ProfileIcon("search_off", Icons.Filled.SearchOff, "Search Off"), - ProfileIcon("segment", Icons.Filled.Segment, "Segment"), - ProfileIcon("send", Icons.AutoMirrored.Filled.Send, "Send"), - ProfileIcon("send_and_archive", Icons.Filled.SendAndArchive, "Send Archive"), - ProfileIcon("sensors", Icons.Filled.Sensors, "Sensors"), - ProfileIcon("sensors_off", Icons.Filled.SensorsOff, "Sensors Off"), - ProfileIcon("settings", Icons.Filled.Settings, "Settings"), - ProfileIcon("settings_accessibility", Icons.Filled.SettingsAccessibility, "Accessibility"), - ProfileIcon("settings_applications", Icons.Filled.SettingsApplications, "Applications"), - ProfileIcon("settings_backup_restore", Icons.Filled.SettingsBackupRestore, "Backup"), - ProfileIcon("settings_bluetooth", Icons.Filled.SettingsBluetooth, "Bluetooth"), - ProfileIcon("settings_brightness", Icons.Filled.SettingsBrightness, "Brightness"), - ProfileIcon("settings_cell", Icons.Filled.SettingsCell, "Cell"), - ProfileIcon("settings_ethernet", Icons.Filled.SettingsEthernet, "Ethernet"), - ProfileIcon("settings_input_antenna", Icons.Filled.SettingsInputAntenna, "Antenna"), - ProfileIcon("settings_input_component", Icons.Filled.SettingsInputComponent, "Component"), - ProfileIcon("settings_input_composite", Icons.Filled.SettingsInputComposite, "Composite"), - ProfileIcon("settings_input_hdmi", Icons.Filled.SettingsInputHdmi, "HDMI"), - ProfileIcon("settings_input_svideo", Icons.Filled.SettingsInputSvideo, "S-Video"), - ProfileIcon("settings_overscan", Icons.Filled.SettingsOverscan, "Overscan"), - ProfileIcon("settings_phone", Icons.Filled.SettingsPhone, "Phone"), - ProfileIcon("settings_power", Icons.Filled.SettingsPower, "Power"), - ProfileIcon("settings_remote", Icons.Filled.SettingsRemote, "Remote"), - ProfileIcon("settings_voice", Icons.Filled.SettingsVoice, "Voice"), - ProfileIcon("shop", Icons.Filled.Shop, "Shop"), - ProfileIcon("shop_2", Icons.Filled.Shop2, "Shop 2"), - ProfileIcon("shop_two", Icons.Filled.ShopTwo, "Shop Two"), - ProfileIcon("shopping_bag", Icons.Filled.ShoppingBag, "Shopping Bag"), - ProfileIcon("shopping_basket", Icons.Filled.ShoppingBasket, "Shopping Basket"), - ProfileIcon("shopping_cart", Icons.Filled.ShoppingCart, "Shopping Cart"), - ProfileIcon("shopping_cart_checkout", Icons.Filled.ShoppingCartCheckout, "Checkout"), - ProfileIcon("smart_button", Icons.Filled.SmartButton, "Smart Button"), - ProfileIcon("source", Icons.Filled.Source, "Source"), - ProfileIcon("space_dashboard", Icons.Filled.SpaceDashboard, "Space Dashboard"), - ProfileIcon("spatial_audio", Icons.Filled.SpatialAudio, "Spatial Audio"), - ProfileIcon("spatial_audio_off", Icons.Filled.SpatialAudioOff, "Spatial Audio Off"), - ProfileIcon("spatial_tracking", Icons.Filled.SpatialTracking, "Spatial Tracking"), - ProfileIcon("speaker_notes", Icons.Filled.SpeakerNotes, "Speaker Notes"), - ProfileIcon("speaker_notes_off", Icons.Filled.SpeakerNotesOff, "Speaker Notes Off"), - ProfileIcon("spellcheck", Icons.Filled.Spellcheck, "Spellcheck"), - ProfileIcon("star_rate", Icons.Filled.StarRate, "Star Rate"), - ProfileIcon("stars", Icons.Filled.Stars, "Stars"), - ProfileIcon("sticky_note_2", Icons.Filled.StickyNote2, "Sticky Note"), - ProfileIcon("store", Icons.Filled.Store, "Store"), - ProfileIcon("subject", Icons.AutoMirrored.Filled.Subject, "Subject"), - ProfileIcon("subtitles_off", Icons.Filled.SubtitlesOff, "Subtitles Off"), - ProfileIcon("supervised_user_circle", Icons.Filled.SupervisedUserCircle, "Supervised User"), - ProfileIcon("supervisor_account", Icons.Filled.SupervisorAccount, "Supervisor"), - ProfileIcon("support", Icons.Filled.Support, "Support"), - ProfileIcon("swap_horiz", Icons.Filled.SwapHoriz, "Swap Horizontal"), - ProfileIcon("swap_horizontal_circle", Icons.Filled.SwapHorizontalCircle, "Swap Circle"), - ProfileIcon("swap_vert", Icons.Filled.SwapVert, "Swap Vertical"), - ProfileIcon( - "swap_vertical_circle", - Icons.Filled.SwapVerticalCircle, - "Swap Vertical Circle", - ), - ProfileIcon("swipe", Icons.Filled.Swipe, "Swipe"), - ProfileIcon("swipe_down", Icons.Filled.SwipeDown, "Swipe Down"), - ProfileIcon("swipe_down_alt", Icons.Filled.SwipeDownAlt, "Swipe Down Alt"), - ProfileIcon("swipe_left", Icons.Filled.SwipeLeft, "Swipe Left"), - ProfileIcon("swipe_left_alt", Icons.Filled.SwipeLeftAlt, "Swipe Left Alt"), - ProfileIcon("swipe_right", Icons.Filled.SwipeRight, "Swipe Right"), - ProfileIcon("swipe_right_alt", Icons.Filled.SwipeRightAlt, "Swipe Right Alt"), - ProfileIcon("swipe_up", Icons.Filled.SwipeUp, "Swipe Up"), - ProfileIcon("swipe_up_alt", Icons.Filled.SwipeUpAlt, "Swipe Up Alt"), - ProfileIcon("swipe_vertical", Icons.Filled.SwipeVertical, "Swipe Vertical"), - ProfileIcon("switch_access_shortcut", Icons.Filled.SwitchAccessShortcut, "Switch Shortcut"), - ProfileIcon( - "switch_access_shortcut_add", - Icons.Filled.SwitchAccessShortcutAdd, - "Add Shortcut", - ), - ProfileIcon("sync_alt", Icons.Filled.SyncAlt, "Sync Alt"), - ProfileIcon("system_update_alt", Icons.Filled.SystemUpdateAlt, "System Update"), - ProfileIcon("tab", Icons.Filled.Tab, "Tab"), - ProfileIcon("tab_unselected", Icons.Filled.TabUnselected, "Tab Unselected"), - ProfileIcon("table_view", Icons.Filled.TableView, "Table View"), - ProfileIcon("tag_faces", Icons.Filled.TagFaces, "Tag Faces"), - ProfileIcon("task_alt", Icons.Filled.TaskAlt, "Task Alt"), - ProfileIcon("terminal", Icons.Filled.Terminal, "Terminal"), - ProfileIcon("text_rotate_up", Icons.Filled.TextRotateUp, "Text Rotate Up"), - ProfileIcon("text_rotate_vertical", Icons.Filled.TextRotateVertical, "Text Vertical"), - ProfileIcon( - "text_rotation_angledown", - Icons.Filled.TextRotationAngledown, - "Text Angledown", - ), - ProfileIcon("text_rotation_angleup", Icons.Filled.TextRotationAngleup, "Text Angleup"), - ProfileIcon("text_rotation_down", Icons.Filled.TextRotationDown, "Text Down"), - ProfileIcon("text_rotation_none", Icons.Filled.TextRotationNone, "Text None"), - ProfileIcon("theaters", Icons.Filled.Theaters, "Theaters"), - ProfileIcon("thumb_down", Icons.Filled.ThumbDown, "Thumb Down"), - ProfileIcon("thumb_down_off_alt", Icons.Filled.ThumbDownOffAlt, "Thumb Down Alt"), - ProfileIcon("thumb_up", Icons.Filled.ThumbUp, "Thumb Up"), - ProfileIcon("thumb_up_off_alt", Icons.Filled.ThumbUpOffAlt, "Thumb Up Alt"), - ProfileIcon("thumbs_up_down", Icons.Filled.ThumbsUpDown, "Thumbs Up Down"), - ProfileIcon("timeline", Icons.Filled.Timeline, "Timeline"), - ProfileIcon("tips_and_updates", Icons.Filled.TipsAndUpdates, "Tips & Updates"), - ProfileIcon("toc", Icons.AutoMirrored.Filled.Toc, "Table of Contents"), - ProfileIcon("today", Icons.Filled.Today, "Today"), - ProfileIcon("token", Icons.Filled.Token, "Token"), - ProfileIcon("toll", Icons.Filled.Toll, "Toll"), - ProfileIcon("touch_app", Icons.Filled.TouchApp, "Touch App"), - ProfileIcon("tour", Icons.Filled.Tour, "Tour"), - ProfileIcon("track_changes", Icons.Filled.TrackChanges, "Track Changes"), - ProfileIcon("transcribe", Icons.Filled.Transcribe, "Transcribe"), - ProfileIcon("translate", Icons.Filled.Translate, "Translate"), - ProfileIcon("trending_down", Icons.AutoMirrored.Filled.TrendingDown, "Trending Down"), - ProfileIcon("trending_flat", Icons.AutoMirrored.Filled.TrendingFlat, "Trending Flat"), - ProfileIcon("trending_up", Icons.AutoMirrored.Filled.TrendingUp, "Trending Up"), - ProfileIcon("troubleshoot", Icons.Filled.Troubleshoot, "Troubleshoot"), - // ProfileIcon("try_sms_star", Icons.Filled.TrySmsStar, "Try SMS Star"), - ProfileIcon("turned_in", Icons.Filled.TurnedIn, "Turned In"), - ProfileIcon("turned_in_not", Icons.Filled.TurnedInNot, "Turned In Not"), - ProfileIcon("unfold_less_double", Icons.Filled.UnfoldLessDouble, "Unfold Less Double"), - ProfileIcon("unfold_more_double", Icons.Filled.UnfoldMoreDouble, "Unfold More Double"), - ProfileIcon("unpublished", Icons.Filled.Unpublished, "Unpublished"), - ProfileIcon("update", Icons.Filled.Update, "Update"), - ProfileIcon("update_disabled", Icons.Filled.UpdateDisabled, "Update Disabled"), - ProfileIcon("upgrade", Icons.Filled.Upgrade, "Upgrade"), - ProfileIcon("verified", Icons.Filled.Verified, "Verified"), - ProfileIcon("verified_user", Icons.Filled.VerifiedUser, "Verified User"), - ProfileIcon("vertical_split", Icons.Filled.VerticalSplit, "Vertical Split"), - ProfileIcon("view_agenda", Icons.Filled.ViewAgenda, "View Agenda"), - ProfileIcon("view_array", Icons.Filled.ViewArray, "View Array"), - ProfileIcon("view_carousel", Icons.Filled.ViewCarousel, "View Carousel"), - ProfileIcon("view_column", Icons.Filled.ViewColumn, "View Column"), - ProfileIcon("view_comfy", Icons.Filled.ViewComfy, "View Comfy"), - ProfileIcon("view_comfy_alt", Icons.Filled.ViewComfyAlt, "View Comfy Alt"), - ProfileIcon("view_compact", Icons.Filled.ViewCompact, "View Compact"), - ProfileIcon("view_compact_alt", Icons.Filled.ViewCompactAlt, "View Compact Alt"), - ProfileIcon("view_cozy", Icons.Filled.ViewCozy, "View Cozy"), - ProfileIcon("view_day", Icons.Filled.ViewDay, "View Day"), - ProfileIcon("view_headline", Icons.Filled.ViewHeadline, "View Headline"), - ProfileIcon("view_in_ar", Icons.Filled.ViewInAr, "View in AR"), - ProfileIcon("view_kanban", Icons.Filled.ViewKanban, "View Kanban"), - ProfileIcon("view_list", Icons.AutoMirrored.Filled.ViewList, "View List"), - ProfileIcon("view_module", Icons.Filled.ViewModule, "View Module"), - ProfileIcon("view_quilt", Icons.AutoMirrored.Filled.ViewQuilt, "View Quilt"), - ProfileIcon("view_sidebar", Icons.AutoMirrored.Filled.ViewSidebar, "View Sidebar"), - ProfileIcon("view_stream", Icons.Filled.ViewStream, "View Stream"), - ProfileIcon("view_timeline", Icons.Filled.ViewTimeline, "View Timeline"), - ProfileIcon("view_week", Icons.Filled.ViewWeek, "View Week"), - ProfileIcon("visibility", Icons.Filled.Visibility, "Visibility"), - ProfileIcon("visibility_off", Icons.Filled.VisibilityOff, "Visibility Off"), - ProfileIcon("voice_over_off", Icons.Filled.VoiceOverOff, "Voice Over Off"), - ProfileIcon("watch_later", Icons.Filled.WatchLater, "Watch Later"), - ProfileIcon("webhook", Icons.Filled.Webhook, "Webhook"), - ProfileIcon("width_full", Icons.Filled.WidthFull, "Width Full"), - ProfileIcon("width_normal", Icons.Filled.WidthNormal, "Width Normal"), - ProfileIcon("width_wide", Icons.Filled.WidthWide, "Width Wide"), - ProfileIcon("wifi_protected_setup", Icons.Filled.WifiProtectedSetup, "WiFi Setup"), - ProfileIcon("work", Icons.Filled.Work, "Work"), - ProfileIcon("work_history", Icons.Filled.WorkHistory, "Work History"), - ProfileIcon("work_off", Icons.Filled.WorkOff, "Work Off"), - ProfileIcon("work_outline", Icons.Filled.WorkOutline, "Work Outline"), - ProfileIcon("wysiwyg", Icons.Filled.Wysiwyg, "WYSIWYG"), - ProfileIcon("zoom_in", Icons.Filled.ZoomIn, "Zoom In"), - ProfileIcon("zoom_out", Icons.Filled.ZoomOut, "Zoom Out"), - ) -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/AlertIcons.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/AlertIcons.kt deleted file mode 100644 index 216e951c4f..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/AlertIcons.kt +++ /dev/null @@ -1,28 +0,0 @@ -package io.nekohasekai.sfa.compose.util.icons - -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.AddAlert -import androidx.compose.material.icons.filled.AutoDelete -import androidx.compose.material.icons.filled.Error -import androidx.compose.material.icons.filled.ErrorOutline -import androidx.compose.material.icons.filled.NotificationImportant -import androidx.compose.material.icons.filled.Warning -import androidx.compose.material.icons.filled.WarningAmber -import io.nekohasekai.sfa.compose.util.ProfileIcon - -/** - * Alert category icons - Warnings, errors, and notifications - * Based on Google's Material Design Icons taxonomy - */ -object AlertIcons { - val icons = - listOf( - ProfileIcon("add_alert", Icons.Filled.AddAlert, "Add Alert"), - ProfileIcon("auto_delete", Icons.Filled.AutoDelete, "Auto Delete"), - ProfileIcon("error", Icons.Filled.Error, "Error"), - ProfileIcon("error_outline", Icons.Filled.ErrorOutline, "Error Outline"), - ProfileIcon("notification_important", Icons.Filled.NotificationImportant, "Important"), - ProfileIcon("warning", Icons.Filled.Warning, "Warning"), - ProfileIcon("warning_amber", Icons.Filled.WarningAmber, "Warning Amber"), - ) -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/CommunicationIcons.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/CommunicationIcons.kt deleted file mode 100644 index fda3847b68..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/CommunicationIcons.kt +++ /dev/null @@ -1,218 +0,0 @@ -package io.nekohasekai.sfa.compose.util.icons - -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.Chat -import androidx.compose.material.icons.automirrored.filled.ListAlt -import androidx.compose.material.icons.automirrored.filled.LiveHelp -import androidx.compose.material.icons.automirrored.filled.Message -import androidx.compose.material.icons.automirrored.filled.ReadMore -import androidx.compose.material.icons.filled.AddIcCall -import androidx.compose.material.icons.filled.AlternateEmail -import androidx.compose.material.icons.filled.AppRegistration -import androidx.compose.material.icons.filled.Business -import androidx.compose.material.icons.filled.Call -import androidx.compose.material.icons.filled.CallEnd -import androidx.compose.material.icons.filled.CallMade -import androidx.compose.material.icons.filled.CallMerge -import androidx.compose.material.icons.filled.CallMissed -import androidx.compose.material.icons.filled.CallMissedOutgoing -import androidx.compose.material.icons.filled.CallReceived -import androidx.compose.material.icons.filled.CallSplit -import androidx.compose.material.icons.filled.CancelPresentation -import androidx.compose.material.icons.filled.CellWifi -import androidx.compose.material.icons.filled.ChatBubble -import androidx.compose.material.icons.filled.ChatBubbleOutline -import androidx.compose.material.icons.filled.ClearAll -import androidx.compose.material.icons.filled.CoPresent -import androidx.compose.material.icons.filled.Comment -import androidx.compose.material.icons.filled.CommentsDisabled -import androidx.compose.material.icons.filled.ContactEmergency -import androidx.compose.material.icons.filled.ContactMail -import androidx.compose.material.icons.filled.ContactPhone -import androidx.compose.material.icons.filled.Contacts -import androidx.compose.material.icons.filled.DesktopAccessDisabled -import androidx.compose.material.icons.filled.DialerSip -import androidx.compose.material.icons.filled.Dialpad -import androidx.compose.material.icons.filled.DocumentScanner -import androidx.compose.material.icons.filled.DomainDisabled -import androidx.compose.material.icons.filled.DomainVerification -import androidx.compose.material.icons.filled.Duo -import androidx.compose.material.icons.filled.Email -import androidx.compose.material.icons.filled.Forum -import androidx.compose.material.icons.filled.ForwardToInbox -import androidx.compose.material.icons.filled.HourglassBottom -import androidx.compose.material.icons.filled.HourglassTop -import androidx.compose.material.icons.filled.Hub -import androidx.compose.material.icons.filled.ImportContacts -import androidx.compose.material.icons.filled.ImportExport -import androidx.compose.material.icons.filled.Inbox -import androidx.compose.material.icons.filled.InvertColorsOff -import androidx.compose.material.icons.filled.Key -import androidx.compose.material.icons.filled.KeyOff -import androidx.compose.material.icons.filled.LocationOff -import androidx.compose.material.icons.filled.LocationOn -import androidx.compose.material.icons.filled.Mail -import androidx.compose.material.icons.filled.MailLock -import androidx.compose.material.icons.filled.MailOutline -import androidx.compose.material.icons.filled.MarkChatRead -import androidx.compose.material.icons.filled.MarkChatUnread -import androidx.compose.material.icons.filled.MarkEmailRead -import androidx.compose.material.icons.filled.MarkEmailUnread -import androidx.compose.material.icons.filled.MarkUnreadChatAlt -import androidx.compose.material.icons.filled.MobileScreenShare -import androidx.compose.material.icons.filled.MoreTime -import androidx.compose.material.icons.filled.Nat -import androidx.compose.material.icons.filled.NoSim -import androidx.compose.material.icons.filled.PausePresentation -import androidx.compose.material.icons.filled.PersonAddDisabled -import androidx.compose.material.icons.filled.PersonSearch -import androidx.compose.material.icons.filled.Phone -import androidx.compose.material.icons.filled.PhoneDisabled -import androidx.compose.material.icons.filled.PhoneEnabled -import androidx.compose.material.icons.filled.PhonelinkErase -import androidx.compose.material.icons.filled.PhonelinkLock -import androidx.compose.material.icons.filled.PhonelinkRing -import androidx.compose.material.icons.filled.PhonelinkSetup -import androidx.compose.material.icons.filled.PortableWifiOff -import androidx.compose.material.icons.filled.PresentToAll -import androidx.compose.material.icons.filled.PrintDisabled -import androidx.compose.material.icons.filled.QrCode -import androidx.compose.material.icons.filled.QrCode2 -import androidx.compose.material.icons.filled.QrCodeScanner -import androidx.compose.material.icons.filled.RingVolume -import androidx.compose.material.icons.filled.RssFeed -import androidx.compose.material.icons.filled.Rtt -import androidx.compose.material.icons.filled.ScreenShare -import androidx.compose.material.icons.filled.SendTimeExtension -import androidx.compose.material.icons.filled.SentimentSatisfiedAlt -import androidx.compose.material.icons.filled.Sip -import androidx.compose.material.icons.filled.SpeakerPhone -import androidx.compose.material.icons.filled.Spoke -import androidx.compose.material.icons.filled.StayCurrentLandscape -import androidx.compose.material.icons.filled.StayCurrentPortrait -import androidx.compose.material.icons.filled.StayPrimaryLandscape -import androidx.compose.material.icons.filled.StayPrimaryPortrait -import androidx.compose.material.icons.filled.StopScreenShare -import androidx.compose.material.icons.filled.SwapCalls -import androidx.compose.material.icons.filled.Textsms -import androidx.compose.material.icons.filled.Unsubscribe -import androidx.compose.material.icons.filled.Voicemail -import androidx.compose.material.icons.filled.VpnKey -import androidx.compose.material.icons.filled.VpnKeyOff -import io.nekohasekai.sfa.compose.util.ProfileIcon - -/** - * Communication category icons - Messaging, calls, emails - * Based on Google's Material Design Icons taxonomy - */ -object CommunicationIcons { - val icons = - listOf( - ProfileIcon("add_ic_call", Icons.Filled.AddIcCall, "Add Call"), - ProfileIcon("alternate_email", Icons.Filled.AlternateEmail, "Alt Email"), - ProfileIcon("app_registration", Icons.Filled.AppRegistration, "App Registration"), - ProfileIcon("business", Icons.Filled.Business, "Business"), - ProfileIcon("call", Icons.Filled.Call, "Call"), - ProfileIcon("call_end", Icons.Filled.CallEnd, "Call End"), - ProfileIcon("call_made", Icons.Filled.CallMade, "Call Made"), - ProfileIcon("call_merge", Icons.Filled.CallMerge, "Call Merge"), - ProfileIcon("call_missed", Icons.Filled.CallMissed, "Call Missed"), - ProfileIcon("call_missed_outgoing", Icons.Filled.CallMissedOutgoing, "Missed Outgoing"), - ProfileIcon("call_received", Icons.Filled.CallReceived, "Call Received"), - ProfileIcon("call_split", Icons.Filled.CallSplit, "Call Split"), - ProfileIcon("cancel_presentation", Icons.Filled.CancelPresentation, "Cancel Presentation"), - ProfileIcon("cell_wifi", Icons.Filled.CellWifi, "Cell WiFi"), - ProfileIcon("chat", Icons.AutoMirrored.Filled.Chat, "Chat"), - ProfileIcon("chat_bubble", Icons.Filled.ChatBubble, "Chat Bubble"), - ProfileIcon("chat_bubble_outline", Icons.Filled.ChatBubbleOutline, "Chat Outline"), - ProfileIcon("clear_all", Icons.Filled.ClearAll, "Clear All"), - ProfileIcon("co_present", Icons.Filled.CoPresent, "Co-Present"), - ProfileIcon("comment", Icons.Filled.Comment, "Comment"), - ProfileIcon("comments_disabled", Icons.Filled.CommentsDisabled, "Comments Disabled"), - ProfileIcon("contact_emergency", Icons.Filled.ContactEmergency, "Emergency Contact"), - ProfileIcon("contact_mail", Icons.Filled.ContactMail, "Contact Mail"), - ProfileIcon("contact_phone", Icons.Filled.ContactPhone, "Contact Phone"), - ProfileIcon("contacts", Icons.Filled.Contacts, "Contacts"), - ProfileIcon( - "desktop_access_disabled", - Icons.Filled.DesktopAccessDisabled, - "Desktop Disabled", - ), - ProfileIcon("dialer_sip", Icons.Filled.DialerSip, "Dialer SIP"), - ProfileIcon("dialpad", Icons.Filled.Dialpad, "Dialpad"), - ProfileIcon("document_scanner", Icons.Filled.DocumentScanner, "Document Scanner"), - ProfileIcon("domain_disabled", Icons.Filled.DomainDisabled, "Domain Disabled"), - ProfileIcon("domain_verification", Icons.Filled.DomainVerification, "Domain Verification"), - ProfileIcon("duo", Icons.Filled.Duo, "Duo"), - ProfileIcon("email", Icons.Filled.Email, "Email"), - ProfileIcon("forward_to_inbox", Icons.Filled.ForwardToInbox, "Forward to Inbox"), - ProfileIcon("forum", Icons.Filled.Forum, "Forum"), - ProfileIcon("hourglass_bottom", Icons.Filled.HourglassBottom, "Hourglass Bottom"), - ProfileIcon("hourglass_top", Icons.Filled.HourglassTop, "Hourglass Top"), - ProfileIcon("hub", Icons.Filled.Hub, "Hub"), - ProfileIcon("import_contacts", Icons.Filled.ImportContacts, "Import Contacts"), - ProfileIcon("import_export", Icons.Filled.ImportExport, "Import Export"), - ProfileIcon("inbox", Icons.Filled.Inbox, "Inbox"), - ProfileIcon("invert_colors_off", Icons.Filled.InvertColorsOff, "Invert Colors Off"), - ProfileIcon("key", Icons.Filled.Key, "Key"), - ProfileIcon("key_off", Icons.Filled.KeyOff, "Key Off"), - ProfileIcon("list_alt", Icons.AutoMirrored.Filled.ListAlt, "List Alt"), - ProfileIcon("live_help", Icons.AutoMirrored.Filled.LiveHelp, "Live Help"), - ProfileIcon("location_off", Icons.Filled.LocationOff, "Location Off"), - ProfileIcon("location_on", Icons.Filled.LocationOn, "Location On"), - ProfileIcon("mail", Icons.Filled.Mail, "Mail"), - ProfileIcon("mail_lock", Icons.Filled.MailLock, "Mail Lock"), - ProfileIcon("mail_outline", Icons.Filled.MailOutline, "Mail Outline"), - ProfileIcon("mark_chat_read", Icons.Filled.MarkChatRead, "Mark Chat Read"), - ProfileIcon("mark_chat_unread", Icons.Filled.MarkChatUnread, "Mark Chat Unread"), - ProfileIcon("mark_email_read", Icons.Filled.MarkEmailRead, "Mark Email Read"), - ProfileIcon("mark_email_unread", Icons.Filled.MarkEmailUnread, "Mark Email Unread"), - ProfileIcon("mark_unread_chat_alt", Icons.Filled.MarkUnreadChatAlt, "Mark Unread Alt"), - ProfileIcon("message", Icons.AutoMirrored.Filled.Message, "Message"), - ProfileIcon("mobile_screen_share", Icons.Filled.MobileScreenShare, "Mobile Share"), - ProfileIcon("more_time", Icons.Filled.MoreTime, "More Time"), - ProfileIcon("nat", Icons.Filled.Nat, "NAT"), - ProfileIcon("no_sim", Icons.Filled.NoSim, "No SIM"), - ProfileIcon("pause_presentation", Icons.Filled.PausePresentation, "Pause Presentation"), - ProfileIcon("person_add_disabled", Icons.Filled.PersonAddDisabled, "Person Disabled"), - ProfileIcon("person_search", Icons.Filled.PersonSearch, "Person Search"), - ProfileIcon("phone", Icons.Filled.Phone, "Phone"), - ProfileIcon("phone_disabled", Icons.Filled.PhoneDisabled, "Phone Disabled"), - ProfileIcon("phone_enabled", Icons.Filled.PhoneEnabled, "Phone Enabled"), - ProfileIcon("phonelink_erase", Icons.Filled.PhonelinkErase, "Phone Erase"), - ProfileIcon("phonelink_lock", Icons.Filled.PhonelinkLock, "Phone Lock"), - ProfileIcon("phonelink_ring", Icons.Filled.PhonelinkRing, "Phone Ring"), - ProfileIcon("phonelink_setup", Icons.Filled.PhonelinkSetup, "Phone Setup"), - ProfileIcon("portable_wifi_off", Icons.Filled.PortableWifiOff, "Portable WiFi Off"), - ProfileIcon("present_to_all", Icons.Filled.PresentToAll, "Present to All"), - ProfileIcon("print_disabled", Icons.Filled.PrintDisabled, "Print Disabled"), - ProfileIcon("qr_code", Icons.Filled.QrCode, "QR Code"), - ProfileIcon("qr_code_2", Icons.Filled.QrCode2, "QR Code 2"), - ProfileIcon("qr_code_scanner", Icons.Filled.QrCodeScanner, "QR Scanner"), - ProfileIcon("read_more", Icons.AutoMirrored.Filled.ReadMore, "Read More"), - ProfileIcon("ring_volume", Icons.Filled.RingVolume, "Ring Volume"), - ProfileIcon("rss_feed", Icons.Filled.RssFeed, "RSS Feed"), - ProfileIcon("rtt", Icons.Filled.Rtt, "RTT"), - ProfileIcon("screen_share", Icons.Filled.ScreenShare, "Screen Share"), - ProfileIcon("send_time_extension", Icons.Filled.SendTimeExtension, "Send Extension"), - ProfileIcon("sentiment_satisfied_alt", Icons.Filled.SentimentSatisfiedAlt, "Satisfied"), - ProfileIcon("sip", Icons.Filled.Sip, "SIP"), - ProfileIcon("speaker_phone", Icons.Filled.SpeakerPhone, "Speaker Phone"), - ProfileIcon("spoke", Icons.Filled.Spoke, "Spoke"), - ProfileIcon("stay_current_landscape", Icons.Filled.StayCurrentLandscape, "Stay Landscape"), - ProfileIcon("stay_current_portrait", Icons.Filled.StayCurrentPortrait, "Stay Portrait"), - ProfileIcon( - "stay_primary_landscape", - Icons.Filled.StayPrimaryLandscape, - "Primary Landscape", - ), - ProfileIcon("stay_primary_portrait", Icons.Filled.StayPrimaryPortrait, "Primary Portrait"), - ProfileIcon("stop_screen_share", Icons.Filled.StopScreenShare, "Stop Screen Share"), - ProfileIcon("swap_calls", Icons.Filled.SwapCalls, "Swap Calls"), - ProfileIcon("textsms", Icons.Filled.Textsms, "Text SMS"), - ProfileIcon("unsubscribe", Icons.Filled.Unsubscribe, "Unsubscribe"), - ProfileIcon("voicemail", Icons.Filled.Voicemail, "Voicemail"), - ProfileIcon("vpn_key", Icons.Filled.VpnKey, "VPN Key"), - ProfileIcon("vpn_key_off", Icons.Filled.VpnKeyOff, "VPN Key Off"), - ) -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/ContentIcons.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/ContentIcons.kt deleted file mode 100644 index 938ad510b8..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/ContentIcons.kt +++ /dev/null @@ -1,187 +0,0 @@ -package io.nekohasekai.sfa.compose.util.icons - -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.Backspace -import androidx.compose.material.icons.automirrored.filled.NextWeek -import androidx.compose.material.icons.automirrored.filled.Redo -import androidx.compose.material.icons.automirrored.filled.Send -import androidx.compose.material.icons.automirrored.filled.Sort -import androidx.compose.material.icons.automirrored.filled.Undo -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.AddBox -import androidx.compose.material.icons.filled.AddCircle -import androidx.compose.material.icons.filled.AddCircleOutline -import androidx.compose.material.icons.filled.AddLink -import androidx.compose.material.icons.filled.Archive -import androidx.compose.material.icons.filled.Ballot -import androidx.compose.material.icons.filled.Biotech -import androidx.compose.material.icons.filled.Block -import androidx.compose.material.icons.filled.Bolt -import androidx.compose.material.icons.filled.Calculate -import androidx.compose.material.icons.filled.ChangeCircle -import androidx.compose.material.icons.filled.Clear -import androidx.compose.material.icons.filled.ContentCopy -import androidx.compose.material.icons.filled.ContentCut -import androidx.compose.material.icons.filled.ContentPaste -import androidx.compose.material.icons.filled.ContentPasteGo -import androidx.compose.material.icons.filled.ContentPasteOff -import androidx.compose.material.icons.filled.ContentPasteSearch -import androidx.compose.material.icons.filled.CopyAll -import androidx.compose.material.icons.filled.Create -import androidx.compose.material.icons.filled.Deselect -import androidx.compose.material.icons.filled.Drafts -import androidx.compose.material.icons.filled.DynamicFeed -import androidx.compose.material.icons.filled.FileCopy -import androidx.compose.material.icons.filled.Filter1 -import androidx.compose.material.icons.filled.Filter2 -import androidx.compose.material.icons.filled.Filter3 -import androidx.compose.material.icons.filled.Filter4 -import androidx.compose.material.icons.filled.Filter5 -import androidx.compose.material.icons.filled.Filter6 -import androidx.compose.material.icons.filled.Filter7 -import androidx.compose.material.icons.filled.Filter8 -import androidx.compose.material.icons.filled.Filter9 -import androidx.compose.material.icons.filled.Filter9Plus -import androidx.compose.material.icons.filled.Flag -import androidx.compose.material.icons.filled.FlagCircle -import androidx.compose.material.icons.filled.FontDownload -import androidx.compose.material.icons.filled.FontDownloadOff -import androidx.compose.material.icons.filled.Forward -import androidx.compose.material.icons.filled.Gesture -import androidx.compose.material.icons.filled.HowToReg -import androidx.compose.material.icons.filled.HowToVote -import androidx.compose.material.icons.filled.Inbox -import androidx.compose.material.icons.filled.Insights -import androidx.compose.material.icons.filled.Inventory -import androidx.compose.material.icons.filled.Inventory2 -import androidx.compose.material.icons.filled.Link -import androidx.compose.material.icons.filled.LinkOff -import androidx.compose.material.icons.filled.LowPriority -import androidx.compose.material.icons.filled.Mail -import androidx.compose.material.icons.filled.Markunread -import androidx.compose.material.icons.filled.MoveToInbox -import androidx.compose.material.icons.filled.OutlinedFlag -import androidx.compose.material.icons.filled.Policy -import androidx.compose.material.icons.filled.PushPin -import androidx.compose.material.icons.filled.Remove -import androidx.compose.material.icons.filled.RemoveCircle -import androidx.compose.material.icons.filled.RemoveCircleOutline -import androidx.compose.material.icons.filled.Reply -import androidx.compose.material.icons.filled.ReplyAll -import androidx.compose.material.icons.filled.Report -import androidx.compose.material.icons.filled.ReportGmailerrorred -import androidx.compose.material.icons.filled.ReportOff -import androidx.compose.material.icons.filled.Save -import androidx.compose.material.icons.filled.SaveAlt -import androidx.compose.material.icons.filled.SaveAs -import androidx.compose.material.icons.filled.SelectAll -import androidx.compose.material.icons.filled.Shield -import androidx.compose.material.icons.filled.SquareFoot -import androidx.compose.material.icons.filled.StackedBarChart -import androidx.compose.material.icons.filled.Stream -import androidx.compose.material.icons.filled.Tag -import androidx.compose.material.icons.filled.TextFormat -import androidx.compose.material.icons.filled.Unarchive -import androidx.compose.material.icons.filled.Upcoming -import androidx.compose.material.icons.filled.Waves -import androidx.compose.material.icons.filled.WebStories -import androidx.compose.material.icons.filled.Weekend -import androidx.compose.material.icons.filled.WhereToVote -import io.nekohasekai.sfa.compose.util.ProfileIcon - -/** - * Content category icons - Content creation and management - * Based on Google's Material Design Icons taxonomy - */ -object ContentIcons { - val icons = - listOf( - ProfileIcon("add", Icons.Filled.Add, "Add"), - ProfileIcon("add_box", Icons.Filled.AddBox, "Add Box"), - ProfileIcon("add_circle", Icons.Filled.AddCircle, "Add Circle"), - ProfileIcon("add_circle_outline", Icons.Filled.AddCircleOutline, "Add Outline"), - ProfileIcon("add_link", Icons.Filled.AddLink, "Add Link"), - ProfileIcon("archive", Icons.Filled.Archive, "Archive"), - ProfileIcon("backspace", Icons.AutoMirrored.Filled.Backspace, "Backspace"), - ProfileIcon("ballot", Icons.Filled.Ballot, "Ballot"), - ProfileIcon("biotech", Icons.Filled.Biotech, "Biotech"), - ProfileIcon("block", Icons.Filled.Block, "Block"), - ProfileIcon("block_flipped", Icons.Filled.Block, "Block Flipped"), - ProfileIcon("bolt", Icons.Filled.Bolt, "Bolt"), - ProfileIcon("calculate", Icons.Filled.Calculate, "Calculate"), - ProfileIcon("change_circle", Icons.Filled.ChangeCircle, "Change Circle"), - ProfileIcon("clear", Icons.Filled.Clear, "Clear"), - ProfileIcon("content_copy", Icons.Filled.ContentCopy, "Copy"), - ProfileIcon("content_cut", Icons.Filled.ContentCut, "Cut"), - ProfileIcon("content_paste", Icons.Filled.ContentPaste, "Paste"), - ProfileIcon("content_paste_go", Icons.Filled.ContentPasteGo, "Paste Go"), - ProfileIcon("content_paste_off", Icons.Filled.ContentPasteOff, "Paste Off"), - ProfileIcon("content_paste_search", Icons.Filled.ContentPasteSearch, "Paste Search"), - ProfileIcon("copy_all", Icons.Filled.CopyAll, "Copy All"), - ProfileIcon("create", Icons.Filled.Create, "Create"), - ProfileIcon("deselect", Icons.Filled.Deselect, "Deselect"), - ProfileIcon("drafts", Icons.Filled.Drafts, "Drafts"), - ProfileIcon("dynamic_feed", Icons.Filled.DynamicFeed, "Dynamic Feed"), - ProfileIcon("file_copy", Icons.Filled.FileCopy, "File Copy"), - ProfileIcon("filter_1", Icons.Filled.Filter1, "Filter 1"), - ProfileIcon("filter_2", Icons.Filled.Filter2, "Filter 2"), - ProfileIcon("filter_3", Icons.Filled.Filter3, "Filter 3"), - ProfileIcon("filter_4", Icons.Filled.Filter4, "Filter 4"), - ProfileIcon("filter_5", Icons.Filled.Filter5, "Filter 5"), - ProfileIcon("filter_6", Icons.Filled.Filter6, "Filter 6"), - ProfileIcon("filter_7", Icons.Filled.Filter7, "Filter 7"), - ProfileIcon("filter_8", Icons.Filled.Filter8, "Filter 8"), - ProfileIcon("filter_9", Icons.Filled.Filter9, "Filter 9"), - ProfileIcon("filter_9_plus", Icons.Filled.Filter9Plus, "Filter 9+"), - ProfileIcon("flag", Icons.Filled.Flag, "Flag"), - ProfileIcon("flag_circle", Icons.Filled.FlagCircle, "Flag Circle"), - ProfileIcon("font_download", Icons.Filled.FontDownload, "Font Download"), - ProfileIcon("font_download_off", Icons.Filled.FontDownloadOff, "Font Download Off"), - ProfileIcon("forward", Icons.Filled.Forward, "Forward"), - ProfileIcon("gesture", Icons.Filled.Gesture, "Gesture"), - ProfileIcon("how_to_reg", Icons.Filled.HowToReg, "How to Register"), - ProfileIcon("how_to_vote", Icons.Filled.HowToVote, "How to Vote"), - ProfileIcon("inbox", Icons.Filled.Inbox, "Inbox"), - ProfileIcon("insights", Icons.Filled.Insights, "Insights"), - ProfileIcon("inventory", Icons.Filled.Inventory, "Inventory"), - ProfileIcon("inventory_2", Icons.Filled.Inventory2, "Inventory 2"), - ProfileIcon("link", Icons.Filled.Link, "Link"), - ProfileIcon("link_off", Icons.Filled.LinkOff, "Link Off"), - ProfileIcon("low_priority", Icons.Filled.LowPriority, "Low Priority"), - ProfileIcon("mail", Icons.Filled.Mail, "Mail"), - ProfileIcon("markunread", Icons.Filled.Markunread, "Mark Unread"), - ProfileIcon("move_to_inbox", Icons.Filled.MoveToInbox, "Move to Inbox"), - ProfileIcon("next_week", Icons.AutoMirrored.Filled.NextWeek, "Next Week"), - ProfileIcon("outlined_flag", Icons.Filled.OutlinedFlag, "Outlined Flag"), - ProfileIcon("policy", Icons.Filled.Policy, "Policy"), - ProfileIcon("push_pin", Icons.Filled.PushPin, "Push Pin"), - ProfileIcon("redo", Icons.AutoMirrored.Filled.Redo, "Redo"), - ProfileIcon("remove", Icons.Filled.Remove, "Remove"), - ProfileIcon("remove_circle", Icons.Filled.RemoveCircle, "Remove Circle"), - ProfileIcon("remove_circle_outline", Icons.Filled.RemoveCircleOutline, "Remove Outline"), - ProfileIcon("reply", Icons.Filled.Reply, "Reply"), - ProfileIcon("reply_all", Icons.Filled.ReplyAll, "Reply All"), - ProfileIcon("report", Icons.Filled.Report, "Report"), - ProfileIcon("report_gmailerrorred", Icons.Filled.ReportGmailerrorred, "Report Error"), - ProfileIcon("report_off", Icons.Filled.ReportOff, "Report Off"), - ProfileIcon("save", Icons.Filled.Save, "Save"), - ProfileIcon("save_alt", Icons.Filled.SaveAlt, "Save Alt"), - ProfileIcon("save_as", Icons.Filled.SaveAs, "Save As"), - ProfileIcon("select_all", Icons.Filled.SelectAll, "Select All"), - ProfileIcon("send", Icons.AutoMirrored.Filled.Send, "Send"), - ProfileIcon("shield", Icons.Filled.Shield, "Shield"), - ProfileIcon("sort", Icons.AutoMirrored.Filled.Sort, "Sort"), - ProfileIcon("square_foot", Icons.Filled.SquareFoot, "Square Foot"), - ProfileIcon("stacked_bar_chart", Icons.Filled.StackedBarChart, "Stacked Chart"), - ProfileIcon("stream", Icons.Filled.Stream, "Stream"), - ProfileIcon("tag", Icons.Filled.Tag, "Tag"), - ProfileIcon("text_format", Icons.Filled.TextFormat, "Text Format"), - ProfileIcon("unarchive", Icons.Filled.Unarchive, "Unarchive"), - ProfileIcon("undo", Icons.AutoMirrored.Filled.Undo, "Undo"), - ProfileIcon("upcoming", Icons.Filled.Upcoming, "Upcoming"), - ProfileIcon("waves", Icons.Filled.Waves, "Waves"), - ProfileIcon("web_stories", Icons.Filled.WebStories, "Web Stories"), - ProfileIcon("weekend", Icons.Filled.Weekend, "Weekend"), - ProfileIcon("where_to_vote", Icons.Filled.WhereToVote, "Where to Vote"), - ) -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/DeviceIcons.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/DeviceIcons.kt deleted file mode 100644 index 0ba4b4183f..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/DeviceIcons.kt +++ /dev/null @@ -1,469 +0,0 @@ -package io.nekohasekai.sfa.compose.util.icons - -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.AccessTime -import androidx.compose.material.icons.filled.AccessTimeFilled -import androidx.compose.material.icons.filled.AdUnits -import androidx.compose.material.icons.filled.AddAlarm -import androidx.compose.material.icons.filled.AddToHomeScreen -import androidx.compose.material.icons.filled.Air -import androidx.compose.material.icons.filled.AirplaneTicket -import androidx.compose.material.icons.filled.AirplanemodeActive -import androidx.compose.material.icons.filled.AirplanemodeInactive -import androidx.compose.material.icons.filled.Aod -import androidx.compose.material.icons.filled.Battery0Bar -import androidx.compose.material.icons.filled.Battery1Bar -import androidx.compose.material.icons.filled.Battery2Bar -import androidx.compose.material.icons.filled.Battery3Bar -import androidx.compose.material.icons.filled.Battery4Bar -import androidx.compose.material.icons.filled.Battery5Bar -import androidx.compose.material.icons.filled.Battery6Bar -import androidx.compose.material.icons.filled.BatteryAlert -import androidx.compose.material.icons.filled.BatteryChargingFull -import androidx.compose.material.icons.filled.BatteryFull -import androidx.compose.material.icons.filled.BatterySaver -import androidx.compose.material.icons.filled.BatteryStd -import androidx.compose.material.icons.filled.BatteryUnknown -import androidx.compose.material.icons.filled.Bloodtype -import androidx.compose.material.icons.filled.Bluetooth -import androidx.compose.material.icons.filled.BluetoothAudio -import androidx.compose.material.icons.filled.BluetoothConnected -import androidx.compose.material.icons.filled.BluetoothDisabled -import androidx.compose.material.icons.filled.BluetoothDrive -import androidx.compose.material.icons.filled.BluetoothSearching -import androidx.compose.material.icons.filled.BrightnessAuto -import androidx.compose.material.icons.filled.BrightnessHigh -import androidx.compose.material.icons.filled.BrightnessLow -import androidx.compose.material.icons.filled.BrightnessMedium -import androidx.compose.material.icons.filled.Cable -import androidx.compose.material.icons.filled.Cameraswitch -import androidx.compose.material.icons.filled.CreditScore -import androidx.compose.material.icons.filled.DarkMode -import androidx.compose.material.icons.filled.DataSaverOff -import androidx.compose.material.icons.filled.DataSaverOn -import androidx.compose.material.icons.filled.DataUsage -import androidx.compose.material.icons.filled.Dataset -import androidx.compose.material.icons.filled.DatasetLinked -import androidx.compose.material.icons.filled.DeveloperMode -import androidx.compose.material.icons.filled.DeviceThermostat -import androidx.compose.material.icons.filled.Devices -import androidx.compose.material.icons.filled.DevicesFold -import androidx.compose.material.icons.filled.DevicesOther -import androidx.compose.material.icons.filled.Discount -import androidx.compose.material.icons.filled.DoNotDisturbOnTotalSilence -import androidx.compose.material.icons.filled.Dvr -import androidx.compose.material.icons.filled.EMobiledata -import androidx.compose.material.icons.filled.EdgesensorHigh -import androidx.compose.material.icons.filled.EdgesensorLow -import androidx.compose.material.icons.filled.FlashlightOff -import androidx.compose.material.icons.filled.FlashlightOn -import androidx.compose.material.icons.filled.Flourescent -import androidx.compose.material.icons.filled.Fluorescent -import androidx.compose.material.icons.filled.FmdBad -import androidx.compose.material.icons.filled.FmdGood -import androidx.compose.material.icons.filled.GMobiledata -import androidx.compose.material.icons.filled.GppBad -import androidx.compose.material.icons.filled.GppGood -import androidx.compose.material.icons.filled.GppMaybe -import androidx.compose.material.icons.filled.GpsFixed -import androidx.compose.material.icons.filled.GpsNotFixed -import androidx.compose.material.icons.filled.GpsOff -import androidx.compose.material.icons.filled.GraphicEq -import androidx.compose.material.icons.filled.Grid3x3 -import androidx.compose.material.icons.filled.Grid4x4 -import androidx.compose.material.icons.filled.GridGoldenratio -import androidx.compose.material.icons.filled.HMobiledata -import androidx.compose.material.icons.filled.HPlusMobiledata -import androidx.compose.material.icons.filled.HdrAuto -import androidx.compose.material.icons.filled.HdrAutoSelect -import androidx.compose.material.icons.filled.HdrOffSelect -import androidx.compose.material.icons.filled.HdrOnSelect -import androidx.compose.material.icons.filled.Lan -import androidx.compose.material.icons.filled.LensBlur -import androidx.compose.material.icons.filled.LightMode -import androidx.compose.material.icons.filled.LocationDisabled -import androidx.compose.material.icons.filled.LocationSearching -import androidx.compose.material.icons.filled.LteMobiledata -import androidx.compose.material.icons.filled.LtePlusMobiledata -import androidx.compose.material.icons.filled.MediaBluetoothOff -import androidx.compose.material.icons.filled.MediaBluetoothOn -import androidx.compose.material.icons.filled.Medication -import androidx.compose.material.icons.filled.MobileFriendly -import androidx.compose.material.icons.filled.MobileOff -import androidx.compose.material.icons.filled.MobiledataOff -import androidx.compose.material.icons.filled.ModeNight -import androidx.compose.material.icons.filled.ModeStandby -import androidx.compose.material.icons.filled.MonitorHeart -import androidx.compose.material.icons.filled.MonitorWeight -import androidx.compose.material.icons.filled.NearbyError -import androidx.compose.material.icons.filled.NearbyOff -import androidx.compose.material.icons.filled.NetworkCell -import androidx.compose.material.icons.filled.NetworkWifi -import androidx.compose.material.icons.filled.NetworkWifi1Bar -import androidx.compose.material.icons.filled.NetworkWifi2Bar -import androidx.compose.material.icons.filled.NetworkWifi3Bar -import androidx.compose.material.icons.filled.Nfc -import androidx.compose.material.icons.filled.Nightlight -import androidx.compose.material.icons.filled.NoteAlt -import androidx.compose.material.icons.filled.Password -import androidx.compose.material.icons.filled.Pattern -import androidx.compose.material.icons.filled.Phishing -import androidx.compose.material.icons.filled.Pin -import androidx.compose.material.icons.filled.PlayLesson -import androidx.compose.material.icons.filled.PriceChange -import androidx.compose.material.icons.filled.PriceCheck -import androidx.compose.material.icons.filled.PunchClock -import androidx.compose.material.icons.filled.Quiz -import androidx.compose.material.icons.filled.RMobiledata -import androidx.compose.material.icons.filled.Radar -import androidx.compose.material.icons.filled.RememberMe -import androidx.compose.material.icons.filled.ResetTv -import androidx.compose.material.icons.filled.RestartAlt -import androidx.compose.material.icons.filled.Reviews -import androidx.compose.material.icons.filled.Rsvp -import androidx.compose.material.icons.filled.ScreenLockLandscape -import androidx.compose.material.icons.filled.ScreenLockPortrait -import androidx.compose.material.icons.filled.ScreenLockRotation -import androidx.compose.material.icons.filled.ScreenRotation -import androidx.compose.material.icons.filled.ScreenSearchDesktop -import androidx.compose.material.icons.filled.Screenshot -import androidx.compose.material.icons.filled.ScreenshotMonitor -import androidx.compose.material.icons.filled.SdStorage -import androidx.compose.material.icons.filled.SecurityUpdate -import androidx.compose.material.icons.filled.SecurityUpdateGood -import androidx.compose.material.icons.filled.SecurityUpdateWarning -import androidx.compose.material.icons.filled.Sell -import androidx.compose.material.icons.filled.SendToMobile -import androidx.compose.material.icons.filled.SettingsSuggest -import androidx.compose.material.icons.filled.SettingsSystemDaydream -import androidx.compose.material.icons.filled.ShareLocation -import androidx.compose.material.icons.filled.Shortcut -import androidx.compose.material.icons.filled.SignalCellular0Bar -import androidx.compose.material.icons.filled.SignalCellular4Bar -import androidx.compose.material.icons.filled.SignalCellularAlt -import androidx.compose.material.icons.filled.SignalCellularAlt1Bar -import androidx.compose.material.icons.filled.SignalCellularAlt2Bar -import androidx.compose.material.icons.filled.SignalCellularConnectedNoInternet0Bar -import androidx.compose.material.icons.filled.SignalCellularConnectedNoInternet4Bar -import androidx.compose.material.icons.filled.SignalCellularNoSim -import androidx.compose.material.icons.filled.SignalCellularNodata -import androidx.compose.material.icons.filled.SignalCellularNull -import androidx.compose.material.icons.filled.SignalCellularOff -import androidx.compose.material.icons.filled.SignalWifi0Bar -import androidx.compose.material.icons.filled.SignalWifi4Bar -import androidx.compose.material.icons.filled.SignalWifi4BarLock -import androidx.compose.material.icons.filled.SignalWifiBad -import androidx.compose.material.icons.filled.SignalWifiConnectedNoInternet4 -import androidx.compose.material.icons.filled.SignalWifiOff -import androidx.compose.material.icons.filled.SignalWifiStatusbar4Bar -import androidx.compose.material.icons.filled.SignalWifiStatusbarConnectedNoInternet4 -import androidx.compose.material.icons.filled.SignalWifiStatusbarNull -import androidx.compose.material.icons.filled.SimCard -import androidx.compose.material.icons.filled.SimCardAlert -import androidx.compose.material.icons.filled.SimCardDownload -import androidx.compose.material.icons.filled.SmartDisplay -import androidx.compose.material.icons.filled.SmartScreen -import androidx.compose.material.icons.filled.SmartToy -import androidx.compose.material.icons.filled.Splitscreen -import androidx.compose.material.icons.filled.SportsScore -import androidx.compose.material.icons.filled.SsidChart -import androidx.compose.material.icons.filled.Storage -import androidx.compose.material.icons.filled.Storm -import androidx.compose.material.icons.filled.Summarize -import androidx.compose.material.icons.filled.SystemSecurityUpdate -import androidx.compose.material.icons.filled.SystemSecurityUpdateGood -import androidx.compose.material.icons.filled.SystemSecurityUpdateWarning -import androidx.compose.material.icons.filled.Task -import androidx.compose.material.icons.filled.Thermostat -import androidx.compose.material.icons.filled.ThermostatAuto -import androidx.compose.material.icons.filled.Timer -import androidx.compose.material.icons.filled.Timer10 -import androidx.compose.material.icons.filled.Timer10Select -import androidx.compose.material.icons.filled.Timer3 -import androidx.compose.material.icons.filled.Timer3Select -import androidx.compose.material.icons.filled.TimerOff -import androidx.compose.material.icons.filled.Tungsten -import androidx.compose.material.icons.filled.Usb -import androidx.compose.material.icons.filled.UsbOff -import androidx.compose.material.icons.filled.Wallpaper -import androidx.compose.material.icons.filled.Water -import androidx.compose.material.icons.filled.Widgets -import androidx.compose.material.icons.filled.Wifi -import androidx.compose.material.icons.filled.Wifi1Bar -import androidx.compose.material.icons.filled.Wifi2Bar -import androidx.compose.material.icons.filled.WifiCalling -import androidx.compose.material.icons.filled.WifiCalling3 -import androidx.compose.material.icons.filled.WifiChannel -import androidx.compose.material.icons.filled.WifiFind -import androidx.compose.material.icons.filled.WifiLock -import androidx.compose.material.icons.filled.WifiOff -import androidx.compose.material.icons.filled.WifiPassword -import androidx.compose.material.icons.filled.WifiProtectedSetup -import androidx.compose.material.icons.filled.WifiTethering -import androidx.compose.material.icons.filled.WifiTetheringError -import androidx.compose.material.icons.filled.WifiTetheringOff -import io.nekohasekai.sfa.compose.util.ProfileIcon - -/** - * Device category icons - Device-specific icons and features - * Based on Google's Material Design Icons taxonomy - */ -object DeviceIcons { - val icons = - listOf( - ProfileIcon("access_time", Icons.Filled.AccessTime, "Access Time"), - ProfileIcon("access_time_filled", Icons.Filled.AccessTimeFilled, "Time Filled"), - ProfileIcon("ad_units", Icons.Filled.AdUnits, "Ad Units"), - ProfileIcon("add_alarm", Icons.Filled.AddAlarm, "Add Alarm"), - ProfileIcon("add_to_home_screen", Icons.Filled.AddToHomeScreen, "Add to Home"), - ProfileIcon("air", Icons.Filled.Air, "Air"), - ProfileIcon("airplane_ticket", Icons.Filled.AirplaneTicket, "Airplane Ticket"), - ProfileIcon("airplanemode_active", Icons.Filled.AirplanemodeActive, "Airplane Active"), - ProfileIcon( - "airplanemode_inactive", - Icons.Filled.AirplanemodeInactive, - "Airplane Inactive", - ), - ProfileIcon("aod", Icons.Filled.Aod, "Always On Display"), - ProfileIcon("battery_0_bar", Icons.Filled.Battery0Bar, "Battery 0"), - ProfileIcon("battery_1_bar", Icons.Filled.Battery1Bar, "Battery 1"), - ProfileIcon("battery_2_bar", Icons.Filled.Battery2Bar, "Battery 2"), - ProfileIcon("battery_3_bar", Icons.Filled.Battery3Bar, "Battery 3"), - ProfileIcon("battery_4_bar", Icons.Filled.Battery4Bar, "Battery 4"), - ProfileIcon("battery_5_bar", Icons.Filled.Battery5Bar, "Battery 5"), - ProfileIcon("battery_6_bar", Icons.Filled.Battery6Bar, "Battery 6"), - ProfileIcon("battery_alert", Icons.Filled.BatteryAlert, "Battery Alert"), - ProfileIcon("battery_charging_full", Icons.Filled.BatteryChargingFull, "Charging Full"), - ProfileIcon("battery_full", Icons.Filled.BatteryFull, "Battery Full"), - ProfileIcon("battery_saver", Icons.Filled.BatterySaver, "Battery Saver"), - ProfileIcon("battery_std", Icons.Filled.BatteryStd, "Battery Standard"), - ProfileIcon("battery_unknown", Icons.Filled.BatteryUnknown, "Battery Unknown"), - ProfileIcon("bloodtype", Icons.Filled.Bloodtype, "Blood Type"), - ProfileIcon("bluetooth", Icons.Filled.Bluetooth, "Bluetooth"), - ProfileIcon("bluetooth_audio", Icons.Filled.BluetoothAudio, "Bluetooth Audio"), - ProfileIcon("bluetooth_connected", Icons.Filled.BluetoothConnected, "Bluetooth Connected"), - ProfileIcon("bluetooth_disabled", Icons.Filled.BluetoothDisabled, "Bluetooth Disabled"), - ProfileIcon("bluetooth_drive", Icons.Filled.BluetoothDrive, "Bluetooth Drive"), - ProfileIcon("bluetooth_searching", Icons.Filled.BluetoothSearching, "Bluetooth Searching"), - ProfileIcon("brightness_auto", Icons.Filled.BrightnessAuto, "Brightness Auto"), - ProfileIcon("brightness_high", Icons.Filled.BrightnessHigh, "Brightness High"), - ProfileIcon("brightness_low", Icons.Filled.BrightnessLow, "Brightness Low"), - ProfileIcon("brightness_medium", Icons.Filled.BrightnessMedium, "Brightness Medium"), - ProfileIcon("cable", Icons.Filled.Cable, "Cable"), - ProfileIcon("cameraswitch", Icons.Filled.Cameraswitch, "Camera Switch"), - ProfileIcon("credit_score", Icons.Filled.CreditScore, "Credit Score"), - ProfileIcon("dark_mode", Icons.Filled.DarkMode, "Dark Mode"), - ProfileIcon("data_saver_off", Icons.Filled.DataSaverOff, "Data Saver Off"), - ProfileIcon("data_saver_on", Icons.Filled.DataSaverOn, "Data Saver On"), - ProfileIcon("data_usage", Icons.Filled.DataUsage, "Data Usage"), - ProfileIcon("dataset", Icons.Filled.Dataset, "Dataset"), - ProfileIcon("dataset_linked", Icons.Filled.DatasetLinked, "Dataset Linked"), - ProfileIcon("developer_mode", Icons.Filled.DeveloperMode, "Developer Mode"), - ProfileIcon("device_thermostat", Icons.Filled.DeviceThermostat, "Thermostat"), - ProfileIcon("devices", Icons.Filled.Devices, "Devices"), - ProfileIcon("devices_fold", Icons.Filled.DevicesFold, "Devices Fold"), - ProfileIcon("devices_other", Icons.Filled.DevicesOther, "Devices Other"), - ProfileIcon("discount", Icons.Filled.Discount, "Discount"), - ProfileIcon( - "do_not_disturb_on_total_silence", - Icons.Filled.DoNotDisturbOnTotalSilence, - "DND Total", - ), - ProfileIcon("dvr", Icons.Filled.Dvr, "DVR"), - ProfileIcon("e_mobiledata", Icons.Filled.EMobiledata, "E Mobile Data"), - ProfileIcon("edgesensor_high", Icons.Filled.EdgesensorHigh, "Edge Sensor High"), - ProfileIcon("edgesensor_low", Icons.Filled.EdgesensorLow, "Edge Sensor Low"), - ProfileIcon("flashlight_off", Icons.Filled.FlashlightOff, "Flashlight Off"), - ProfileIcon("flashlight_on", Icons.Filled.FlashlightOn, "Flashlight On"), - ProfileIcon("flourescent", Icons.Filled.Flourescent, "Flourescent"), - ProfileIcon("fluorescent", Icons.Filled.Fluorescent, "Fluorescent"), - ProfileIcon("fmd_bad", Icons.Filled.FmdBad, "Find My Device Bad"), - ProfileIcon("fmd_good", Icons.Filled.FmdGood, "Find My Device Good"), - ProfileIcon("g_mobiledata", Icons.Filled.GMobiledata, "G Mobile Data"), - ProfileIcon("gpp_bad", Icons.Filled.GppBad, "GPP Bad"), - ProfileIcon("gpp_good", Icons.Filled.GppGood, "GPP Good"), - ProfileIcon("gpp_maybe", Icons.Filled.GppMaybe, "GPP Maybe"), - ProfileIcon("gps_fixed", Icons.Filled.GpsFixed, "GPS Fixed"), - ProfileIcon("gps_not_fixed", Icons.Filled.GpsNotFixed, "GPS Not Fixed"), - ProfileIcon("gps_off", Icons.Filled.GpsOff, "GPS Off"), - ProfileIcon("graphic_eq", Icons.Filled.GraphicEq, "Graphic EQ"), - ProfileIcon("grid_3x3", Icons.Filled.Grid3x3, "Grid 3x3"), - ProfileIcon("grid_4x4", Icons.Filled.Grid4x4, "Grid 4x4"), - ProfileIcon("grid_goldenratio", Icons.Filled.GridGoldenratio, "Grid Golden Ratio"), - ProfileIcon("h_mobiledata", Icons.Filled.HMobiledata, "H Mobile Data"), - ProfileIcon("h_plus_mobiledata", Icons.Filled.HPlusMobiledata, "H+ Mobile Data"), - ProfileIcon("hdr_auto", Icons.Filled.HdrAuto, "HDR Auto"), - ProfileIcon("hdr_auto_select", Icons.Filled.HdrAutoSelect, "HDR Auto Select"), - ProfileIcon("hdr_off_select", Icons.Filled.HdrOffSelect, "HDR Off Select"), - ProfileIcon("hdr_on_select", Icons.Filled.HdrOnSelect, "HDR On Select"), - ProfileIcon("lan", Icons.Filled.Lan, "LAN"), - ProfileIcon("lens_blur", Icons.Filled.LensBlur, "Lens Blur"), - ProfileIcon("light_mode", Icons.Filled.LightMode, "Light Mode"), - ProfileIcon("location_disabled", Icons.Filled.LocationDisabled, "Location Disabled"), - ProfileIcon("location_searching", Icons.Filled.LocationSearching, "Location Searching"), - ProfileIcon("lte_mobiledata", Icons.Filled.LteMobiledata, "LTE"), - ProfileIcon("lte_plus_mobiledata", Icons.Filled.LtePlusMobiledata, "LTE+"), - ProfileIcon("media_bluetooth_off", Icons.Filled.MediaBluetoothOff, "Media Bluetooth Off"), - ProfileIcon("media_bluetooth_on", Icons.Filled.MediaBluetoothOn, "Media Bluetooth On"), - ProfileIcon("medication", Icons.Filled.Medication, "Medication"), - // ProfileIcon("medication_liquid", Icons.Filled.MedicationLiquid, "Medication Liquid"), - ProfileIcon("mobile_friendly", Icons.Filled.MobileFriendly, "Mobile Friendly"), - ProfileIcon("mobile_off", Icons.Filled.MobileOff, "Mobile Off"), - ProfileIcon("mobiledata_off", Icons.Filled.MobiledataOff, "Mobile Data Off"), - ProfileIcon("mode_night", Icons.Filled.ModeNight, "Night Mode"), - ProfileIcon("mode_standby", Icons.Filled.ModeStandby, "Standby Mode"), - ProfileIcon("monitor_heart", Icons.Filled.MonitorHeart, "Monitor Heart"), - ProfileIcon("monitor_weight", Icons.Filled.MonitorWeight, "Monitor Weight"), - ProfileIcon("nearby_error", Icons.Filled.NearbyError, "Nearby Error"), - ProfileIcon("nearby_off", Icons.Filled.NearbyOff, "Nearby Off"), - ProfileIcon("network_cell", Icons.Filled.NetworkCell, "Network Cell"), - ProfileIcon("network_wifi", Icons.Filled.NetworkWifi, "Network WiFi"), - ProfileIcon("network_wifi_1_bar", Icons.Filled.NetworkWifi1Bar, "WiFi 1 Bar"), - ProfileIcon("network_wifi_2_bar", Icons.Filled.NetworkWifi2Bar, "WiFi 2 Bar"), - ProfileIcon("network_wifi_3_bar", Icons.Filled.NetworkWifi3Bar, "WiFi 3 Bar"), - ProfileIcon("nfc", Icons.Filled.Nfc, "NFC"), - ProfileIcon("nightlight", Icons.Filled.Nightlight, "Nightlight"), - ProfileIcon("note_alt", Icons.Filled.NoteAlt, "Note Alt"), - ProfileIcon("password", Icons.Filled.Password, "Password"), - ProfileIcon("pattern", Icons.Filled.Pattern, "Pattern"), - ProfileIcon("phishing", Icons.Filled.Phishing, "Phishing"), - ProfileIcon("pin", Icons.Filled.Pin, "PIN"), - ProfileIcon("play_lesson", Icons.Filled.PlayLesson, "Play Lesson"), - ProfileIcon("price_change", Icons.Filled.PriceChange, "Price Change"), - ProfileIcon("price_check", Icons.Filled.PriceCheck, "Price Check"), - ProfileIcon("punch_clock", Icons.Filled.PunchClock, "Punch Clock"), - ProfileIcon("quiz", Icons.Filled.Quiz, "Quiz"), - ProfileIcon("r_mobiledata", Icons.Filled.RMobiledata, "R Mobile Data"), - ProfileIcon("radar", Icons.Filled.Radar, "Radar"), - ProfileIcon("remember_me", Icons.Filled.RememberMe, "Remember Me"), - ProfileIcon("reset_tv", Icons.Filled.ResetTv, "Reset TV"), - ProfileIcon("restart_alt", Icons.Filled.RestartAlt, "Restart"), - ProfileIcon("reviews", Icons.Filled.Reviews, "Reviews"), - ProfileIcon("rsvp", Icons.Filled.Rsvp, "RSVP"), - ProfileIcon("screen_lock_landscape", Icons.Filled.ScreenLockLandscape, "Lock Landscape"), - ProfileIcon("screen_lock_portrait", Icons.Filled.ScreenLockPortrait, "Lock Portrait"), - ProfileIcon("screen_lock_rotation", Icons.Filled.ScreenLockRotation, "Lock Rotation"), - ProfileIcon("screen_rotation", Icons.Filled.ScreenRotation, "Screen Rotation"), - ProfileIcon("screen_search_desktop", Icons.Filled.ScreenSearchDesktop, "Screen Search"), - ProfileIcon("screenshot", Icons.Filled.Screenshot, "Screenshot"), - ProfileIcon("screenshot_monitor", Icons.Filled.ScreenshotMonitor, "Screenshot Monitor"), - ProfileIcon("sd_storage", Icons.Filled.SdStorage, "SD Storage"), - ProfileIcon("security_update", Icons.Filled.SecurityUpdate, "Security Update"), - ProfileIcon("security_update_good", Icons.Filled.SecurityUpdateGood, "Security Good"), - ProfileIcon( - "security_update_warning", - Icons.Filled.SecurityUpdateWarning, - "Security Warning", - ), - ProfileIcon("sell", Icons.Filled.Sell, "Sell"), - ProfileIcon("send_to_mobile", Icons.Filled.SendToMobile, "Send to Mobile"), - ProfileIcon("settings_suggest", Icons.Filled.SettingsSuggest, "Settings Suggest"), - ProfileIcon("settings_system_daydream", Icons.Filled.SettingsSystemDaydream, "Daydream"), - ProfileIcon("share_location", Icons.Filled.ShareLocation, "Share Location"), - ProfileIcon("shortcut", Icons.Filled.Shortcut, "Shortcut"), - ProfileIcon("signal_cellular_0_bar", Icons.Filled.SignalCellular0Bar, "Signal 0"), - ProfileIcon("signal_cellular_4_bar", Icons.Filled.SignalCellular4Bar, "Signal 4"), - ProfileIcon("signal_cellular_alt", Icons.Filled.SignalCellularAlt, "Signal Alt"), - ProfileIcon( - "signal_cellular_alt_1_bar", - Icons.Filled.SignalCellularAlt1Bar, - "Signal Alt 1", - ), - ProfileIcon( - "signal_cellular_alt_2_bar", - Icons.Filled.SignalCellularAlt2Bar, - "Signal Alt 2", - ), - ProfileIcon( - "signal_cellular_connected_no_internet_0_bar", - Icons.Filled.SignalCellularConnectedNoInternet0Bar, - "No Internet", - ), - ProfileIcon( - "signal_cellular_connected_no_internet_4_bar", - Icons.Filled.SignalCellularConnectedNoInternet4Bar, - "No Internet 4", - ), - ProfileIcon("signal_cellular_no_sim", Icons.Filled.SignalCellularNoSim, "No SIM"), - ProfileIcon("signal_cellular_nodata", Icons.Filled.SignalCellularNodata, "No Data"), - ProfileIcon("signal_cellular_null", Icons.Filled.SignalCellularNull, "Signal Null"), - ProfileIcon("signal_cellular_off", Icons.Filled.SignalCellularOff, "Signal Off"), - ProfileIcon("signal_wifi_0_bar", Icons.Filled.SignalWifi0Bar, "WiFi 0"), - ProfileIcon("signal_wifi_4_bar", Icons.Filled.SignalWifi4Bar, "WiFi 4"), - ProfileIcon("signal_wifi_4_bar_lock", Icons.Filled.SignalWifi4BarLock, "WiFi Lock"), - ProfileIcon("signal_wifi_bad", Icons.Filled.SignalWifiBad, "WiFi Bad"), - ProfileIcon( - "signal_wifi_connected_no_internet_4", - Icons.Filled.SignalWifiConnectedNoInternet4, - "WiFi No Internet", - ), - ProfileIcon("signal_wifi_off", Icons.Filled.SignalWifiOff, "WiFi Off"), - ProfileIcon( - "signal_wifi_statusbar_4_bar", - Icons.Filled.SignalWifiStatusbar4Bar, - "WiFi Status 4", - ), - ProfileIcon( - "signal_wifi_statusbar_connected_no_internet_4", - Icons.Filled.SignalWifiStatusbarConnectedNoInternet4, - "WiFi Status No Internet", - ), - ProfileIcon( - "signal_wifi_statusbar_null", - Icons.Filled.SignalWifiStatusbarNull, - "WiFi Status Null", - ), - ProfileIcon("sim_card", Icons.Filled.SimCard, "SIM Card"), - ProfileIcon("sim_card_alert", Icons.Filled.SimCardAlert, "SIM Alert"), - ProfileIcon("sim_card_download", Icons.Filled.SimCardDownload, "SIM Download"), - ProfileIcon("smart_display", Icons.Filled.SmartDisplay, "Smart Display"), - ProfileIcon("smart_screen", Icons.Filled.SmartScreen, "Smart Screen"), - ProfileIcon("smart_toy", Icons.Filled.SmartToy, "Smart Toy"), - ProfileIcon("splitscreen", Icons.Filled.Splitscreen, "Split Screen"), - ProfileIcon("sports_score", Icons.Filled.SportsScore, "Sports Score"), - ProfileIcon("ssid_chart", Icons.Filled.SsidChart, "SSID Chart"), - ProfileIcon("storage", Icons.Filled.Storage, "Storage"), - ProfileIcon("storm", Icons.Filled.Storm, "Storm"), - ProfileIcon("summarize", Icons.Filled.Summarize, "Summarize"), - ProfileIcon("system_security_update", Icons.Filled.SystemSecurityUpdate, "System Security"), - ProfileIcon( - "system_security_update_good", - Icons.Filled.SystemSecurityUpdateGood, - "System Security Good", - ), - ProfileIcon( - "system_security_update_warning", - Icons.Filled.SystemSecurityUpdateWarning, - "System Warning", - ), - ProfileIcon("task", Icons.Filled.Task, "Task"), - ProfileIcon("thermostat", Icons.Filled.Thermostat, "Thermostat"), - ProfileIcon("thermostat_auto", Icons.Filled.ThermostatAuto, "Thermostat Auto"), - ProfileIcon("timer", Icons.Filled.Timer, "Timer"), - ProfileIcon("timer_10", Icons.Filled.Timer10, "Timer 10"), - ProfileIcon("timer_10_select", Icons.Filled.Timer10Select, "Timer 10 Select"), - ProfileIcon("timer_3", Icons.Filled.Timer3, "Timer 3"), - ProfileIcon("timer_3_select", Icons.Filled.Timer3Select, "Timer 3 Select"), - ProfileIcon("timer_off", Icons.Filled.TimerOff, "Timer Off"), - ProfileIcon("tungsten", Icons.Filled.Tungsten, "Tungsten"), - ProfileIcon("usb", Icons.Filled.Usb, "USB"), - ProfileIcon("usb_off", Icons.Filled.UsbOff, "USB Off"), - ProfileIcon("wallpaper", Icons.Filled.Wallpaper, "Wallpaper"), - ProfileIcon("water", Icons.Filled.Water, "Water"), - ProfileIcon("widgets", Icons.Filled.Widgets, "Widgets"), - ProfileIcon("wifi", Icons.Filled.Wifi, "WiFi"), - ProfileIcon("wifi_1_bar", Icons.Filled.Wifi1Bar, "WiFi 1 Bar"), - ProfileIcon("wifi_2_bar", Icons.Filled.Wifi2Bar, "WiFi 2 Bar"), - ProfileIcon("wifi_calling", Icons.Filled.WifiCalling, "WiFi Calling"), - ProfileIcon("wifi_calling_3", Icons.Filled.WifiCalling3, "WiFi Calling 3"), - ProfileIcon("wifi_channel", Icons.Filled.WifiChannel, "WiFi Channel"), - ProfileIcon("wifi_find", Icons.Filled.WifiFind, "WiFi Find"), - ProfileIcon("wifi_lock", Icons.Filled.WifiLock, "WiFi Lock"), - ProfileIcon("wifi_off", Icons.Filled.WifiOff, "WiFi Off"), - ProfileIcon("wifi_password", Icons.Filled.WifiPassword, "WiFi Password"), - ProfileIcon("wifi_protected_setup", Icons.Filled.WifiProtectedSetup, "WiFi Setup"), - ProfileIcon("wifi_tethering", Icons.Filled.WifiTethering, "WiFi Tethering"), - ProfileIcon("wifi_tethering_error", Icons.Filled.WifiTetheringError, "WiFi Error"), - ProfileIcon("wifi_tethering_off", Icons.Filled.WifiTetheringOff, "WiFi Tethering Off"), - ) -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/EditorIcons.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/EditorIcons.kt deleted file mode 100644 index 0634d4dfa5..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/EditorIcons.kt +++ /dev/null @@ -1,272 +0,0 @@ -package io.nekohasekai.sfa.compose.util.icons - -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.FormatListBulleted -import androidx.compose.material.icons.automirrored.filled.InsertDriveFile -import androidx.compose.material.icons.automirrored.filled.Notes -import androidx.compose.material.icons.automirrored.filled.ShortText -import androidx.compose.material.icons.automirrored.filled.ShowChart -import androidx.compose.material.icons.automirrored.filled.WrapText -import androidx.compose.material.icons.filled.AddChart -import androidx.compose.material.icons.filled.AddComment -import androidx.compose.material.icons.filled.AlignHorizontalCenter -import androidx.compose.material.icons.filled.AlignHorizontalLeft -import androidx.compose.material.icons.filled.AlignHorizontalRight -import androidx.compose.material.icons.filled.AlignVerticalBottom -import androidx.compose.material.icons.filled.AlignVerticalCenter -import androidx.compose.material.icons.filled.AlignVerticalTop -import androidx.compose.material.icons.filled.AreaChart -import androidx.compose.material.icons.filled.AttachEmail -import androidx.compose.material.icons.filled.AttachFile -import androidx.compose.material.icons.filled.AttachMoney -import androidx.compose.material.icons.filled.AutoGraph -import androidx.compose.material.icons.filled.BarChart -import androidx.compose.material.icons.filled.BorderAll -import androidx.compose.material.icons.filled.BorderBottom -import androidx.compose.material.icons.filled.BorderClear -import androidx.compose.material.icons.filled.BorderColor -import androidx.compose.material.icons.filled.BorderHorizontal -import androidx.compose.material.icons.filled.BorderInner -import androidx.compose.material.icons.filled.BorderLeft -import androidx.compose.material.icons.filled.BorderOuter -import androidx.compose.material.icons.filled.BorderRight -import androidx.compose.material.icons.filled.BorderStyle -import androidx.compose.material.icons.filled.BorderTop -import androidx.compose.material.icons.filled.BorderVertical -import androidx.compose.material.icons.filled.BubbleChart -import androidx.compose.material.icons.filled.CandlestickChart -import androidx.compose.material.icons.filled.Checklist -import androidx.compose.material.icons.filled.ChecklistRtl -import androidx.compose.material.icons.filled.DataArray -import androidx.compose.material.icons.filled.DataObject -import androidx.compose.material.icons.filled.DragHandle -import androidx.compose.material.icons.filled.Draw -import androidx.compose.material.icons.filled.EditNote -import androidx.compose.material.icons.filled.FormatAlignCenter -import androidx.compose.material.icons.filled.FormatAlignJustify -import androidx.compose.material.icons.filled.FormatAlignLeft -import androidx.compose.material.icons.filled.FormatAlignRight -import androidx.compose.material.icons.filled.FormatBold -import androidx.compose.material.icons.filled.FormatClear -import androidx.compose.material.icons.filled.FormatColorFill -import androidx.compose.material.icons.filled.FormatColorReset -import androidx.compose.material.icons.filled.FormatColorText -import androidx.compose.material.icons.filled.FormatIndentDecrease -import androidx.compose.material.icons.filled.FormatIndentIncrease -import androidx.compose.material.icons.filled.FormatItalic -import androidx.compose.material.icons.filled.FormatLineSpacing -import androidx.compose.material.icons.filled.FormatListNumbered -import androidx.compose.material.icons.filled.FormatListNumberedRtl -import androidx.compose.material.icons.filled.FormatPaint -import androidx.compose.material.icons.filled.FormatQuote -import androidx.compose.material.icons.filled.FormatShapes -import androidx.compose.material.icons.filled.FormatSize -import androidx.compose.material.icons.filled.FormatStrikethrough -import androidx.compose.material.icons.filled.FormatTextdirectionLToR -import androidx.compose.material.icons.filled.FormatTextdirectionRToL -import androidx.compose.material.icons.filled.FormatUnderlined -import androidx.compose.material.icons.filled.Functions -import androidx.compose.material.icons.filled.Height -import androidx.compose.material.icons.filled.Hexagon -import androidx.compose.material.icons.filled.Highlight -import androidx.compose.material.icons.filled.HorizontalDistribute -import androidx.compose.material.icons.filled.HorizontalRule -import androidx.compose.material.icons.filled.InsertChart -import androidx.compose.material.icons.filled.InsertChartOutlined -import androidx.compose.material.icons.filled.InsertComment -import androidx.compose.material.icons.filled.InsertEmoticon -import androidx.compose.material.icons.filled.InsertInvitation -import androidx.compose.material.icons.filled.InsertLink -import androidx.compose.material.icons.filled.InsertPageBreak -import androidx.compose.material.icons.filled.InsertPhoto -import androidx.compose.material.icons.filled.LineAxis -import androidx.compose.material.icons.filled.LineWeight -import androidx.compose.material.icons.filled.LinearScale -import androidx.compose.material.icons.filled.Margin -import androidx.compose.material.icons.filled.MergeType -import androidx.compose.material.icons.filled.Mode -import androidx.compose.material.icons.filled.ModeComment -import androidx.compose.material.icons.filled.ModeEdit -import androidx.compose.material.icons.filled.ModeEditOutline -import androidx.compose.material.icons.filled.MonetizationOn -import androidx.compose.material.icons.filled.MoneyOff -import androidx.compose.material.icons.filled.MoneyOffCsred -import androidx.compose.material.icons.filled.MoveDown -import androidx.compose.material.icons.filled.MoveUp -import androidx.compose.material.icons.filled.MultilineChart -import androidx.compose.material.icons.filled.Numbers -import androidx.compose.material.icons.filled.Padding -import androidx.compose.material.icons.filled.Pentagon -import androidx.compose.material.icons.filled.PieChart -import androidx.compose.material.icons.filled.PieChartOutline -import androidx.compose.material.icons.filled.Polyline -import androidx.compose.material.icons.filled.PostAdd -import androidx.compose.material.icons.filled.Publish -import androidx.compose.material.icons.filled.QueryStats -import androidx.compose.material.icons.filled.Rectangle -import androidx.compose.material.icons.filled.ScatterPlot -import androidx.compose.material.icons.filled.Schema -import androidx.compose.material.icons.filled.Score -import androidx.compose.material.icons.filled.SpaceBar -import androidx.compose.material.icons.filled.Square -import androidx.compose.material.icons.filled.StackedLineChart -import androidx.compose.material.icons.filled.StrikethroughS -import androidx.compose.material.icons.filled.Subscript -import androidx.compose.material.icons.filled.Superscript -import androidx.compose.material.icons.filled.TableChart -import androidx.compose.material.icons.filled.TableRows -import androidx.compose.material.icons.filled.TextDecrease -import androidx.compose.material.icons.filled.TextFields -import androidx.compose.material.icons.filled.TextIncrease -import androidx.compose.material.icons.filled.Title -import androidx.compose.material.icons.filled.VerticalAlignBottom -import androidx.compose.material.icons.filled.VerticalAlignCenter -import androidx.compose.material.icons.filled.VerticalAlignTop -import androidx.compose.material.icons.filled.VerticalDistribute -import androidx.compose.material.icons.filled.WaterfallChart -import io.nekohasekai.sfa.compose.util.ProfileIcon - -/** - * Editor category icons - Text and content editing - * Based on Google's Material Design Icons taxonomy - */ -object EditorIcons { - val icons = - listOf( - ProfileIcon("add_chart", Icons.Filled.AddChart, "Add Chart"), - ProfileIcon("add_comment", Icons.Filled.AddComment, "Add Comment"), - ProfileIcon("align_horizontal_center", Icons.Filled.AlignHorizontalCenter, "Align Center"), - ProfileIcon("align_horizontal_left", Icons.Filled.AlignHorizontalLeft, "Align Left"), - ProfileIcon("align_horizontal_right", Icons.Filled.AlignHorizontalRight, "Align Right"), - ProfileIcon("align_vertical_bottom", Icons.Filled.AlignVerticalBottom, "Align Bottom"), - ProfileIcon("align_vertical_center", Icons.Filled.AlignVerticalCenter, "Align Middle"), - ProfileIcon("align_vertical_top", Icons.Filled.AlignVerticalTop, "Align Top"), - ProfileIcon("area_chart", Icons.Filled.AreaChart, "Area Chart"), - ProfileIcon("attach_email", Icons.Filled.AttachEmail, "Attach Email"), - ProfileIcon("attach_file", Icons.Filled.AttachFile, "Attach File"), - ProfileIcon("attach_money", Icons.Filled.AttachMoney, "Attach Money"), - ProfileIcon("auto_graph", Icons.Filled.AutoGraph, "Auto Graph"), - ProfileIcon("bar_chart", Icons.Filled.BarChart, "Bar Chart"), - ProfileIcon("border_all", Icons.Filled.BorderAll, "Border All"), - ProfileIcon("border_bottom", Icons.Filled.BorderBottom, "Border Bottom"), - ProfileIcon("border_clear", Icons.Filled.BorderClear, "Border Clear"), - ProfileIcon("border_color", Icons.Filled.BorderColor, "Border Color"), - ProfileIcon("border_horizontal", Icons.Filled.BorderHorizontal, "Border Horizontal"), - ProfileIcon("border_inner", Icons.Filled.BorderInner, "Border Inner"), - ProfileIcon("border_left", Icons.Filled.BorderLeft, "Border Left"), - ProfileIcon("border_outer", Icons.Filled.BorderOuter, "Border Outer"), - ProfileIcon("border_right", Icons.Filled.BorderRight, "Border Right"), - ProfileIcon("border_style", Icons.Filled.BorderStyle, "Border Style"), - ProfileIcon("border_top", Icons.Filled.BorderTop, "Border Top"), - ProfileIcon("border_vertical", Icons.Filled.BorderVertical, "Border Vertical"), - ProfileIcon("bubble_chart", Icons.Filled.BubbleChart, "Bubble Chart"), - ProfileIcon("candlestick_chart", Icons.Filled.CandlestickChart, "Candlestick Chart"), - ProfileIcon("checklist", Icons.Filled.Checklist, "Checklist"), - ProfileIcon("checklist_rtl", Icons.Filled.ChecklistRtl, "Checklist RTL"), - ProfileIcon("data_array", Icons.Filled.DataArray, "Data Array"), - ProfileIcon("data_object", Icons.Filled.DataObject, "Data Object"), - ProfileIcon("drag_handle", Icons.Filled.DragHandle, "Drag Handle"), - ProfileIcon("draw", Icons.Filled.Draw, "Draw"), - ProfileIcon("edit_note", Icons.Filled.EditNote, "Edit Note"), - ProfileIcon("format_align_center", Icons.Filled.FormatAlignCenter, "Format Center"), - ProfileIcon("format_align_justify", Icons.Filled.FormatAlignJustify, "Format Justify"), - ProfileIcon("format_align_left", Icons.Filled.FormatAlignLeft, "Format Left"), - ProfileIcon("format_align_right", Icons.Filled.FormatAlignRight, "Format Right"), - ProfileIcon("format_bold", Icons.Filled.FormatBold, "Bold"), - ProfileIcon("format_clear", Icons.Filled.FormatClear, "Format Clear"), - ProfileIcon("format_color_fill", Icons.Filled.FormatColorFill, "Color Fill"), - ProfileIcon("format_color_reset", Icons.Filled.FormatColorReset, "Color Reset"), - ProfileIcon("format_color_text", Icons.Filled.FormatColorText, "Color Text"), - ProfileIcon("format_indent_decrease", Icons.Filled.FormatIndentDecrease, "Indent Less"), - ProfileIcon("format_indent_increase", Icons.Filled.FormatIndentIncrease, "Indent More"), - ProfileIcon("format_italic", Icons.Filled.FormatItalic, "Italic"), - ProfileIcon("format_line_spacing", Icons.Filled.FormatLineSpacing, "Line Spacing"), - ProfileIcon( - "format_list_bulleted", - Icons.AutoMirrored.Filled.FormatListBulleted, - "Bulleted List", - ), - ProfileIcon("format_list_numbered", Icons.Filled.FormatListNumbered, "Numbered List"), - ProfileIcon("format_list_numbered_rtl", Icons.Filled.FormatListNumberedRtl, "List RTL"), - ProfileIcon("format_paint", Icons.Filled.FormatPaint, "Format Paint"), - ProfileIcon("format_quote", Icons.Filled.FormatQuote, "Quote"), - ProfileIcon("format_shapes", Icons.Filled.FormatShapes, "Format Shapes"), - ProfileIcon("format_size", Icons.Filled.FormatSize, "Format Size"), - ProfileIcon("format_strikethrough", Icons.Filled.FormatStrikethrough, "Strikethrough"), - ProfileIcon("format_text_direction_l_to_r", Icons.Filled.FormatTextdirectionLToR, "LTR"), - ProfileIcon("format_text_direction_r_to_l", Icons.Filled.FormatTextdirectionRToL, "RTL"), - ProfileIcon("format_underlined", Icons.Filled.FormatUnderlined, "Underlined"), - ProfileIcon("functions", Icons.Filled.Functions, "Functions"), - ProfileIcon("height", Icons.Filled.Height, "Height"), - ProfileIcon("hexagon", Icons.Filled.Hexagon, "Hexagon"), - ProfileIcon("highlight", Icons.Filled.Highlight, "Highlight"), - ProfileIcon( - "horizontal_distribute", - Icons.Filled.HorizontalDistribute, - "Horizontal Distribute", - ), - ProfileIcon("horizontal_rule", Icons.Filled.HorizontalRule, "Horizontal Rule"), - ProfileIcon("insert_chart", Icons.Filled.InsertChart, "Insert Chart"), - ProfileIcon( - "insert_chart_outlined", - Icons.Filled.InsertChartOutlined, - "Insert Chart Outlined", - ), - ProfileIcon("insert_comment", Icons.Filled.InsertComment, "Insert Comment"), - ProfileIcon("insert_drive_file", Icons.AutoMirrored.Filled.InsertDriveFile, "Insert File"), - ProfileIcon("insert_emoticon", Icons.Filled.InsertEmoticon, "Insert Emoticon"), - ProfileIcon("insert_invitation", Icons.Filled.InsertInvitation, "Insert Invitation"), - ProfileIcon("insert_link", Icons.Filled.InsertLink, "Insert Link"), - ProfileIcon("insert_page_break", Icons.Filled.InsertPageBreak, "Page Break"), - ProfileIcon("insert_photo", Icons.Filled.InsertPhoto, "Insert Photo"), - ProfileIcon("line_axis", Icons.Filled.LineAxis, "Line Axis"), - ProfileIcon("line_weight", Icons.Filled.LineWeight, "Line Weight"), - ProfileIcon("linear_scale", Icons.Filled.LinearScale, "Linear Scale"), - ProfileIcon("margin", Icons.Filled.Margin, "Margin"), - ProfileIcon("merge_type", Icons.Filled.MergeType, "Merge Type"), - ProfileIcon("mode", Icons.Filled.Mode, "Mode"), - ProfileIcon("mode_comment", Icons.Filled.ModeComment, "Mode Comment"), - ProfileIcon("mode_edit", Icons.Filled.ModeEdit, "Mode Edit"), - ProfileIcon("mode_edit_outline", Icons.Filled.ModeEditOutline, "Mode Edit Outline"), - ProfileIcon("monetization_on", Icons.Filled.MonetizationOn, "Monetization On"), - ProfileIcon("money_off", Icons.Filled.MoneyOff, "Money Off"), - ProfileIcon("money_off_csred", Icons.Filled.MoneyOffCsred, "Money Off CS"), - ProfileIcon("move_down", Icons.Filled.MoveDown, "Move Down"), - ProfileIcon("move_up", Icons.Filled.MoveUp, "Move Up"), - ProfileIcon("multiline_chart", Icons.Filled.MultilineChart, "Multiline Chart"), - ProfileIcon("notes", Icons.AutoMirrored.Filled.Notes, "Notes"), - ProfileIcon("numbers", Icons.Filled.Numbers, "Numbers"), - ProfileIcon("padding", Icons.Filled.Padding, "Padding"), - ProfileIcon("pentagon", Icons.Filled.Pentagon, "Pentagon"), - ProfileIcon("pie_chart", Icons.Filled.PieChart, "Pie Chart"), - ProfileIcon("pie_chart_outline", Icons.Filled.PieChartOutline, "Pie Chart Outline"), - ProfileIcon("polyline", Icons.Filled.Polyline, "Polyline"), - ProfileIcon("post_add", Icons.Filled.PostAdd, "Post Add"), - ProfileIcon("publish", Icons.Filled.Publish, "Publish"), - ProfileIcon("query_stats", Icons.Filled.QueryStats, "Query Stats"), - ProfileIcon("rectangle", Icons.Filled.Rectangle, "Rectangle"), - ProfileIcon("scatter_plot", Icons.Filled.ScatterPlot, "Scatter Plot"), - ProfileIcon("schema", Icons.Filled.Schema, "Schema"), - ProfileIcon("score", Icons.Filled.Score, "Score"), - ProfileIcon("short_text", Icons.AutoMirrored.Filled.ShortText, "Short Text"), - ProfileIcon("show_chart", Icons.AutoMirrored.Filled.ShowChart, "Show Chart"), - ProfileIcon("space_bar", Icons.Filled.SpaceBar, "Space Bar"), - ProfileIcon("square", Icons.Filled.Square, "Square"), - ProfileIcon("stacked_line_chart", Icons.Filled.StackedLineChart, "Stacked Line Chart"), - ProfileIcon("strikethrough_s", Icons.Filled.StrikethroughS, "Strikethrough S"), - ProfileIcon("subscript", Icons.Filled.Subscript, "Subscript"), - ProfileIcon("superscript", Icons.Filled.Superscript, "Superscript"), - ProfileIcon("table_chart", Icons.Filled.TableChart, "Table Chart"), - ProfileIcon("table_rows", Icons.Filled.TableRows, "Table Rows"), - ProfileIcon("text_decrease", Icons.Filled.TextDecrease, "Text Decrease"), - ProfileIcon("text_fields", Icons.Filled.TextFields, "Text Fields"), - ProfileIcon("text_increase", Icons.Filled.TextIncrease, "Text Increase"), - ProfileIcon("title", Icons.Filled.Title, "Title"), - ProfileIcon("vertical_align_bottom", Icons.Filled.VerticalAlignBottom, "Vertical Bottom"), - ProfileIcon("vertical_align_center", Icons.Filled.VerticalAlignCenter, "Vertical Center"), - ProfileIcon("vertical_align_top", Icons.Filled.VerticalAlignTop, "Vertical Top"), - ProfileIcon("vertical_distribute", Icons.Filled.VerticalDistribute, "Vertical Distribute"), - ProfileIcon("waterfall_chart", Icons.Filled.WaterfallChart, "Waterfall Chart"), - ProfileIcon("wrap_text", Icons.AutoMirrored.Filled.WrapText, "Wrap Text"), - ) -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/FileIcons.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/FileIcons.kt deleted file mode 100644 index df842a5969..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/FileIcons.kt +++ /dev/null @@ -1,112 +0,0 @@ -package io.nekohasekai.sfa.compose.util.icons - -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Approval -import androidx.compose.material.icons.filled.AttachEmail -import androidx.compose.material.icons.filled.Attachment -import androidx.compose.material.icons.filled.Cloud -import androidx.compose.material.icons.filled.CloudCircle -import androidx.compose.material.icons.filled.CloudDone -import androidx.compose.material.icons.filled.CloudDownload -import androidx.compose.material.icons.filled.CloudOff -import androidx.compose.material.icons.filled.CloudQueue -import androidx.compose.material.icons.filled.CloudSync -import androidx.compose.material.icons.filled.CloudUpload -import androidx.compose.material.icons.filled.CreateNewFolder -import androidx.compose.material.icons.filled.Difference -import androidx.compose.material.icons.filled.Download -import androidx.compose.material.icons.filled.DownloadDone -import androidx.compose.material.icons.filled.DownloadForOffline -import androidx.compose.material.icons.filled.Downloading -import androidx.compose.material.icons.filled.DriveFileMove -import androidx.compose.material.icons.filled.DriveFileMoveRtl -import androidx.compose.material.icons.filled.DriveFileRenameOutline -import androidx.compose.material.icons.filled.DriveFolderUpload -import androidx.compose.material.icons.filled.FileCopy -import androidx.compose.material.icons.filled.FileDownload -import androidx.compose.material.icons.filled.FileDownloadDone -import androidx.compose.material.icons.filled.FileDownloadOff -import androidx.compose.material.icons.filled.FileOpen -import androidx.compose.material.icons.filled.FilePresent -import androidx.compose.material.icons.filled.FileUpload -import androidx.compose.material.icons.filled.Folder -import androidx.compose.material.icons.filled.FolderCopy -import androidx.compose.material.icons.filled.FolderDelete -import androidx.compose.material.icons.filled.FolderOff -import androidx.compose.material.icons.filled.FolderOpen -import androidx.compose.material.icons.filled.FolderShared -import androidx.compose.material.icons.filled.FolderSpecial -import androidx.compose.material.icons.filled.FolderZip -import androidx.compose.material.icons.filled.FormatOverline -import androidx.compose.material.icons.filled.GridView -import androidx.compose.material.icons.filled.Javascript -import androidx.compose.material.icons.filled.Newspaper -import androidx.compose.material.icons.filled.RequestQuote -import androidx.compose.material.icons.filled.RuleFolder -import androidx.compose.material.icons.filled.SnippetFolder -import androidx.compose.material.icons.filled.Source -import androidx.compose.material.icons.filled.TextSnippet -import androidx.compose.material.icons.filled.Topic -import androidx.compose.material.icons.filled.Upload -import androidx.compose.material.icons.filled.UploadFile -import androidx.compose.material.icons.filled.Workspaces -import io.nekohasekai.sfa.compose.util.ProfileIcon - -/** - * File category icons - File types and operations - * Based on Google's Material Design Icons taxonomy - */ -object FileIcons { - val icons = - listOf( - ProfileIcon("approval", Icons.Filled.Approval, "Approval"), - ProfileIcon("attach_email", Icons.Filled.AttachEmail, "Attach Email"), - ProfileIcon("attachment", Icons.Filled.Attachment, "Attachment"), - ProfileIcon("cloud", Icons.Filled.Cloud, "Cloud"), - ProfileIcon("cloud_circle", Icons.Filled.CloudCircle, "Cloud Circle"), - ProfileIcon("cloud_done", Icons.Filled.CloudDone, "Cloud Done"), - ProfileIcon("cloud_download", Icons.Filled.CloudDownload, "Cloud Download"), - ProfileIcon("cloud_off", Icons.Filled.CloudOff, "Cloud Off"), - ProfileIcon("cloud_queue", Icons.Filled.CloudQueue, "Cloud Queue"), - ProfileIcon("cloud_sync", Icons.Filled.CloudSync, "Cloud Sync"), - ProfileIcon("cloud_upload", Icons.Filled.CloudUpload, "Cloud Upload"), - ProfileIcon("create_new_folder", Icons.Filled.CreateNewFolder, "New Folder"), - ProfileIcon("difference", Icons.Filled.Difference, "Difference"), - ProfileIcon("download", Icons.Filled.Download, "Download"), - ProfileIcon("download_done", Icons.Filled.DownloadDone, "Download Done"), - ProfileIcon("download_for_offline", Icons.Filled.DownloadForOffline, "Download Offline"), - ProfileIcon("downloading", Icons.Filled.Downloading, "Downloading"), - ProfileIcon("drive_file_move", Icons.Filled.DriveFileMove, "File Move"), - ProfileIcon("drive_file_move_rtl", Icons.Filled.DriveFileMoveRtl, "File Move RTL"), - ProfileIcon("drive_file_rename_outline", Icons.Filled.DriveFileRenameOutline, "Rename"), - ProfileIcon("drive_folder_upload", Icons.Filled.DriveFolderUpload, "Folder Upload"), - ProfileIcon("file_copy", Icons.Filled.FileCopy, "File Copy"), - ProfileIcon("file_download", Icons.Filled.FileDownload, "File Download"), - ProfileIcon("file_download_done", Icons.Filled.FileDownloadDone, "Download Done"), - ProfileIcon("file_download_off", Icons.Filled.FileDownloadOff, "Download Off"), - ProfileIcon("file_open", Icons.Filled.FileOpen, "File Open"), - ProfileIcon("file_present", Icons.Filled.FilePresent, "File Present"), - ProfileIcon("file_upload", Icons.Filled.FileUpload, "File Upload"), - ProfileIcon("folder", Icons.Filled.Folder, "Folder"), - ProfileIcon("folder_copy", Icons.Filled.FolderCopy, "Folder Copy"), - ProfileIcon("folder_delete", Icons.Filled.FolderDelete, "Folder Delete"), - ProfileIcon("folder_off", Icons.Filled.FolderOff, "Folder Off"), - ProfileIcon("folder_open", Icons.Filled.FolderOpen, "Folder Open"), - ProfileIcon("folder_shared", Icons.Filled.FolderShared, "Folder Shared"), - ProfileIcon("folder_special", Icons.Filled.FolderSpecial, "Folder Special"), - ProfileIcon("folder_zip", Icons.Filled.FolderZip, "Folder Zip"), - ProfileIcon("format_overline", Icons.Filled.FormatOverline, "Format Overline"), - ProfileIcon("grid_view", Icons.Filled.GridView, "Grid View"), - ProfileIcon("javascript", Icons.Filled.Javascript, "JavaScript"), - ProfileIcon("newspaper", Icons.Filled.Newspaper, "Newspaper"), - ProfileIcon("request_quote", Icons.Filled.RequestQuote, "Request Quote"), - ProfileIcon("rule_folder", Icons.Filled.RuleFolder, "Rule Folder"), - ProfileIcon("snippet_folder", Icons.Filled.SnippetFolder, "Snippet Folder"), - ProfileIcon("source", Icons.Filled.Source, "Source"), - ProfileIcon("text_snippet", Icons.Filled.TextSnippet, "Text Snippet"), - ProfileIcon("topic", Icons.Filled.Topic, "Topic"), - ProfileIcon("upload", Icons.Filled.Upload, "Upload"), - ProfileIcon("upload_file", Icons.Filled.UploadFile, "Upload File"), - ProfileIcon("workspaces", Icons.Filled.Workspaces, "Workspaces"), - ) -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/HardwareIcons.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/HardwareIcons.kt deleted file mode 100644 index 3486f616ca..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/HardwareIcons.kt +++ /dev/null @@ -1,186 +0,0 @@ -package io.nekohasekai.sfa.compose.util.icons - -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.BrowserNotSupported -import androidx.compose.material.icons.filled.BrowserUpdated -import androidx.compose.material.icons.filled.Cast -import androidx.compose.material.icons.filled.CastConnected -import androidx.compose.material.icons.filled.CastForEducation -import androidx.compose.material.icons.filled.Computer -import androidx.compose.material.icons.filled.ConnectedTv -import androidx.compose.material.icons.filled.DesktopMac -import androidx.compose.material.icons.filled.DesktopWindows -import androidx.compose.material.icons.filled.DeveloperBoard -import androidx.compose.material.icons.filled.DeveloperBoardOff -import androidx.compose.material.icons.filled.DeviceHub -import androidx.compose.material.icons.filled.DeviceUnknown -import androidx.compose.material.icons.filled.DevicesOther -import androidx.compose.material.icons.filled.DisplaySettings -import androidx.compose.material.icons.filled.Dock -import androidx.compose.material.icons.filled.Earbuds -import androidx.compose.material.icons.filled.EarbudsBattery -import androidx.compose.material.icons.filled.Gamepad -import androidx.compose.material.icons.filled.Headphones -import androidx.compose.material.icons.filled.HeadphonesBattery -import androidx.compose.material.icons.filled.Headset -import androidx.compose.material.icons.filled.HeadsetMic -import androidx.compose.material.icons.filled.HeadsetOff -import androidx.compose.material.icons.filled.HomeMax -import androidx.compose.material.icons.filled.HomeMini -import androidx.compose.material.icons.filled.Keyboard -import androidx.compose.material.icons.filled.KeyboardAlt -import androidx.compose.material.icons.filled.KeyboardArrowDown -import androidx.compose.material.icons.filled.KeyboardArrowLeft -import androidx.compose.material.icons.filled.KeyboardArrowRight -import androidx.compose.material.icons.filled.KeyboardArrowUp -import androidx.compose.material.icons.filled.KeyboardBackspace -import androidx.compose.material.icons.filled.KeyboardCapslock -import androidx.compose.material.icons.filled.KeyboardCommandKey -import androidx.compose.material.icons.filled.KeyboardControlKey -import androidx.compose.material.icons.filled.KeyboardDoubleArrowDown -import androidx.compose.material.icons.filled.KeyboardDoubleArrowLeft -import androidx.compose.material.icons.filled.KeyboardDoubleArrowRight -import androidx.compose.material.icons.filled.KeyboardDoubleArrowUp -import androidx.compose.material.icons.filled.KeyboardHide -import androidx.compose.material.icons.filled.KeyboardOptionKey -import androidx.compose.material.icons.filled.KeyboardReturn -import androidx.compose.material.icons.filled.KeyboardTab -import androidx.compose.material.icons.filled.KeyboardVoice -import androidx.compose.material.icons.filled.Laptop -import androidx.compose.material.icons.filled.LaptopChromebook -import androidx.compose.material.icons.filled.LaptopMac -import androidx.compose.material.icons.filled.LaptopWindows -import androidx.compose.material.icons.filled.Memory -import androidx.compose.material.icons.filled.Monitor -import androidx.compose.material.icons.filled.Mouse -import androidx.compose.material.icons.filled.PhoneAndroid -import androidx.compose.material.icons.filled.PhoneIphone -import androidx.compose.material.icons.filled.Phonelink -import androidx.compose.material.icons.filled.PhonelinkOff -import androidx.compose.material.icons.filled.PivotTableChart -import androidx.compose.material.icons.filled.PointOfSale -import androidx.compose.material.icons.filled.PowerInput -import androidx.compose.material.icons.filled.Print -import androidx.compose.material.icons.filled.Router -import androidx.compose.material.icons.filled.Scanner -import androidx.compose.material.icons.filled.Security -import androidx.compose.material.icons.filled.SimCard -import androidx.compose.material.icons.filled.Smartphone -import androidx.compose.material.icons.filled.Speaker -import androidx.compose.material.icons.filled.SpeakerGroup -import androidx.compose.material.icons.filled.Start -import androidx.compose.material.icons.filled.Tablet -import androidx.compose.material.icons.filled.TabletAndroid -import androidx.compose.material.icons.filled.TabletMac -import androidx.compose.material.icons.filled.Toys -import androidx.compose.material.icons.filled.Tv -import androidx.compose.material.icons.filled.TvOff -import androidx.compose.material.icons.filled.VideogameAsset -import androidx.compose.material.icons.filled.VideogameAssetOff -import androidx.compose.material.icons.filled.Watch -import androidx.compose.material.icons.filled.WatchOff -import io.nekohasekai.sfa.compose.util.ProfileIcon - -/** - * Hardware category icons - Physical hardware and peripherals - * Based on Google's Material Design Icons taxonomy - */ -object HardwareIcons { - val icons = - listOf( - ProfileIcon( - "browser_not_supported", - Icons.Filled.BrowserNotSupported, - "Browser Not Supported", - ), - ProfileIcon("browser_updated", Icons.Filled.BrowserUpdated, "Browser Updated"), - ProfileIcon("cast", Icons.Filled.Cast, "Cast"), - ProfileIcon("cast_connected", Icons.Filled.CastConnected, "Cast Connected"), - ProfileIcon("cast_for_education", Icons.Filled.CastForEducation, "Cast Education"), - ProfileIcon("computer", Icons.Filled.Computer, "Computer"), - ProfileIcon("connected_tv", Icons.Filled.ConnectedTv, "Connected TV"), - ProfileIcon("desktop_mac", Icons.Filled.DesktopMac, "Desktop Mac"), - ProfileIcon("desktop_windows", Icons.Filled.DesktopWindows, "Desktop Windows"), - ProfileIcon("developer_board", Icons.Filled.DeveloperBoard, "Developer Board"), - ProfileIcon("developer_board_off", Icons.Filled.DeveloperBoardOff, "Developer Board Off"), - ProfileIcon("device_hub", Icons.Filled.DeviceHub, "Device Hub"), - ProfileIcon("device_unknown", Icons.Filled.DeviceUnknown, "Device Unknown"), - ProfileIcon("devices_other", Icons.Filled.DevicesOther, "Devices Other"), - ProfileIcon("display_settings", Icons.Filled.DisplaySettings, "Display Settings"), - ProfileIcon("dock", Icons.Filled.Dock, "Dock"), - ProfileIcon("earbuds", Icons.Filled.Earbuds, "Earbuds"), - ProfileIcon("earbuds_battery", Icons.Filled.EarbudsBattery, "Earbuds Battery"), - ProfileIcon("gamepad", Icons.Filled.Gamepad, "Gamepad"), - ProfileIcon("headphones", Icons.Filled.Headphones, "Headphones"), - ProfileIcon("headphones_battery", Icons.Filled.HeadphonesBattery, "Headphones Battery"), - ProfileIcon("headset", Icons.Filled.Headset, "Headset"), - ProfileIcon("headset_mic", Icons.Filled.HeadsetMic, "Headset Mic"), - ProfileIcon("headset_off", Icons.Filled.HeadsetOff, "Headset Off"), - ProfileIcon("home_max", Icons.Filled.HomeMax, "Home Max"), - ProfileIcon("home_mini", Icons.Filled.HomeMini, "Home Mini"), - ProfileIcon("keyboard", Icons.Filled.Keyboard, "Keyboard"), - ProfileIcon("keyboard_alt", Icons.Filled.KeyboardAlt, "Keyboard Alt"), - ProfileIcon("keyboard_arrow_down", Icons.Filled.KeyboardArrowDown, "Arrow Down"), - ProfileIcon("keyboard_arrow_left", Icons.Filled.KeyboardArrowLeft, "Arrow Left"), - ProfileIcon("keyboard_arrow_right", Icons.Filled.KeyboardArrowRight, "Arrow Right"), - ProfileIcon("keyboard_arrow_up", Icons.Filled.KeyboardArrowUp, "Arrow Up"), - ProfileIcon("keyboard_backspace", Icons.Filled.KeyboardBackspace, "Backspace"), - ProfileIcon("keyboard_capslock", Icons.Filled.KeyboardCapslock, "Caps Lock"), - ProfileIcon("keyboard_command_key", Icons.Filled.KeyboardCommandKey, "Command Key"), - ProfileIcon("keyboard_control_key", Icons.Filled.KeyboardControlKey, "Control Key"), - ProfileIcon( - "keyboard_double_arrow_down", - Icons.Filled.KeyboardDoubleArrowDown, - "Double Down", - ), - ProfileIcon( - "keyboard_double_arrow_left", - Icons.Filled.KeyboardDoubleArrowLeft, - "Double Left", - ), - ProfileIcon( - "keyboard_double_arrow_right", - Icons.Filled.KeyboardDoubleArrowRight, - "Double Right", - ), - ProfileIcon("keyboard_double_arrow_up", Icons.Filled.KeyboardDoubleArrowUp, "Double Up"), - ProfileIcon("keyboard_hide", Icons.Filled.KeyboardHide, "Keyboard Hide"), - ProfileIcon("keyboard_option_key", Icons.Filled.KeyboardOptionKey, "Option Key"), - ProfileIcon("keyboard_return", Icons.Filled.KeyboardReturn, "Return"), - ProfileIcon("keyboard_tab", Icons.Filled.KeyboardTab, "Tab"), - ProfileIcon("keyboard_voice", Icons.Filled.KeyboardVoice, "Voice"), - ProfileIcon("laptop", Icons.Filled.Laptop, "Laptop"), - ProfileIcon("laptop_chromebook", Icons.Filled.LaptopChromebook, "Chromebook"), - ProfileIcon("laptop_mac", Icons.Filled.LaptopMac, "Laptop Mac"), - ProfileIcon("laptop_windows", Icons.Filled.LaptopWindows, "Laptop Windows"), - ProfileIcon("memory", Icons.Filled.Memory, "Memory"), - ProfileIcon("monitor", Icons.Filled.Monitor, "Monitor"), - ProfileIcon("mouse", Icons.Filled.Mouse, "Mouse"), - ProfileIcon("phone_android", Icons.Filled.PhoneAndroid, "Phone Android"), - ProfileIcon("phone_iphone", Icons.Filled.PhoneIphone, "Phone iPhone"), - ProfileIcon("phonelink", Icons.Filled.Phonelink, "Phonelink"), - ProfileIcon("phonelink_off", Icons.Filled.PhonelinkOff, "Phonelink Off"), - ProfileIcon("pivot_table_chart", Icons.Filled.PivotTableChart, "Pivot Table"), - ProfileIcon("point_of_sale", Icons.Filled.PointOfSale, "Point of Sale"), - ProfileIcon("power_input", Icons.Filled.PowerInput, "Power Input"), - ProfileIcon("printer", Icons.Filled.Print, "Printer"), - ProfileIcon("router", Icons.Filled.Router, "Router"), - ProfileIcon("scanner", Icons.Filled.Scanner, "Scanner"), - ProfileIcon("security", Icons.Filled.Security, "Security"), - ProfileIcon("sim_card", Icons.Filled.SimCard, "SIM Card"), - ProfileIcon("smartphone", Icons.Filled.Smartphone, "Smartphone"), - ProfileIcon("speaker", Icons.Filled.Speaker, "Speaker"), - ProfileIcon("speaker_group", Icons.Filled.SpeakerGroup, "Speaker Group"), - ProfileIcon("start", Icons.Filled.Start, "Start"), - ProfileIcon("tablet", Icons.Filled.Tablet, "Tablet"), - ProfileIcon("tablet_android", Icons.Filled.TabletAndroid, "Tablet Android"), - ProfileIcon("tablet_mac", Icons.Filled.TabletMac, "Tablet Mac"), - ProfileIcon("toys", Icons.Filled.Toys, "Toys"), - ProfileIcon("tv", Icons.Filled.Tv, "TV"), - ProfileIcon("tv_off", Icons.Filled.TvOff, "TV Off"), - ProfileIcon("videogame_asset", Icons.Filled.VideogameAsset, "Videogame"), - ProfileIcon("videogame_asset_off", Icons.Filled.VideogameAssetOff, "Videogame Off"), - ProfileIcon("watch", Icons.Filled.Watch, "Watch"), - ProfileIcon("watch_off", Icons.Filled.WatchOff, "Watch Off"), - ) -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/IconCategory.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/IconCategory.kt deleted file mode 100644 index 040e6305c5..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/IconCategory.kt +++ /dev/null @@ -1,10 +0,0 @@ -package io.nekohasekai.sfa.compose.util.icons - -import io.nekohasekai.sfa.compose.util.ProfileIcon - -/** - * Represents a category of Material Icons following Google's official taxonomy - */ -data class IconCategory(val name: String, val icons: List) { - val size: Int get() = icons.size -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/ImageIcons.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/ImageIcons.kt deleted file mode 100644 index 67ade2e06a..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/ImageIcons.kt +++ /dev/null @@ -1,509 +0,0 @@ -package io.nekohasekai.sfa.compose.util.icons - -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ReceiptLong -import androidx.compose.material.icons.automirrored.filled.RotateLeft -import androidx.compose.material.icons.automirrored.filled.RotateRight -import androidx.compose.material.icons.filled.AddAPhoto -import androidx.compose.material.icons.filled.AddPhotoAlternate -import androidx.compose.material.icons.filled.AddToPhotos -import androidx.compose.material.icons.filled.Adjust -import androidx.compose.material.icons.filled.Animation -import androidx.compose.material.icons.filled.Assistant -import androidx.compose.material.icons.filled.AssistantPhoto -import androidx.compose.material.icons.filled.Audiotrack -import androidx.compose.material.icons.filled.AutoAwesome -import androidx.compose.material.icons.filled.AutoAwesomeMosaic -import androidx.compose.material.icons.filled.AutoAwesomeMotion -import androidx.compose.material.icons.filled.AutoFixHigh -import androidx.compose.material.icons.filled.AutoFixNormal -import androidx.compose.material.icons.filled.AutoFixOff -import androidx.compose.material.icons.filled.AutoMode -import androidx.compose.material.icons.filled.AutoStories -import androidx.compose.material.icons.filled.AutofpsSelect -import androidx.compose.material.icons.filled.Bedtime -import androidx.compose.material.icons.filled.BedtimeOff -import androidx.compose.material.icons.filled.BlurCircular -import androidx.compose.material.icons.filled.BlurLinear -import androidx.compose.material.icons.filled.BlurOff -import androidx.compose.material.icons.filled.BlurOn -import androidx.compose.material.icons.filled.Brightness1 -import androidx.compose.material.icons.filled.Brightness2 -import androidx.compose.material.icons.filled.Brightness3 -import androidx.compose.material.icons.filled.Brightness4 -import androidx.compose.material.icons.filled.Brightness5 -import androidx.compose.material.icons.filled.Brightness6 -import androidx.compose.material.icons.filled.Brightness7 -import androidx.compose.material.icons.filled.BrokenImage -import androidx.compose.material.icons.filled.Brush -import androidx.compose.material.icons.filled.BurstMode -import androidx.compose.material.icons.filled.Camera -import androidx.compose.material.icons.filled.CameraAlt -import androidx.compose.material.icons.filled.CameraFront -import androidx.compose.material.icons.filled.CameraOutdoor -import androidx.compose.material.icons.filled.CameraRear -import androidx.compose.material.icons.filled.CameraRoll -import androidx.compose.material.icons.filled.Cases -import androidx.compose.material.icons.filled.CenterFocusStrong -import androidx.compose.material.icons.filled.CenterFocusWeak -import androidx.compose.material.icons.filled.Circle -import androidx.compose.material.icons.filled.Collections -import androidx.compose.material.icons.filled.CollectionsBookmark -import androidx.compose.material.icons.filled.ColorLens -import androidx.compose.material.icons.filled.Colorize -import androidx.compose.material.icons.filled.Compare -import androidx.compose.material.icons.filled.Contrast -import androidx.compose.material.icons.filled.ControlPoint -import androidx.compose.material.icons.filled.ControlPointDuplicate -import androidx.compose.material.icons.filled.Crop -import androidx.compose.material.icons.filled.Crop169 -import androidx.compose.material.icons.filled.Crop32 -import androidx.compose.material.icons.filled.Crop54 -import androidx.compose.material.icons.filled.Crop75 -import androidx.compose.material.icons.filled.CropDin -import androidx.compose.material.icons.filled.CropFree -import androidx.compose.material.icons.filled.CropLandscape -import androidx.compose.material.icons.filled.CropOriginal -import androidx.compose.material.icons.filled.CropPortrait -import androidx.compose.material.icons.filled.CropRotate -import androidx.compose.material.icons.filled.CropSquare -import androidx.compose.material.icons.filled.CurrencyFranc -import androidx.compose.material.icons.filled.CurrencyLira -import androidx.compose.material.icons.filled.CurrencyPound -import androidx.compose.material.icons.filled.CurrencyRuble -import androidx.compose.material.icons.filled.CurrencyRupee -import androidx.compose.material.icons.filled.CurrencyYen -import androidx.compose.material.icons.filled.CurrencyYuan -import androidx.compose.material.icons.filled.Deblur -import androidx.compose.material.icons.filled.Dehaze -import androidx.compose.material.icons.filled.Details -import androidx.compose.material.icons.filled.DirtyLens -import androidx.compose.material.icons.filled.Edit -import androidx.compose.material.icons.filled.Euro -import androidx.compose.material.icons.filled.Exposure -import androidx.compose.material.icons.filled.ExposureNeg1 -import androidx.compose.material.icons.filled.ExposureNeg2 -import androidx.compose.material.icons.filled.ExposurePlus1 -import androidx.compose.material.icons.filled.ExposurePlus2 -import androidx.compose.material.icons.filled.ExposureZero -import androidx.compose.material.icons.filled.FaceRetouchingNatural -import androidx.compose.material.icons.filled.FaceRetouchingOff -import androidx.compose.material.icons.filled.Filter -import androidx.compose.material.icons.filled.Filter1 -import androidx.compose.material.icons.filled.Filter2 -import androidx.compose.material.icons.filled.Filter3 -import androidx.compose.material.icons.filled.Filter4 -import androidx.compose.material.icons.filled.Filter5 -import androidx.compose.material.icons.filled.Filter6 -import androidx.compose.material.icons.filled.Filter7 -import androidx.compose.material.icons.filled.Filter8 -import androidx.compose.material.icons.filled.Filter9 -import androidx.compose.material.icons.filled.Filter9Plus -import androidx.compose.material.icons.filled.FilterBAndW -import androidx.compose.material.icons.filled.FilterCenterFocus -import androidx.compose.material.icons.filled.FilterDrama -import androidx.compose.material.icons.filled.FilterFrames -import androidx.compose.material.icons.filled.FilterHdr -import androidx.compose.material.icons.filled.FilterNone -import androidx.compose.material.icons.filled.FilterTiltShift -import androidx.compose.material.icons.filled.FilterVintage -import androidx.compose.material.icons.filled.Flare -import androidx.compose.material.icons.filled.FlashAuto -import androidx.compose.material.icons.filled.FlashOff -import androidx.compose.material.icons.filled.FlashOn -import androidx.compose.material.icons.filled.Flip -import androidx.compose.material.icons.filled.FlipCameraAndroid -import androidx.compose.material.icons.filled.FlipCameraIos -import androidx.compose.material.icons.filled.Gradient -import androidx.compose.material.icons.filled.Grain -import androidx.compose.material.icons.filled.GridOff -import androidx.compose.material.icons.filled.GridOn -import androidx.compose.material.icons.filled.HdrEnhancedSelect -import androidx.compose.material.icons.filled.HdrOff -import androidx.compose.material.icons.filled.HdrOn -import androidx.compose.material.icons.filled.HdrPlus -import androidx.compose.material.icons.filled.HdrStrong -import androidx.compose.material.icons.filled.HdrWeak -import androidx.compose.material.icons.filled.Healing -import androidx.compose.material.icons.filled.Hevc -import androidx.compose.material.icons.filled.HideImage -import androidx.compose.material.icons.filled.Image -import androidx.compose.material.icons.filled.ImageAspectRatio -import androidx.compose.material.icons.filled.ImageNotSupported -import androidx.compose.material.icons.filled.ImageSearch -import androidx.compose.material.icons.filled.IncompleteCircle -import androidx.compose.material.icons.filled.Iso -import androidx.compose.material.icons.filled.Landscape -import androidx.compose.material.icons.filled.LeakAdd -import androidx.compose.material.icons.filled.LeakRemove -import androidx.compose.material.icons.filled.Lens -import androidx.compose.material.icons.filled.LinkedCamera -import androidx.compose.material.icons.filled.LogoDev -import androidx.compose.material.icons.filled.Looks -import androidx.compose.material.icons.filled.Looks3 -import androidx.compose.material.icons.filled.Looks4 -import androidx.compose.material.icons.filled.Looks5 -import androidx.compose.material.icons.filled.Looks6 -import androidx.compose.material.icons.filled.LooksOne -import androidx.compose.material.icons.filled.LooksTwo -import androidx.compose.material.icons.filled.Loupe -import androidx.compose.material.icons.filled.MicExternalOff -import androidx.compose.material.icons.filled.MicExternalOn -import androidx.compose.material.icons.filled.MonochromePhotos -import androidx.compose.material.icons.filled.MotionPhotosAuto -import androidx.compose.material.icons.filled.MotionPhotosOff -import androidx.compose.material.icons.filled.MotionPhotosOn -import androidx.compose.material.icons.filled.MotionPhotosPause -import androidx.compose.material.icons.filled.MotionPhotosPaused -import androidx.compose.material.icons.filled.MovieCreation -import androidx.compose.material.icons.filled.MovieFilter -import androidx.compose.material.icons.filled.Mp -import androidx.compose.material.icons.filled.MusicNote -import androidx.compose.material.icons.filled.MusicOff -import androidx.compose.material.icons.filled.Nature -import androidx.compose.material.icons.filled.NaturePeople -import androidx.compose.material.icons.filled.NavigateBefore -import androidx.compose.material.icons.filled.NavigateNext -import androidx.compose.material.icons.filled.Palette -import androidx.compose.material.icons.filled.Panorama -import androidx.compose.material.icons.filled.PanoramaFishEye -import androidx.compose.material.icons.filled.PanoramaHorizontal -import androidx.compose.material.icons.filled.PanoramaHorizontalSelect -import androidx.compose.material.icons.filled.PanoramaPhotosphere -import androidx.compose.material.icons.filled.PanoramaPhotosphereSelect -import androidx.compose.material.icons.filled.PanoramaVertical -import androidx.compose.material.icons.filled.PanoramaVerticalSelect -import androidx.compose.material.icons.filled.PanoramaWideAngle -import androidx.compose.material.icons.filled.PanoramaWideAngleSelect -import androidx.compose.material.icons.filled.Photo -import androidx.compose.material.icons.filled.PhotoAlbum -import androidx.compose.material.icons.filled.PhotoCamera -import androidx.compose.material.icons.filled.PhotoCameraBack -import androidx.compose.material.icons.filled.PhotoCameraFront -import androidx.compose.material.icons.filled.PhotoFilter -import androidx.compose.material.icons.filled.PhotoLibrary -import androidx.compose.material.icons.filled.PhotoSizeSelectActual -import androidx.compose.material.icons.filled.PhotoSizeSelectLarge -import androidx.compose.material.icons.filled.PhotoSizeSelectSmall -import androidx.compose.material.icons.filled.PictureAsPdf -import androidx.compose.material.icons.filled.Portrait -import androidx.compose.material.icons.filled.RawOff -import androidx.compose.material.icons.filled.RawOn -import androidx.compose.material.icons.filled.RemoveRedEye -import androidx.compose.material.icons.filled.Rotate90DegreesCcw -import androidx.compose.material.icons.filled.Rotate90DegreesCw -import androidx.compose.material.icons.filled.ShutterSpeed -import androidx.compose.material.icons.filled.Slideshow -import androidx.compose.material.icons.filled.Straighten -import androidx.compose.material.icons.filled.Style -import androidx.compose.material.icons.filled.SwitchCamera -import androidx.compose.material.icons.filled.SwitchVideo -import androidx.compose.material.icons.filled.TagFaces -import androidx.compose.material.icons.filled.Texture -import androidx.compose.material.icons.filled.ThermostatAuto -import androidx.compose.material.icons.filled.Timelapse -import androidx.compose.material.icons.filled.Timer -import androidx.compose.material.icons.filled.Timer10 -import androidx.compose.material.icons.filled.Timer3 -import androidx.compose.material.icons.filled.TimerOff -import androidx.compose.material.icons.filled.Tonality -import androidx.compose.material.icons.filled.Transform -import androidx.compose.material.icons.filled.Tune -import androidx.compose.material.icons.filled.VideoCameraBack -import androidx.compose.material.icons.filled.VideoCameraFront -import androidx.compose.material.icons.filled.VideoStable -import androidx.compose.material.icons.filled.ViewComfy -import androidx.compose.material.icons.filled.ViewCompact -import androidx.compose.material.icons.filled.Vignette -import androidx.compose.material.icons.filled.Vrpano -import androidx.compose.material.icons.filled.WbAuto -import androidx.compose.material.icons.filled.WbCloudy -import androidx.compose.material.icons.filled.WbIncandescent -import androidx.compose.material.icons.filled.WbIridescent -import androidx.compose.material.icons.filled.WbShade -import androidx.compose.material.icons.filled.WbSunny -import androidx.compose.material.icons.filled.WbTwilight -import io.nekohasekai.sfa.compose.util.ProfileIcon - -/** - * Image category icons - Image editing and gallery - * Based on Google's Material Design Icons taxonomy - */ -object ImageIcons { - val icons = - listOf( - // ProfileIcon("10mp", Icons.Filled.TenMp, "10MP"), - // ProfileIcon("11mp", Icons.Filled.ElevenMp, "11MP"), - // ProfileIcon("12mp", Icons.Filled.TwelveMp, "12MP"), - // ProfileIcon("13mp", Icons.Filled.ThirteenMp, "13MP"), - // ProfileIcon("14mp", Icons.Filled.FourteenMp, "14MP"), - // ProfileIcon("15mp", Icons.Filled.FifteenMp, "15MP"), - // ProfileIcon("16mp", Icons.Filled.SixteenMp, "16MP"), - // ProfileIcon("17mp", Icons.Filled.SeventeenMp, "17MP"), - // ProfileIcon("18mp", Icons.Filled.EighteenMp, "18MP"), - // ProfileIcon("19mp", Icons.Filled.NineteenMp, "19MP"), - // ProfileIcon("20mp", Icons.Filled.TwentyMp, "20MP"), - // ProfileIcon("21mp", Icons.Filled.TwentyOneMp, "21MP"), - // ProfileIcon("22mp", Icons.Filled.TwentyTwoMp, "22MP"), - // ProfileIcon("23mp", Icons.Filled.TwentyThreeMp, "23MP"), - // ProfileIcon("24mp", Icons.Filled.TwentyFourMp, "24MP"), - // ProfileIcon("2mp", Icons.Filled.TwoMp, "2MP"), - // ProfileIcon("30fps", Icons.Filled.ThirtyFps, "30 FPS"), // Not available - // ProfileIcon("30fps_select", Icons.Filled.ThirtyFpsSelect, "30 FPS Select"), - // ProfileIcon("3mp", Icons.Filled.ThreeMp, "3MP"), - // ProfileIcon("4mp", Icons.Filled.FourMp, "4MP"), - // ProfileIcon("5mp", Icons.Filled.FiveMp, "5MP"), - // ProfileIcon("60fps", Icons.Filled.SixtyFps, "60 FPS"), - // ProfileIcon("60fps_select", Icons.Filled.SixtyFpsSelect, "60 FPS Select"), - // ProfileIcon("6mp", Icons.Filled.SixMp, "6MP"), - // ProfileIcon("7mp", Icons.Filled.SevenMp, "7MP"), - // ProfileIcon("8mp", Icons.Filled.EightMp, "8MP"), - // ProfileIcon("9mp", Icons.Filled.NineMp, "9MP"), - ProfileIcon("add_a_photo", Icons.Filled.AddAPhoto, "Add Photo"), - ProfileIcon("add_photo_alternate", Icons.Filled.AddPhotoAlternate, "Add Photo Alt"), - ProfileIcon("add_to_photos", Icons.Filled.AddToPhotos, "Add to Photos"), - ProfileIcon("adjust", Icons.Filled.Adjust, "Adjust"), - ProfileIcon("animation", Icons.Filled.Animation, "Animation"), - ProfileIcon("assistant", Icons.Filled.Assistant, "Assistant"), - ProfileIcon("assistant_photo", Icons.Filled.AssistantPhoto, "Assistant Photo"), - ProfileIcon("audiotrack", Icons.Filled.Audiotrack, "Audio Track"), - ProfileIcon("auto_awesome", Icons.Filled.AutoAwesome, "Auto Awesome"), - ProfileIcon("auto_awesome_mosaic", Icons.Filled.AutoAwesomeMosaic, "Auto Mosaic"), - ProfileIcon("auto_awesome_motion", Icons.Filled.AutoAwesomeMotion, "Auto Motion"), - ProfileIcon("auto_fix_high", Icons.Filled.AutoFixHigh, "Auto Fix High"), - ProfileIcon("auto_fix_normal", Icons.Filled.AutoFixNormal, "Auto Fix Normal"), - ProfileIcon("auto_fix_off", Icons.Filled.AutoFixOff, "Auto Fix Off"), - ProfileIcon("auto_mode", Icons.Filled.AutoMode, "Auto Mode"), - ProfileIcon("auto_stories", Icons.Filled.AutoStories, "Auto Stories"), - ProfileIcon("autofps_select", Icons.Filled.AutofpsSelect, "Auto FPS Select"), - ProfileIcon("bedtime", Icons.Filled.Bedtime, "Bedtime"), - ProfileIcon("bedtime_off", Icons.Filled.BedtimeOff, "Bedtime Off"), - ProfileIcon("blur_circular", Icons.Filled.BlurCircular, "Blur Circular"), - ProfileIcon("blur_linear", Icons.Filled.BlurLinear, "Blur Linear"), - ProfileIcon("blur_off", Icons.Filled.BlurOff, "Blur Off"), - ProfileIcon("blur_on", Icons.Filled.BlurOn, "Blur On"), - ProfileIcon("brightness_1", Icons.Filled.Brightness1, "Brightness 1"), - ProfileIcon("brightness_2", Icons.Filled.Brightness2, "Brightness 2"), - ProfileIcon("brightness_3", Icons.Filled.Brightness3, "Brightness 3"), - ProfileIcon("brightness_4", Icons.Filled.Brightness4, "Brightness 4"), - ProfileIcon("brightness_5", Icons.Filled.Brightness5, "Brightness 5"), - ProfileIcon("brightness_6", Icons.Filled.Brightness6, "Brightness 6"), - ProfileIcon("brightness_7", Icons.Filled.Brightness7, "Brightness 7"), - ProfileIcon("broken_image", Icons.Filled.BrokenImage, "Broken Image"), - ProfileIcon("brush", Icons.Filled.Brush, "Brush"), - ProfileIcon("burst_mode", Icons.Filled.BurstMode, "Burst Mode"), - ProfileIcon("camera", Icons.Filled.Camera, "Camera"), - ProfileIcon("camera_alt", Icons.Filled.CameraAlt, "Camera Alt"), - ProfileIcon("camera_front", Icons.Filled.CameraFront, "Camera Front"), - ProfileIcon("camera_outdoor", Icons.Filled.CameraOutdoor, "Camera Outdoor"), - ProfileIcon("camera_rear", Icons.Filled.CameraRear, "Camera Rear"), - ProfileIcon("camera_roll", Icons.Filled.CameraRoll, "Camera Roll"), - ProfileIcon("cases", Icons.Filled.Cases, "Cases"), - ProfileIcon("center_focus_strong", Icons.Filled.CenterFocusStrong, "Center Focus Strong"), - ProfileIcon("center_focus_weak", Icons.Filled.CenterFocusWeak, "Center Focus Weak"), - ProfileIcon("circle", Icons.Filled.Circle, "Circle"), - ProfileIcon("collections", Icons.Filled.Collections, "Collections"), - ProfileIcon( - "collections_bookmark", - Icons.Filled.CollectionsBookmark, - "Collections Bookmark", - ), - ProfileIcon("color_lens", Icons.Filled.ColorLens, "Color Lens"), - ProfileIcon("colorize", Icons.Filled.Colorize, "Colorize"), - ProfileIcon("compare", Icons.Filled.Compare, "Compare"), - ProfileIcon("contrast", Icons.Filled.Contrast, "Contrast"), - ProfileIcon("control_point", Icons.Filled.ControlPoint, "Control Point"), - ProfileIcon( - "control_point_duplicate", - Icons.Filled.ControlPointDuplicate, - "Control Duplicate", - ), - ProfileIcon("crop", Icons.Filled.Crop, "Crop"), - ProfileIcon("crop_16_9", Icons.Filled.Crop169, "Crop 16:9"), - ProfileIcon("crop_3_2", Icons.Filled.Crop32, "Crop 3:2"), - ProfileIcon("crop_5_4", Icons.Filled.Crop54, "Crop 5:4"), - ProfileIcon("crop_7_5", Icons.Filled.Crop75, "Crop 7:5"), - ProfileIcon("crop_din", Icons.Filled.CropDin, "Crop Din"), - ProfileIcon("crop_free", Icons.Filled.CropFree, "Crop Free"), - ProfileIcon("crop_landscape", Icons.Filled.CropLandscape, "Crop Landscape"), - ProfileIcon("crop_original", Icons.Filled.CropOriginal, "Crop Original"), - ProfileIcon("crop_portrait", Icons.Filled.CropPortrait, "Crop Portrait"), - ProfileIcon("crop_rotate", Icons.Filled.CropRotate, "Crop Rotate"), - ProfileIcon("crop_square", Icons.Filled.CropSquare, "Crop Square"), - ProfileIcon("currency_franc", Icons.Filled.CurrencyFranc, "Currency Franc"), - ProfileIcon("currency_lira", Icons.Filled.CurrencyLira, "Currency Lira"), - ProfileIcon("currency_pound", Icons.Filled.CurrencyPound, "Currency Pound"), - ProfileIcon("currency_ruble", Icons.Filled.CurrencyRuble, "Currency Ruble"), - ProfileIcon("currency_rupee", Icons.Filled.CurrencyRupee, "Currency Rupee"), - ProfileIcon("currency_yen", Icons.Filled.CurrencyYen, "Currency Yen"), - ProfileIcon("currency_yuan", Icons.Filled.CurrencyYuan, "Currency Yuan"), - ProfileIcon("deblur", Icons.Filled.Deblur, "Deblur"), - ProfileIcon("dehaze", Icons.Filled.Dehaze, "Dehaze"), - ProfileIcon("details", Icons.Filled.Details, "Details"), - ProfileIcon("dirty_lens", Icons.Filled.DirtyLens, "Dirty Lens"), - ProfileIcon("edit", Icons.Filled.Edit, "Edit"), - ProfileIcon("euro", Icons.Filled.Euro, "Euro"), - ProfileIcon("exposure", Icons.Filled.Exposure, "Exposure"), - ProfileIcon("exposure_neg_1", Icons.Filled.ExposureNeg1, "Exposure -1"), - ProfileIcon("exposure_neg_2", Icons.Filled.ExposureNeg2, "Exposure -2"), - ProfileIcon("exposure_plus_1", Icons.Filled.ExposurePlus1, "Exposure +1"), - ProfileIcon("exposure_plus_2", Icons.Filled.ExposurePlus2, "Exposure +2"), - ProfileIcon("exposure_zero", Icons.Filled.ExposureZero, "Exposure 0"), - ProfileIcon("face_retouching_natural", Icons.Filled.FaceRetouchingNatural, "Face Natural"), - ProfileIcon("face_retouching_off", Icons.Filled.FaceRetouchingOff, "Face Off"), - ProfileIcon("filter", Icons.Filled.Filter, "Filter"), - ProfileIcon("filter_1", Icons.Filled.Filter1, "Filter 1"), - ProfileIcon("filter_2", Icons.Filled.Filter2, "Filter 2"), - ProfileIcon("filter_3", Icons.Filled.Filter3, "Filter 3"), - ProfileIcon("filter_4", Icons.Filled.Filter4, "Filter 4"), - ProfileIcon("filter_5", Icons.Filled.Filter5, "Filter 5"), - ProfileIcon("filter_6", Icons.Filled.Filter6, "Filter 6"), - ProfileIcon("filter_7", Icons.Filled.Filter7, "Filter 7"), - ProfileIcon("filter_8", Icons.Filled.Filter8, "Filter 8"), - ProfileIcon("filter_9", Icons.Filled.Filter9, "Filter 9"), - ProfileIcon("filter_9_plus", Icons.Filled.Filter9Plus, "Filter 9+"), - ProfileIcon("filter_b_and_w", Icons.Filled.FilterBAndW, "Filter B&W"), - ProfileIcon("filter_center_focus", Icons.Filled.FilterCenterFocus, "Filter Focus"), - ProfileIcon("filter_drama", Icons.Filled.FilterDrama, "Filter Drama"), - ProfileIcon("filter_frames", Icons.Filled.FilterFrames, "Filter Frames"), - ProfileIcon("filter_hdr", Icons.Filled.FilterHdr, "Filter HDR"), - ProfileIcon("filter_none", Icons.Filled.FilterNone, "Filter None"), - ProfileIcon("filter_tilt_shift", Icons.Filled.FilterTiltShift, "Filter Tilt"), - ProfileIcon("filter_vintage", Icons.Filled.FilterVintage, "Filter Vintage"), - ProfileIcon("flare", Icons.Filled.Flare, "Flare"), - ProfileIcon("flash_auto", Icons.Filled.FlashAuto, "Flash Auto"), - ProfileIcon("flash_off", Icons.Filled.FlashOff, "Flash Off"), - ProfileIcon("flash_on", Icons.Filled.FlashOn, "Flash On"), - ProfileIcon("flip", Icons.Filled.Flip, "Flip"), - ProfileIcon("flip_camera_android", Icons.Filled.FlipCameraAndroid, "Flip Camera"), - ProfileIcon("flip_camera_ios", Icons.Filled.FlipCameraIos, "Flip Camera iOS"), - ProfileIcon("gradient", Icons.Filled.Gradient, "Gradient"), - ProfileIcon("grain", Icons.Filled.Grain, "Grain"), - ProfileIcon("grid_off", Icons.Filled.GridOff, "Grid Off"), - ProfileIcon("grid_on", Icons.Filled.GridOn, "Grid On"), - ProfileIcon("hdr_enhanced_select", Icons.Filled.HdrEnhancedSelect, "HDR Enhanced"), - ProfileIcon("hdr_off", Icons.Filled.HdrOff, "HDR Off"), - ProfileIcon("hdr_on", Icons.Filled.HdrOn, "HDR On"), - ProfileIcon("hdr_plus", Icons.Filled.HdrPlus, "HDR Plus"), - ProfileIcon("hdr_strong", Icons.Filled.HdrStrong, "HDR Strong"), - ProfileIcon("hdr_weak", Icons.Filled.HdrWeak, "HDR Weak"), - ProfileIcon("healing", Icons.Filled.Healing, "Healing"), - ProfileIcon("hevc", Icons.Filled.Hevc, "HEVC"), - ProfileIcon("hide_image", Icons.Filled.HideImage, "Hide Image"), - ProfileIcon("image", Icons.Filled.Image, "Image"), - ProfileIcon("image_aspect_ratio", Icons.Filled.ImageAspectRatio, "Image Aspect"), - ProfileIcon("image_not_supported", Icons.Filled.ImageNotSupported, "Image Not Supported"), - ProfileIcon("image_search", Icons.Filled.ImageSearch, "Image Search"), - ProfileIcon("incomplete_circle", Icons.Filled.IncompleteCircle, "Incomplete Circle"), - ProfileIcon("iso", Icons.Filled.Iso, "ISO"), - ProfileIcon("landscape", Icons.Filled.Landscape, "Landscape"), - ProfileIcon("leak_add", Icons.Filled.LeakAdd, "Leak Add"), - ProfileIcon("leak_remove", Icons.Filled.LeakRemove, "Leak Remove"), - ProfileIcon("lens", Icons.Filled.Lens, "Lens"), - ProfileIcon("linked_camera", Icons.Filled.LinkedCamera, "Linked Camera"), - ProfileIcon("logo_dev", Icons.Filled.LogoDev, "Logo Dev"), - ProfileIcon("looks", Icons.Filled.Looks, "Looks"), - ProfileIcon("looks_3", Icons.Filled.Looks3, "Looks 3"), - ProfileIcon("looks_4", Icons.Filled.Looks4, "Looks 4"), - ProfileIcon("looks_5", Icons.Filled.Looks5, "Looks 5"), - ProfileIcon("looks_6", Icons.Filled.Looks6, "Looks 6"), - ProfileIcon("looks_one", Icons.Filled.LooksOne, "Looks One"), - ProfileIcon("looks_two", Icons.Filled.LooksTwo, "Looks Two"), - ProfileIcon("loupe", Icons.Filled.Loupe, "Loupe"), - ProfileIcon("mic_external_off", Icons.Filled.MicExternalOff, "Mic External Off"), - ProfileIcon("mic_external_on", Icons.Filled.MicExternalOn, "Mic External On"), - ProfileIcon("monochrome_photos", Icons.Filled.MonochromePhotos, "Monochrome"), - ProfileIcon("motion_photos_auto", Icons.Filled.MotionPhotosAuto, "Motion Auto"), - ProfileIcon("motion_photos_off", Icons.Filled.MotionPhotosOff, "Motion Off"), - ProfileIcon("motion_photos_on", Icons.Filled.MotionPhotosOn, "Motion On"), - ProfileIcon("motion_photos_pause", Icons.Filled.MotionPhotosPause, "Motion Pause"), - ProfileIcon("motion_photos_paused", Icons.Filled.MotionPhotosPaused, "Motion Paused"), - ProfileIcon("movie_creation", Icons.Filled.MovieCreation, "Movie Creation"), - ProfileIcon("movie_filter", Icons.Filled.MovieFilter, "Movie Filter"), - ProfileIcon("mp", Icons.Filled.Mp, "MP"), - ProfileIcon("music_note", Icons.Filled.MusicNote, "Music Note"), - ProfileIcon("music_off", Icons.Filled.MusicOff, "Music Off"), - ProfileIcon("nature", Icons.Filled.Nature, "Nature"), - ProfileIcon("nature_people", Icons.Filled.NaturePeople, "Nature People"), - ProfileIcon("navigate_before", Icons.Filled.NavigateBefore, "Navigate Before"), - ProfileIcon("navigate_next", Icons.Filled.NavigateNext, "Navigate Next"), - ProfileIcon("palette", Icons.Filled.Palette, "Palette"), - ProfileIcon("panorama", Icons.Filled.Panorama, "Panorama"), - ProfileIcon("panorama_fish_eye", Icons.Filled.PanoramaFishEye, "Fish Eye"), - ProfileIcon("panorama_horizontal", Icons.Filled.PanoramaHorizontal, "Panorama Horizontal"), - ProfileIcon( - "panorama_horizontal_select", - Icons.Filled.PanoramaHorizontalSelect, - "Horizontal Select", - ), - ProfileIcon("panorama_photosphere", Icons.Filled.PanoramaPhotosphere, "Photosphere"), - ProfileIcon( - "panorama_photosphere_select", - Icons.Filled.PanoramaPhotosphereSelect, - "Photosphere Select", - ), - ProfileIcon("panorama_vertical", Icons.Filled.PanoramaVertical, "Panorama Vertical"), - ProfileIcon( - "panorama_vertical_select", - Icons.Filled.PanoramaVerticalSelect, - "Vertical Select", - ), - ProfileIcon("panorama_wide_angle", Icons.Filled.PanoramaWideAngle, "Wide Angle"), - ProfileIcon( - "panorama_wide_angle_select", - Icons.Filled.PanoramaWideAngleSelect, - "Wide Select", - ), - ProfileIcon("photo", Icons.Filled.Photo, "Photo"), - ProfileIcon("photo_album", Icons.Filled.PhotoAlbum, "Photo Album"), - ProfileIcon("photo_camera", Icons.Filled.PhotoCamera, "Photo Camera"), - ProfileIcon("photo_camera_back", Icons.Filled.PhotoCameraBack, "Camera Back"), - ProfileIcon("photo_camera_front", Icons.Filled.PhotoCameraFront, "Camera Front"), - ProfileIcon("photo_filter", Icons.Filled.PhotoFilter, "Photo Filter"), - ProfileIcon("photo_library", Icons.Filled.PhotoLibrary, "Photo Library"), - ProfileIcon("photo_size_select_actual", Icons.Filled.PhotoSizeSelectActual, "Actual Size"), - ProfileIcon("photo_size_select_large", Icons.Filled.PhotoSizeSelectLarge, "Large Size"), - ProfileIcon("photo_size_select_small", Icons.Filled.PhotoSizeSelectSmall, "Small Size"), - ProfileIcon("picture_as_pdf", Icons.Filled.PictureAsPdf, "Picture as PDF"), - ProfileIcon("portrait", Icons.Filled.Portrait, "Portrait"), - ProfileIcon("raw_off", Icons.Filled.RawOff, "RAW Off"), - ProfileIcon("raw_on", Icons.Filled.RawOn, "RAW On"), - ProfileIcon("receipt_long", Icons.AutoMirrored.Filled.ReceiptLong, "Receipt Long"), - ProfileIcon("remove_red_eye", Icons.Filled.RemoveRedEye, "Remove Red Eye"), - ProfileIcon("rotate_90_degrees_ccw", Icons.Filled.Rotate90DegreesCcw, "Rotate CCW"), - ProfileIcon("rotate_90_degrees_cw", Icons.Filled.Rotate90DegreesCw, "Rotate CW"), - ProfileIcon("rotate_left", Icons.AutoMirrored.Filled.RotateLeft, "Rotate Left"), - ProfileIcon("rotate_right", Icons.AutoMirrored.Filled.RotateRight, "Rotate Right"), - ProfileIcon("shutter_speed", Icons.Filled.ShutterSpeed, "Shutter Speed"), - ProfileIcon("slideshow", Icons.Filled.Slideshow, "Slideshow"), - ProfileIcon("straighten", Icons.Filled.Straighten, "Straighten"), - ProfileIcon("style", Icons.Filled.Style, "Style"), - ProfileIcon("switch_camera", Icons.Filled.SwitchCamera, "Switch Camera"), - ProfileIcon("switch_video", Icons.Filled.SwitchVideo, "Switch Video"), - ProfileIcon("tag_faces", Icons.Filled.TagFaces, "Tag Faces"), - ProfileIcon("texture", Icons.Filled.Texture, "Texture"), - ProfileIcon("thermostat_auto", Icons.Filled.ThermostatAuto, "Thermostat Auto"), - ProfileIcon("timelapse", Icons.Filled.Timelapse, "Timelapse"), - ProfileIcon("timer", Icons.Filled.Timer, "Timer"), - ProfileIcon("timer_10", Icons.Filled.Timer10, "Timer 10"), - ProfileIcon("timer_3", Icons.Filled.Timer3, "Timer 3"), - ProfileIcon("timer_off", Icons.Filled.TimerOff, "Timer Off"), - ProfileIcon("tonality", Icons.Filled.Tonality, "Tonality"), - ProfileIcon("transform", Icons.Filled.Transform, "Transform"), - ProfileIcon("tune", Icons.Filled.Tune, "Tune"), - ProfileIcon("video_camera_back", Icons.Filled.VideoCameraBack, "Video Back"), - ProfileIcon("video_camera_front", Icons.Filled.VideoCameraFront, "Video Front"), - ProfileIcon("video_stable", Icons.Filled.VideoStable, "Video Stable"), - ProfileIcon("view_comfy", Icons.Filled.ViewComfy, "View Comfy"), - ProfileIcon("view_compact", Icons.Filled.ViewCompact, "View Compact"), - ProfileIcon("vignette", Icons.Filled.Vignette, "Vignette"), - ProfileIcon("vrpano", Icons.Filled.Vrpano, "VR Pano"), - ProfileIcon("wb_auto", Icons.Filled.WbAuto, "WB Auto"), - ProfileIcon("wb_cloudy", Icons.Filled.WbCloudy, "WB Cloudy"), - ProfileIcon("wb_incandescent", Icons.Filled.WbIncandescent, "WB Incandescent"), - ProfileIcon("wb_iridescent", Icons.Filled.WbIridescent, "WB Iridescent"), - ProfileIcon("wb_shade", Icons.Filled.WbShade, "WB Shade"), - ProfileIcon("wb_sunny", Icons.Filled.WbSunny, "WB Sunny"), - ProfileIcon("wb_twilight", Icons.Filled.WbTwilight, "WB Twilight"), - ) -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/MapsIcons.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/MapsIcons.kt deleted file mode 100644 index 6b246098f6..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/MapsIcons.kt +++ /dev/null @@ -1,465 +0,0 @@ -package io.nekohasekai.sfa.compose.util.icons - -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.AddLocation -import androidx.compose.material.icons.filled.AddLocationAlt -import androidx.compose.material.icons.filled.AddRoad -import androidx.compose.material.icons.filled.Agriculture -import androidx.compose.material.icons.filled.AirlineStops -import androidx.compose.material.icons.filled.Airlines -import androidx.compose.material.icons.filled.AltRoute -import androidx.compose.material.icons.filled.Atm -import androidx.compose.material.icons.filled.Attractions -import androidx.compose.material.icons.filled.Badge -import androidx.compose.material.icons.filled.BakeryDining -import androidx.compose.material.icons.filled.Beenhere -import androidx.compose.material.icons.filled.BikeScooter -import androidx.compose.material.icons.filled.BreakfastDining -import androidx.compose.material.icons.filled.BrunchDining -import androidx.compose.material.icons.filled.BusAlert -import androidx.compose.material.icons.filled.CarCrash -import androidx.compose.material.icons.filled.CarRental -import androidx.compose.material.icons.filled.CarRepair -import androidx.compose.material.icons.filled.Castle -import androidx.compose.material.icons.filled.Category -import androidx.compose.material.icons.filled.Celebration -import androidx.compose.material.icons.filled.Church -import androidx.compose.material.icons.filled.CleaningServices -import androidx.compose.material.icons.filled.CompassCalibration -import androidx.compose.material.icons.filled.ConnectingAirports -import androidx.compose.material.icons.filled.CrisisAlert -import androidx.compose.material.icons.filled.DeliveryDining -import androidx.compose.material.icons.filled.DepartureBoard -import androidx.compose.material.icons.filled.DesignServices -import androidx.compose.material.icons.filled.Diamond -import androidx.compose.material.icons.filled.DinnerDining -import androidx.compose.material.icons.filled.Directions -import androidx.compose.material.icons.filled.DirectionsBike -import androidx.compose.material.icons.filled.DirectionsBoat -import androidx.compose.material.icons.filled.DirectionsBoatFilled -import androidx.compose.material.icons.filled.DirectionsBus -import androidx.compose.material.icons.filled.DirectionsBusFilled -import androidx.compose.material.icons.filled.DirectionsCar -import androidx.compose.material.icons.filled.DirectionsCarFilled -import androidx.compose.material.icons.filled.DirectionsRailway -import androidx.compose.material.icons.filled.DirectionsRailwayFilled -import androidx.compose.material.icons.filled.DirectionsRun -import androidx.compose.material.icons.filled.DirectionsSubway -import androidx.compose.material.icons.filled.DirectionsSubwayFilled -import androidx.compose.material.icons.filled.DirectionsTransit -import androidx.compose.material.icons.filled.DirectionsTransitFilled -import androidx.compose.material.icons.filled.DirectionsWalk -import androidx.compose.material.icons.filled.DryCleaning -import androidx.compose.material.icons.filled.EditAttributes -import androidx.compose.material.icons.filled.EditLocation -import androidx.compose.material.icons.filled.EditLocationAlt -import androidx.compose.material.icons.filled.EditRoad -import androidx.compose.material.icons.filled.Egg -import androidx.compose.material.icons.filled.EggAlt -import androidx.compose.material.icons.filled.ElectricBike -import androidx.compose.material.icons.filled.ElectricCar -import androidx.compose.material.icons.filled.ElectricMoped -import androidx.compose.material.icons.filled.ElectricRickshaw -import androidx.compose.material.icons.filled.ElectricScooter -import androidx.compose.material.icons.filled.ElectricalServices -import androidx.compose.material.icons.filled.Emergency -import androidx.compose.material.icons.filled.EmergencyRecording -import androidx.compose.material.icons.filled.EmergencyShare -import androidx.compose.material.icons.filled.EvStation -import androidx.compose.material.icons.filled.Factory -import androidx.compose.material.icons.filled.Fastfood -import androidx.compose.material.icons.filled.Festival -import androidx.compose.material.icons.filled.FireExtinguisher -import androidx.compose.material.icons.filled.FireHydrantAlt -import androidx.compose.material.icons.filled.FireTruck -import androidx.compose.material.icons.filled.Flight -import androidx.compose.material.icons.filled.FlightClass -import androidx.compose.material.icons.filled.FlightLand -import androidx.compose.material.icons.filled.FlightTakeoff -import androidx.compose.material.icons.filled.FoodBank -import androidx.compose.material.icons.filled.Forest -import androidx.compose.material.icons.filled.ForkLeft -import androidx.compose.material.icons.filled.ForkRight -import androidx.compose.material.icons.filled.Fort -import androidx.compose.material.icons.filled.Hail -import androidx.compose.material.icons.filled.Handyman -import androidx.compose.material.icons.filled.Hardware -import androidx.compose.material.icons.filled.HomeRepairService -import androidx.compose.material.icons.filled.Hotel -import androidx.compose.material.icons.filled.Hvac -import androidx.compose.material.icons.filled.Icecream -import androidx.compose.material.icons.filled.KebabDining -import androidx.compose.material.icons.filled.Layers -import androidx.compose.material.icons.filled.LayersClear -import androidx.compose.material.icons.filled.Liquor -import androidx.compose.material.icons.filled.LocalActivity -import androidx.compose.material.icons.filled.LocalAirport -import androidx.compose.material.icons.filled.LocalAtm -import androidx.compose.material.icons.filled.LocalBar -import androidx.compose.material.icons.filled.LocalCafe -import androidx.compose.material.icons.filled.LocalCarWash -import androidx.compose.material.icons.filled.LocalConvenienceStore -import androidx.compose.material.icons.filled.LocalDining -import androidx.compose.material.icons.filled.LocalDrink -import androidx.compose.material.icons.filled.LocalFireDepartment -import androidx.compose.material.icons.filled.LocalFlorist -import androidx.compose.material.icons.filled.LocalGasStation -import androidx.compose.material.icons.filled.LocalGroceryStore -import androidx.compose.material.icons.filled.LocalHospital -import androidx.compose.material.icons.filled.LocalHotel -import androidx.compose.material.icons.filled.LocalLaundryService -import androidx.compose.material.icons.filled.LocalLibrary -import androidx.compose.material.icons.filled.LocalMall -import androidx.compose.material.icons.filled.LocalMovies -import androidx.compose.material.icons.filled.LocalOffer -import androidx.compose.material.icons.filled.LocalParking -import androidx.compose.material.icons.filled.LocalPharmacy -import androidx.compose.material.icons.filled.LocalPhone -import androidx.compose.material.icons.filled.LocalPizza -import androidx.compose.material.icons.filled.LocalPlay -import androidx.compose.material.icons.filled.LocalPolice -import androidx.compose.material.icons.filled.LocalPostOffice -import androidx.compose.material.icons.filled.LocalPrintshop -import androidx.compose.material.icons.filled.LocalSee -import androidx.compose.material.icons.filled.LocalShipping -import androidx.compose.material.icons.filled.LocalTaxi -import androidx.compose.material.icons.filled.LocationCity -import androidx.compose.material.icons.filled.LocationDisabled -import androidx.compose.material.icons.filled.LocationOff -import androidx.compose.material.icons.filled.LocationOn -import androidx.compose.material.icons.filled.LocationSearching -import androidx.compose.material.icons.filled.LunchDining -import androidx.compose.material.icons.filled.Map -import androidx.compose.material.icons.filled.MapsHomeWork -import androidx.compose.material.icons.filled.MapsUgc -import androidx.compose.material.icons.filled.MedicalInformation -import androidx.compose.material.icons.filled.MedicalServices -import androidx.compose.material.icons.filled.Merge -import androidx.compose.material.icons.filled.MinorCrash -import androidx.compose.material.icons.filled.MiscellaneousServices -import androidx.compose.material.icons.filled.ModeOfTravel -import androidx.compose.material.icons.filled.Money -import androidx.compose.material.icons.filled.Mosque -import androidx.compose.material.icons.filled.Moving -import androidx.compose.material.icons.filled.MultipleStop -import androidx.compose.material.icons.filled.Museum -import androidx.compose.material.icons.filled.MyLocation -import androidx.compose.material.icons.filled.Navigation -import androidx.compose.material.icons.filled.NearMe -import androidx.compose.material.icons.filled.NearMeDisabled -import androidx.compose.material.icons.filled.Nightlife -import androidx.compose.material.icons.filled.NoCrash -import androidx.compose.material.icons.filled.NoMeals -import androidx.compose.material.icons.filled.NoTransfer -import androidx.compose.material.icons.filled.NotListedLocation -import androidx.compose.material.icons.filled.Park -import androidx.compose.material.icons.filled.PedalBike -import androidx.compose.material.icons.filled.PersonPin -import androidx.compose.material.icons.filled.PersonPinCircle -import androidx.compose.material.icons.filled.PestControl -import androidx.compose.material.icons.filled.PestControlRodent -import androidx.compose.material.icons.filled.PinDrop -import androidx.compose.material.icons.filled.Place -import androidx.compose.material.icons.filled.Plumbing -import androidx.compose.material.icons.filled.RailwayAlert -import androidx.compose.material.icons.filled.RamenDining -import androidx.compose.material.icons.filled.RampLeft -import androidx.compose.material.icons.filled.RampRight -import androidx.compose.material.icons.filled.RateReview -import androidx.compose.material.icons.filled.RemoveRoad -import androidx.compose.material.icons.filled.Restaurant -import androidx.compose.material.icons.filled.RestaurantMenu -import androidx.compose.material.icons.filled.RoundaboutLeft -import androidx.compose.material.icons.filled.RoundaboutRight -import androidx.compose.material.icons.filled.Route -import androidx.compose.material.icons.filled.RunCircle -import androidx.compose.material.icons.filled.SafetyCheck -import androidx.compose.material.icons.filled.Sailing -import androidx.compose.material.icons.filled.Satellite -import androidx.compose.material.icons.filled.ScreenRotationAlt -import androidx.compose.material.icons.filled.SetMeal -import androidx.compose.material.icons.filled.Signpost -import androidx.compose.material.icons.filled.Snowmobile -import androidx.compose.material.icons.filled.Sos -import androidx.compose.material.icons.filled.SoupKitchen -import androidx.compose.material.icons.filled.Stadium -import androidx.compose.material.icons.filled.StoreMallDirectory -import androidx.compose.material.icons.filled.Straight -import androidx.compose.material.icons.filled.Streetview -import androidx.compose.material.icons.filled.Subway -import androidx.compose.material.icons.filled.Synagogue -import androidx.compose.material.icons.filled.TakeoutDining -import androidx.compose.material.icons.filled.TaxiAlert -import androidx.compose.material.icons.filled.TempleBuddhist -import androidx.compose.material.icons.filled.TempleHindu -import androidx.compose.material.icons.filled.Terrain -import androidx.compose.material.icons.filled.TheaterComedy -import androidx.compose.material.icons.filled.TireRepair -import androidx.compose.material.icons.filled.Traffic -import androidx.compose.material.icons.filled.Train -import androidx.compose.material.icons.filled.Tram -import androidx.compose.material.icons.filled.TransferWithinAStation -import androidx.compose.material.icons.filled.TransitEnterexit -import androidx.compose.material.icons.filled.TripOrigin -import androidx.compose.material.icons.filled.TurnLeft -import androidx.compose.material.icons.filled.TurnRight -import androidx.compose.material.icons.filled.TurnSharpLeft -import androidx.compose.material.icons.filled.TurnSharpRight -import androidx.compose.material.icons.filled.TurnSlightLeft -import androidx.compose.material.icons.filled.TurnSlightRight -import androidx.compose.material.icons.filled.TwoWheeler -import androidx.compose.material.icons.filled.UTurnLeft -import androidx.compose.material.icons.filled.UTurnRight -import androidx.compose.material.icons.filled.VolunteerActivism -import androidx.compose.material.icons.filled.Warehouse -import androidx.compose.material.icons.filled.WineBar -import androidx.compose.material.icons.filled.WrongLocation -import androidx.compose.material.icons.filled.ZoomInMap -import androidx.compose.material.icons.filled.ZoomOutMap -import io.nekohasekai.sfa.compose.util.ProfileIcon - -/** - * Maps category icons - Location and navigation - * Based on Google's Material Design Icons taxonomy - */ -object MapsIcons { - val icons = - listOf( - // ProfileIcon("360", Icons.Filled.ThreeSixty, "360"), - ProfileIcon("add_location", Icons.Filled.AddLocation, "Add Location"), - ProfileIcon("add_location_alt", Icons.Filled.AddLocationAlt, "Add Location Alt"), - ProfileIcon("add_road", Icons.Filled.AddRoad, "Add Road"), - ProfileIcon("agriculture", Icons.Filled.Agriculture, "Agriculture"), - ProfileIcon("airline_stops", Icons.Filled.AirlineStops, "Airline Stops"), - ProfileIcon("airlines", Icons.Filled.Airlines, "Airlines"), - ProfileIcon("alt_route", Icons.Filled.AltRoute, "Alt Route"), - ProfileIcon("atm", Icons.Filled.Atm, "ATM"), - ProfileIcon("attractions", Icons.Filled.Attractions, "Attractions"), - ProfileIcon("badge", Icons.Filled.Badge, "Badge"), - ProfileIcon("bakery_dining", Icons.Filled.BakeryDining, "Bakery Dining"), - ProfileIcon("beenhere", Icons.Filled.Beenhere, "Been Here"), - ProfileIcon("bike_scooter", Icons.Filled.BikeScooter, "Bike Scooter"), - ProfileIcon("breakfast_dining", Icons.Filled.BreakfastDining, "Breakfast Dining"), - ProfileIcon("brunch_dining", Icons.Filled.BrunchDining, "Brunch Dining"), - ProfileIcon("bus_alert", Icons.Filled.BusAlert, "Bus Alert"), - ProfileIcon("car_crash", Icons.Filled.CarCrash, "Car Crash"), - ProfileIcon("car_rental", Icons.Filled.CarRental, "Car Rental"), - ProfileIcon("car_repair", Icons.Filled.CarRepair, "Car Repair"), - ProfileIcon("castle", Icons.Filled.Castle, "Castle"), - ProfileIcon("category", Icons.Filled.Category, "Category"), - ProfileIcon("celebration", Icons.Filled.Celebration, "Celebration"), - ProfileIcon("church", Icons.Filled.Church, "Church"), - ProfileIcon("cleaning_services", Icons.Filled.CleaningServices, "Cleaning Services"), - ProfileIcon("compass_calibration", Icons.Filled.CompassCalibration, "Compass Calibration"), - ProfileIcon("connecting_airports", Icons.Filled.ConnectingAirports, "Connecting Airports"), - ProfileIcon("crisis_alert", Icons.Filled.CrisisAlert, "Crisis Alert"), - ProfileIcon("delivery_dining", Icons.Filled.DeliveryDining, "Delivery Dining"), - ProfileIcon("departure_board", Icons.Filled.DepartureBoard, "Departure Board"), - ProfileIcon("design_services", Icons.Filled.DesignServices, "Design Services"), - ProfileIcon("diamond", Icons.Filled.Diamond, "Diamond"), - ProfileIcon("dinner_dining", Icons.Filled.DinnerDining, "Dinner Dining"), - ProfileIcon("directions", Icons.Filled.Directions, "Directions"), - ProfileIcon("directions_bike", Icons.Filled.DirectionsBike, "Directions Bike"), - ProfileIcon("directions_boat", Icons.Filled.DirectionsBoat, "Directions Boat"), - ProfileIcon("directions_boat_filled", Icons.Filled.DirectionsBoatFilled, "Boat Filled"), - ProfileIcon("directions_bus", Icons.Filled.DirectionsBus, "Directions Bus"), - ProfileIcon("directions_bus_filled", Icons.Filled.DirectionsBusFilled, "Bus Filled"), - ProfileIcon("directions_car", Icons.Filled.DirectionsCar, "Directions Car"), - ProfileIcon("directions_car_filled", Icons.Filled.DirectionsCarFilled, "Car Filled"), - ProfileIcon("directions_railway", Icons.Filled.DirectionsRailway, "Railway"), - ProfileIcon( - "directions_railway_filled", - Icons.Filled.DirectionsRailwayFilled, - "Railway Filled", - ), - ProfileIcon("directions_run", Icons.Filled.DirectionsRun, "Directions Run"), - ProfileIcon("directions_subway", Icons.Filled.DirectionsSubway, "Subway"), - ProfileIcon( - "directions_subway_filled", - Icons.Filled.DirectionsSubwayFilled, - "Subway Filled", - ), - ProfileIcon("directions_transit", Icons.Filled.DirectionsTransit, "Transit"), - ProfileIcon( - "directions_transit_filled", - Icons.Filled.DirectionsTransitFilled, - "Transit Filled", - ), - ProfileIcon("directions_walk", Icons.Filled.DirectionsWalk, "Directions Walk"), - ProfileIcon("dry_cleaning", Icons.Filled.DryCleaning, "Dry Cleaning"), - ProfileIcon("edit_attributes", Icons.Filled.EditAttributes, "Edit Attributes"), - ProfileIcon("edit_location", Icons.Filled.EditLocation, "Edit Location"), - ProfileIcon("edit_location_alt", Icons.Filled.EditLocationAlt, "Edit Location Alt"), - ProfileIcon("edit_road", Icons.Filled.EditRoad, "Edit Road"), - ProfileIcon("egg", Icons.Filled.Egg, "Egg"), - ProfileIcon("egg_alt", Icons.Filled.EggAlt, "Egg Alt"), - ProfileIcon("electric_bike", Icons.Filled.ElectricBike, "Electric Bike"), - ProfileIcon("electric_car", Icons.Filled.ElectricCar, "Electric Car"), - ProfileIcon("electric_moped", Icons.Filled.ElectricMoped, "Electric Moped"), - ProfileIcon("electric_rickshaw", Icons.Filled.ElectricRickshaw, "Electric Rickshaw"), - ProfileIcon("electric_scooter", Icons.Filled.ElectricScooter, "Electric Scooter"), - ProfileIcon("electrical_services", Icons.Filled.ElectricalServices, "Electrical Services"), - ProfileIcon("emergency", Icons.Filled.Emergency, "Emergency"), - ProfileIcon("emergency_recording", Icons.Filled.EmergencyRecording, "Emergency Recording"), - ProfileIcon("emergency_share", Icons.Filled.EmergencyShare, "Emergency Share"), - ProfileIcon("ev_station", Icons.Filled.EvStation, "EV Station"), - ProfileIcon("factory", Icons.Filled.Factory, "Factory"), - ProfileIcon("fastfood", Icons.Filled.Fastfood, "Fast Food"), - ProfileIcon("festival", Icons.Filled.Festival, "Festival"), - ProfileIcon("fire_extinguisher", Icons.Filled.FireExtinguisher, "Fire Extinguisher"), - ProfileIcon("fire_hydrant_alt", Icons.Filled.FireHydrantAlt, "Fire Hydrant"), - ProfileIcon("fire_truck", Icons.Filled.FireTruck, "Fire Truck"), - ProfileIcon("flight", Icons.Filled.Flight, "Flight"), - ProfileIcon("flight_class", Icons.Filled.FlightClass, "Flight Class"), - ProfileIcon("flight_land", Icons.Filled.FlightLand, "Flight Land"), - ProfileIcon("flight_takeoff", Icons.Filled.FlightTakeoff, "Flight Takeoff"), - ProfileIcon("food_bank", Icons.Filled.FoodBank, "Food Bank"), - ProfileIcon("forest", Icons.Filled.Forest, "Forest"), - ProfileIcon("fork_left", Icons.Filled.ForkLeft, "Fork Left"), - ProfileIcon("fork_right", Icons.Filled.ForkRight, "Fork Right"), - ProfileIcon("fort", Icons.Filled.Fort, "Fort"), - ProfileIcon("hail", Icons.Filled.Hail, "Hail"), - ProfileIcon("handyman", Icons.Filled.Handyman, "Handyman"), - ProfileIcon("hardware", Icons.Filled.Hardware, "Hardware"), - ProfileIcon("home_repair_service", Icons.Filled.HomeRepairService, "Home Repair"), - ProfileIcon("hotel", Icons.Filled.Hotel, "Hotel"), - ProfileIcon("hvac", Icons.Filled.Hvac, "HVAC"), - ProfileIcon("icecream", Icons.Filled.Icecream, "Ice Cream"), - ProfileIcon("kebab_dining", Icons.Filled.KebabDining, "Kebab Dining"), - ProfileIcon("layers", Icons.Filled.Layers, "Layers"), - ProfileIcon("layers_clear", Icons.Filled.LayersClear, "Layers Clear"), - ProfileIcon("liquor", Icons.Filled.Liquor, "Liquor"), - ProfileIcon("local_activity", Icons.Filled.LocalActivity, "Local Activity"), - ProfileIcon("local_airport", Icons.Filled.LocalAirport, "Airport"), - ProfileIcon("local_atm", Icons.Filled.LocalAtm, "ATM"), - ProfileIcon("local_bar", Icons.Filled.LocalBar, "Bar"), - ProfileIcon("local_cafe", Icons.Filled.LocalCafe, "Cafe"), - ProfileIcon("local_car_wash", Icons.Filled.LocalCarWash, "Car Wash"), - ProfileIcon( - "local_convenience_store", - Icons.Filled.LocalConvenienceStore, - "Convenience Store", - ), - ProfileIcon("local_dining", Icons.Filled.LocalDining, "Dining"), - ProfileIcon("local_drink", Icons.Filled.LocalDrink, "Drink"), - ProfileIcon("local_fire_department", Icons.Filled.LocalFireDepartment, "Fire Department"), - ProfileIcon("local_florist", Icons.Filled.LocalFlorist, "Florist"), - ProfileIcon("local_gas_station", Icons.Filled.LocalGasStation, "Gas Station"), - ProfileIcon("local_grocery_store", Icons.Filled.LocalGroceryStore, "Grocery Store"), - ProfileIcon("local_hospital", Icons.Filled.LocalHospital, "Hospital"), - ProfileIcon("local_hotel", Icons.Filled.LocalHotel, "Hotel"), - ProfileIcon("local_laundry_service", Icons.Filled.LocalLaundryService, "Laundry"), - ProfileIcon("local_library", Icons.Filled.LocalLibrary, "Library"), - ProfileIcon("local_mall", Icons.Filled.LocalMall, "Mall"), - ProfileIcon("local_movies", Icons.Filled.LocalMovies, "Movies"), - ProfileIcon("local_offer", Icons.Filled.LocalOffer, "Offer"), - ProfileIcon("local_parking", Icons.Filled.LocalParking, "Parking"), - ProfileIcon("local_pharmacy", Icons.Filled.LocalPharmacy, "Pharmacy"), - ProfileIcon("local_phone", Icons.Filled.LocalPhone, "Phone"), - ProfileIcon("local_pizza", Icons.Filled.LocalPizza, "Pizza"), - ProfileIcon("local_play", Icons.Filled.LocalPlay, "Play"), - ProfileIcon("local_police", Icons.Filled.LocalPolice, "Police"), - ProfileIcon("local_post_office", Icons.Filled.LocalPostOffice, "Post Office"), - ProfileIcon("local_printshop", Icons.Filled.LocalPrintshop, "Print Shop"), - ProfileIcon("local_see", Icons.Filled.LocalSee, "See"), - ProfileIcon("local_shipping", Icons.Filled.LocalShipping, "Shipping"), - ProfileIcon("local_taxi", Icons.Filled.LocalTaxi, "Taxi"), - ProfileIcon("location_city", Icons.Filled.LocationCity, "City"), - ProfileIcon("location_disabled", Icons.Filled.LocationDisabled, "Location Disabled"), - ProfileIcon("location_off", Icons.Filled.LocationOff, "Location Off"), - ProfileIcon("location_on", Icons.Filled.LocationOn, "Location On"), - ProfileIcon("location_searching", Icons.Filled.LocationSearching, "Location Searching"), - ProfileIcon("lunch_dining", Icons.Filled.LunchDining, "Lunch Dining"), - ProfileIcon("map", Icons.Filled.Map, "Map"), - ProfileIcon("maps_home_work", Icons.Filled.MapsHomeWork, "Home Work"), - ProfileIcon("maps_ugc", Icons.Filled.MapsUgc, "Maps UGC"), - ProfileIcon("medical_information", Icons.Filled.MedicalInformation, "Medical Info"), - ProfileIcon("medical_services", Icons.Filled.MedicalServices, "Medical Services"), - ProfileIcon("merge", Icons.Filled.Merge, "Merge"), - ProfileIcon("minor_crash", Icons.Filled.MinorCrash, "Minor Crash"), - ProfileIcon("miscellaneous_services", Icons.Filled.MiscellaneousServices, "Misc Services"), - ProfileIcon("mode_of_travel", Icons.Filled.ModeOfTravel, "Mode of Travel"), - ProfileIcon("money", Icons.Filled.Money, "Money"), - ProfileIcon("mosque", Icons.Filled.Mosque, "Mosque"), - ProfileIcon("moving", Icons.Filled.Moving, "Moving"), - ProfileIcon("multiple_stop", Icons.Filled.MultipleStop, "Multiple Stop"), - ProfileIcon("museum", Icons.Filled.Museum, "Museum"), - ProfileIcon("my_location", Icons.Filled.MyLocation, "My Location"), - ProfileIcon("navigation", Icons.Filled.Navigation, "Navigation"), - ProfileIcon("near_me", Icons.Filled.NearMe, "Near Me"), - ProfileIcon("near_me_disabled", Icons.Filled.NearMeDisabled, "Near Me Disabled"), - ProfileIcon("nightlife", Icons.Filled.Nightlife, "Nightlife"), - ProfileIcon("no_crash", Icons.Filled.NoCrash, "No Crash"), - ProfileIcon("no_meals", Icons.Filled.NoMeals, "No Meals"), - ProfileIcon("no_transfer", Icons.Filled.NoTransfer, "No Transfer"), - ProfileIcon("not_listed_location", Icons.Filled.NotListedLocation, "Not Listed"), - ProfileIcon("park", Icons.Filled.Park, "Park"), - ProfileIcon("pedal_bike", Icons.Filled.PedalBike, "Pedal Bike"), - ProfileIcon("person_pin", Icons.Filled.PersonPin, "Person Pin"), - ProfileIcon("person_pin_circle", Icons.Filled.PersonPinCircle, "Person Pin Circle"), - ProfileIcon("pest_control", Icons.Filled.PestControl, "Pest Control"), - ProfileIcon("pest_control_rodent", Icons.Filled.PestControlRodent, "Pest Rodent"), - ProfileIcon("pin_drop", Icons.Filled.PinDrop, "Pin Drop"), - ProfileIcon("place", Icons.Filled.Place, "Place"), - ProfileIcon("plumbing", Icons.Filled.Plumbing, "Plumbing"), - ProfileIcon("railway_alert", Icons.Filled.RailwayAlert, "Railway Alert"), - ProfileIcon("ramen_dining", Icons.Filled.RamenDining, "Ramen Dining"), - ProfileIcon("ramp_left", Icons.Filled.RampLeft, "Ramp Left"), - ProfileIcon("ramp_right", Icons.Filled.RampRight, "Ramp Right"), - ProfileIcon("rate_review", Icons.Filled.RateReview, "Rate Review"), - ProfileIcon("remove_road", Icons.Filled.RemoveRoad, "Remove Road"), - ProfileIcon("restaurant", Icons.Filled.Restaurant, "Restaurant"), - ProfileIcon("restaurant_menu", Icons.Filled.RestaurantMenu, "Restaurant Menu"), - ProfileIcon("route", Icons.Filled.Route, "Route"), - ProfileIcon("roundabout_left", Icons.Filled.RoundaboutLeft, "Roundabout Left"), - ProfileIcon("roundabout_right", Icons.Filled.RoundaboutRight, "Roundabout Right"), - ProfileIcon("run_circle", Icons.Filled.RunCircle, "Run Circle"), - ProfileIcon("safety_check", Icons.Filled.SafetyCheck, "Safety Check"), - ProfileIcon("sailing", Icons.Filled.Sailing, "Sailing"), - ProfileIcon("satellite", Icons.Filled.Satellite, "Satellite"), - ProfileIcon("screen_rotation_alt", Icons.Filled.ScreenRotationAlt, "Screen Rotation Alt"), - ProfileIcon("set_meal", Icons.Filled.SetMeal, "Set Meal"), - ProfileIcon("signpost", Icons.Filled.Signpost, "Signpost"), - ProfileIcon("snowmobile", Icons.Filled.Snowmobile, "Snowmobile"), - ProfileIcon("sos", Icons.Filled.Sos, "SOS"), - ProfileIcon("soup_kitchen", Icons.Filled.SoupKitchen, "Soup Kitchen"), - ProfileIcon("stadium", Icons.Filled.Stadium, "Stadium"), - ProfileIcon("store_mall_directory", Icons.Filled.StoreMallDirectory, "Mall Directory"), - ProfileIcon("straight", Icons.Filled.Straight, "Straight"), - ProfileIcon("streetview", Icons.Filled.Streetview, "Street View"), - ProfileIcon("subway", Icons.Filled.Subway, "Subway"), - ProfileIcon("synagogue", Icons.Filled.Synagogue, "Synagogue"), - ProfileIcon("takeout_dining", Icons.Filled.TakeoutDining, "Takeout Dining"), - ProfileIcon("taxi_alert", Icons.Filled.TaxiAlert, "Taxi Alert"), - ProfileIcon("temple_buddhist", Icons.Filled.TempleBuddhist, "Buddhist Temple"), - ProfileIcon("temple_hindu", Icons.Filled.TempleHindu, "Hindu Temple"), - ProfileIcon("terrain", Icons.Filled.Terrain, "Terrain"), - ProfileIcon("theater_comedy", Icons.Filled.TheaterComedy, "Theater Comedy"), - ProfileIcon("tire_repair", Icons.Filled.TireRepair, "Tire Repair"), - ProfileIcon("traffic", Icons.Filled.Traffic, "Traffic"), - ProfileIcon("train", Icons.Filled.Train, "Train"), - ProfileIcon("tram", Icons.Filled.Tram, "Tram"), - ProfileIcon( - "transfer_within_a_station", - Icons.Filled.TransferWithinAStation, - "Transfer Station", - ), - ProfileIcon("transit_enterexit", Icons.Filled.TransitEnterexit, "Transit Enter/Exit"), - ProfileIcon("trip_origin", Icons.Filled.TripOrigin, "Trip Origin"), - ProfileIcon("turn_left", Icons.Filled.TurnLeft, "Turn Left"), - ProfileIcon("turn_right", Icons.Filled.TurnRight, "Turn Right"), - ProfileIcon("turn_sharp_left", Icons.Filled.TurnSharpLeft, "Turn Sharp Left"), - ProfileIcon("turn_sharp_right", Icons.Filled.TurnSharpRight, "Turn Sharp Right"), - ProfileIcon("turn_slight_left", Icons.Filled.TurnSlightLeft, "Turn Slight Left"), - ProfileIcon("turn_slight_right", Icons.Filled.TurnSlightRight, "Turn Slight Right"), - ProfileIcon("two_wheeler", Icons.Filled.TwoWheeler, "Two Wheeler"), - ProfileIcon("u_turn_left", Icons.Filled.UTurnLeft, "U-Turn Left"), - ProfileIcon("u_turn_right", Icons.Filled.UTurnRight, "U-Turn Right"), - ProfileIcon("volunteer_activism", Icons.Filled.VolunteerActivism, "Volunteer"), - ProfileIcon("warehouse", Icons.Filled.Warehouse, "Warehouse"), - ProfileIcon("wine_bar", Icons.Filled.WineBar, "Wine Bar"), - ProfileIcon("wrong_location", Icons.Filled.WrongLocation, "Wrong Location"), - ProfileIcon("zoom_in_map", Icons.Filled.ZoomInMap, "Zoom In Map"), - ProfileIcon("zoom_out_map", Icons.Filled.ZoomOutMap, "Zoom Out Map"), - ) -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/MaterialIconsLibrary.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/MaterialIconsLibrary.kt deleted file mode 100644 index 9d2c351680..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/MaterialIconsLibrary.kt +++ /dev/null @@ -1,102 +0,0 @@ -package io.nekohasekai.sfa.compose.util.icons - -import androidx.compose.ui.graphics.vector.ImageVector -import io.nekohasekai.sfa.compose.util.ProfileIcon - -/** - * Complete Material Icons Library following Google's official taxonomy - * Icons are organized into categories as defined by Material Design guidelines - * - * Categories based on https://fonts.google.com/icons taxonomy: - * - Action: User actions and common UI operations - * - Alert: Warnings, errors, and notifications - * - AV (Audio/Video): Media controls and playback - * - Communication: Messaging, calls, emails - * - Content: Content creation and management - * - Device: Device-specific icons and features - * - Editor: Text and content editing - * - File: File types and operations - * - Hardware: Physical hardware and peripherals - * - Image: Image editing and gallery - * - Maps: Location and navigation - * - Navigation: App navigation and menus - * - Notification: Alerts and status updates - * - Places: Locations and venues - * - Social: Social media and sharing - * - Toggle: Switches and toggles - */ -object MaterialIconsLibrary { - /** - * All icon categories following Google's Material Design taxonomy - */ - val categories: List = - listOf( - IconCategory("Action", ActionIcons.icons), - IconCategory("Alert", AlertIcons.icons), - IconCategory("Audio & Video", AVIcons.icons), - IconCategory("Communication", CommunicationIcons.icons), - IconCategory("Content", ContentIcons.icons), - IconCategory("Device", DeviceIcons.icons), - IconCategory("Editor", EditorIcons.icons), - IconCategory("File", FileIcons.icons), - IconCategory("Hardware", HardwareIcons.icons), - IconCategory("Image", ImageIcons.icons), - IconCategory("Maps", MapsIcons.icons), - IconCategory("Navigation", NavigationIcons.icons), - IconCategory("Notification", NotificationIcons.icons), - IconCategory("Places", PlacesIcons.icons), - IconCategory("Social", SocialIcons.icons), - IconCategory("Toggle", ToggleIcons.icons), - ) - - /** - * Get all icons from all categories - */ - fun getAllIcons(): List = categories.flatMap { it.icons } - - /** - * Get an icon by its ID - */ - fun getIconById(id: String): ImageVector? = getAllIcons().find { it.id == id }?.icon - - /** - * Get the category name for a given icon ID - */ - fun getCategoryForIcon(iconId: String): String? { - categories.forEach { category -> - if (category.icons.any { it.id == iconId }) { - return category.name - } - } - return null - } - - /** - * Search icons by query (searches in both ID and label) - */ - fun searchIcons(query: String): List { - if (query.isBlank()) return getAllIcons() - - val lowercaseQuery = query.lowercase() - return getAllIcons().filter { - it.id.contains(lowercaseQuery) || - it.label.lowercase().contains(lowercaseQuery) - } - } - - /** - * Get icons by category name - */ - fun getIconsByCategory(categoryName: String): List = categories.find { it.name.equals(categoryName, ignoreCase = true) }?.icons - ?: emptyList() - - /** - * Get total number of icons in the library - */ - fun getTotalIconCount(): Int = categories.sumOf { it.icons.size } - - /** - * Get category names - */ - fun getCategoryNames(): List = categories.map { it.name } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/NavigationIcons.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/NavigationIcons.kt deleted file mode 100644 index 8d97d3a487..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/NavigationIcons.kt +++ /dev/null @@ -1,137 +0,0 @@ -package io.nekohasekai.sfa.compose.util.icons - -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.automirrored.filled.ArrowBackIos -import androidx.compose.material.icons.automirrored.filled.ArrowForward -import androidx.compose.material.icons.automirrored.filled.ArrowForwardIos -import androidx.compose.material.icons.automirrored.filled.ArrowRightAlt -import androidx.compose.material.icons.automirrored.filled.MenuBook -import androidx.compose.material.icons.automirrored.filled.MenuOpen -import androidx.compose.material.icons.filled.AppSettingsAlt -import androidx.compose.material.icons.filled.Apps -import androidx.compose.material.icons.filled.ArrowBackIosNew -import androidx.compose.material.icons.filled.ArrowDownward -import androidx.compose.material.icons.filled.ArrowDropDown -import androidx.compose.material.icons.filled.ArrowDropDownCircle -import androidx.compose.material.icons.filled.ArrowDropUp -import androidx.compose.material.icons.filled.ArrowLeft -import androidx.compose.material.icons.filled.ArrowRight -import androidx.compose.material.icons.filled.ArrowUpward -import androidx.compose.material.icons.filled.AssistantDirection -import androidx.compose.material.icons.filled.Campaign -import androidx.compose.material.icons.filled.Cancel -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.ChevronLeft -import androidx.compose.material.icons.filled.ChevronRight -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.DoubleArrow -import androidx.compose.material.icons.filled.East -import androidx.compose.material.icons.filled.ExpandCircleDown -import androidx.compose.material.icons.filled.ExpandLess -import androidx.compose.material.icons.filled.ExpandMore -import androidx.compose.material.icons.filled.FirstPage -import androidx.compose.material.icons.filled.Fullscreen -import androidx.compose.material.icons.filled.FullscreenExit -import androidx.compose.material.icons.filled.HomeWork -import androidx.compose.material.icons.filled.LastPage -import androidx.compose.material.icons.filled.LegendToggle -import androidx.compose.material.icons.filled.LiveTv -import androidx.compose.material.icons.filled.Menu -import androidx.compose.material.icons.filled.MoreHoriz -import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material.icons.filled.North -import androidx.compose.material.icons.filled.NorthEast -import androidx.compose.material.icons.filled.NorthWest -import androidx.compose.material.icons.filled.OfflineShare -import androidx.compose.material.icons.filled.Payments -import androidx.compose.material.icons.filled.PivotTableChart -import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material.icons.filled.South -import androidx.compose.material.icons.filled.SouthEast -import androidx.compose.material.icons.filled.SouthWest -import androidx.compose.material.icons.filled.SubdirectoryArrowLeft -import androidx.compose.material.icons.filled.SubdirectoryArrowRight -import androidx.compose.material.icons.filled.SwitchLeft -import androidx.compose.material.icons.filled.SwitchRight -import androidx.compose.material.icons.filled.UnfoldLess -import androidx.compose.material.icons.filled.UnfoldMore -import androidx.compose.material.icons.filled.WaterfallChart -import androidx.compose.material.icons.filled.West -import io.nekohasekai.sfa.compose.util.ProfileIcon - -/** - * Navigation category icons - App navigation and menus - * Based on Google's Material Design Icons taxonomy - */ -object NavigationIcons { - val icons = - listOf( - ProfileIcon("app_settings_alt", Icons.Filled.AppSettingsAlt, "App Settings"), - ProfileIcon("apps", Icons.Filled.Apps, "Apps"), - ProfileIcon("arrow_back", Icons.AutoMirrored.Filled.ArrowBack, "Arrow Back"), - ProfileIcon("arrow_back_ios", Icons.AutoMirrored.Filled.ArrowBackIos, "Back iOS"), - ProfileIcon("arrow_back_ios_new", Icons.Filled.ArrowBackIosNew, "Back iOS New"), - ProfileIcon("arrow_downward", Icons.Filled.ArrowDownward, "Arrow Down"), - ProfileIcon("arrow_drop_down", Icons.Filled.ArrowDropDown, "Drop Down"), - ProfileIcon("arrow_drop_down_circle", Icons.Filled.ArrowDropDownCircle, "Drop Down Circle"), - ProfileIcon("arrow_drop_up", Icons.Filled.ArrowDropUp, "Drop Up"), - ProfileIcon("arrow_forward", Icons.AutoMirrored.Filled.ArrowForward, "Arrow Forward"), - ProfileIcon("arrow_forward_ios", Icons.AutoMirrored.Filled.ArrowForwardIos, "Forward iOS"), - ProfileIcon("arrow_left", Icons.Filled.ArrowLeft, "Arrow Left"), - ProfileIcon("arrow_right", Icons.Filled.ArrowRight, "Arrow Right"), - ProfileIcon("arrow_right_alt", Icons.AutoMirrored.Filled.ArrowRightAlt, "Arrow Right Alt"), - ProfileIcon("arrow_upward", Icons.Filled.ArrowUpward, "Arrow Up"), - ProfileIcon("assistant_direction", Icons.Filled.AssistantDirection, "Assistant Direction"), - // ProfileIcon("assistant_navigation", Icons.Filled.AssistantNavigation, "Assistant Navigation"), - ProfileIcon("campaign", Icons.Filled.Campaign, "Campaign"), - ProfileIcon("cancel", Icons.Filled.Cancel, "Cancel"), - ProfileIcon("check", Icons.Filled.Check, "Check"), - ProfileIcon("chevron_left", Icons.Filled.ChevronLeft, "Chevron Left"), - ProfileIcon("chevron_right", Icons.Filled.ChevronRight, "Chevron Right"), - ProfileIcon("close", Icons.Filled.Close, "Close"), - ProfileIcon("double_arrow", Icons.Filled.DoubleArrow, "Double Arrow"), - ProfileIcon("east", Icons.Filled.East, "East"), - ProfileIcon("expand_circle_down", Icons.Filled.ExpandCircleDown, "Expand Circle Down"), - ProfileIcon("expand_less", Icons.Filled.ExpandLess, "Expand Less"), - ProfileIcon("expand_more", Icons.Filled.ExpandMore, "Expand More"), - ProfileIcon("first_page", Icons.Filled.FirstPage, "First Page"), - ProfileIcon("fullscreen", Icons.Filled.Fullscreen, "Fullscreen"), - ProfileIcon("fullscreen_exit", Icons.Filled.FullscreenExit, "Fullscreen Exit"), - ProfileIcon("home_work", Icons.Filled.HomeWork, "Home Work"), - ProfileIcon("last_page", Icons.Filled.LastPage, "Last Page"), - ProfileIcon("legend_toggle", Icons.Filled.LegendToggle, "Legend Toggle"), - ProfileIcon("live_tv", Icons.Filled.LiveTv, "Live TV"), - ProfileIcon("menu", Icons.Filled.Menu, "Menu"), - ProfileIcon("menu_book", Icons.AutoMirrored.Filled.MenuBook, "Menu Book"), - ProfileIcon("menu_open", Icons.AutoMirrored.Filled.MenuOpen, "Menu Open"), - ProfileIcon("more_horiz", Icons.Filled.MoreHoriz, "More Horizontal"), - ProfileIcon("more_vert", Icons.Filled.MoreVert, "More Vertical"), - ProfileIcon("north", Icons.Filled.North, "North"), - ProfileIcon("north_east", Icons.Filled.NorthEast, "North East"), - ProfileIcon("north_west", Icons.Filled.NorthWest, "North West"), - ProfileIcon("offline_share", Icons.Filled.OfflineShare, "Offline Share"), - ProfileIcon("payments", Icons.Filled.Payments, "Payments"), - ProfileIcon("pivot_table_chart", Icons.Filled.PivotTableChart, "Pivot Table"), - ProfileIcon("refresh", Icons.Filled.Refresh, "Refresh"), - ProfileIcon("south", Icons.Filled.South, "South"), - ProfileIcon("south_east", Icons.Filled.SouthEast, "South East"), - ProfileIcon("south_west", Icons.Filled.SouthWest, "South West"), - ProfileIcon( - "subdirectory_arrow_left", - Icons.Filled.SubdirectoryArrowLeft, - "Subdirectory Left", - ), - ProfileIcon( - "subdirectory_arrow_right", - Icons.Filled.SubdirectoryArrowRight, - "Subdirectory Right", - ), - ProfileIcon("switch_left", Icons.Filled.SwitchLeft, "Switch Left"), - ProfileIcon("switch_right", Icons.Filled.SwitchRight, "Switch Right"), - ProfileIcon("unfold_less", Icons.Filled.UnfoldLess, "Unfold Less"), - ProfileIcon("unfold_more", Icons.Filled.UnfoldMore, "Unfold More"), - ProfileIcon("waterfall_chart", Icons.Filled.WaterfallChart, "Waterfall Chart"), - ProfileIcon("west", Icons.Filled.West, "West"), - ) -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/NotificationIcons.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/NotificationIcons.kt deleted file mode 100644 index 864f6c4c6e..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/NotificationIcons.kt +++ /dev/null @@ -1,186 +0,0 @@ -package io.nekohasekai.sfa.compose.util.icons - -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.AccountTree -import androidx.compose.material.icons.filled.Adb -import androidx.compose.material.icons.filled.AirlineSeatFlat -import androidx.compose.material.icons.filled.AirlineSeatFlatAngled -import androidx.compose.material.icons.filled.AirlineSeatIndividualSuite -import androidx.compose.material.icons.filled.AirlineSeatLegroomExtra -import androidx.compose.material.icons.filled.AirlineSeatLegroomNormal -import androidx.compose.material.icons.filled.AirlineSeatLegroomReduced -import androidx.compose.material.icons.filled.AirlineSeatReclineExtra -import androidx.compose.material.icons.filled.AirlineSeatReclineNormal -import androidx.compose.material.icons.filled.BluetoothAudio -import androidx.compose.material.icons.filled.ConfirmationNumber -import androidx.compose.material.icons.filled.DirectionsOff -import androidx.compose.material.icons.filled.DiscFull -import androidx.compose.material.icons.filled.DoDisturb -import androidx.compose.material.icons.filled.DoDisturbAlt -import androidx.compose.material.icons.filled.DoDisturbOff -import androidx.compose.material.icons.filled.DoDisturbOn -import androidx.compose.material.icons.filled.DoNotDisturb -import androidx.compose.material.icons.filled.DoNotDisturbAlt -import androidx.compose.material.icons.filled.DoNotDisturbOff -import androidx.compose.material.icons.filled.DoNotDisturbOn -import androidx.compose.material.icons.filled.DriveEta -import androidx.compose.material.icons.filled.EnhancedEncryption -import androidx.compose.material.icons.filled.EventAvailable -import androidx.compose.material.icons.filled.EventBusy -import androidx.compose.material.icons.filled.EventNote -import androidx.compose.material.icons.filled.FolderSpecial -import androidx.compose.material.icons.filled.ImagesearchRoller -import androidx.compose.material.icons.filled.LiveTv -import androidx.compose.material.icons.filled.Mms -import androidx.compose.material.icons.filled.More -import androidx.compose.material.icons.filled.NetworkCheck -import androidx.compose.material.icons.filled.NetworkLocked -import androidx.compose.material.icons.filled.NoEncryption -import androidx.compose.material.icons.filled.NoEncryptionGmailerrorred -import androidx.compose.material.icons.filled.OndemandVideo -import androidx.compose.material.icons.filled.PersonalVideo -import androidx.compose.material.icons.filled.PhoneBluetoothSpeaker -import androidx.compose.material.icons.filled.PhoneCallback -import androidx.compose.material.icons.filled.PhoneForwarded -import androidx.compose.material.icons.filled.PhoneInTalk -import androidx.compose.material.icons.filled.PhoneLocked -import androidx.compose.material.icons.filled.PhoneMissed -import androidx.compose.material.icons.filled.PhonePaused -import androidx.compose.material.icons.filled.Power -import androidx.compose.material.icons.filled.PowerOff -import androidx.compose.material.icons.filled.PriorityHigh -import androidx.compose.material.icons.filled.RunningWithErrors -import androidx.compose.material.icons.filled.SdCardAlert -import androidx.compose.material.icons.filled.SimCardAlert -import androidx.compose.material.icons.filled.Sms -import androidx.compose.material.icons.filled.SmsFailed -import androidx.compose.material.icons.filled.SupportAgent -import androidx.compose.material.icons.filled.Sync -import androidx.compose.material.icons.filled.SyncDisabled -import androidx.compose.material.icons.filled.SyncLock -import androidx.compose.material.icons.filled.SyncProblem -import androidx.compose.material.icons.filled.SystemUpdate -import androidx.compose.material.icons.filled.TapAndPlay -import androidx.compose.material.icons.filled.TimeToLeave -import androidx.compose.material.icons.filled.TvOff -import androidx.compose.material.icons.filled.Vibration -import androidx.compose.material.icons.filled.VideoChat -import androidx.compose.material.icons.filled.VoiceChat -import androidx.compose.material.icons.filled.VpnLock -import androidx.compose.material.icons.filled.Wc -import androidx.compose.material.icons.filled.Wifi -import androidx.compose.material.icons.filled.WifiCalling -import androidx.compose.material.icons.filled.WifiOff -import io.nekohasekai.sfa.compose.util.ProfileIcon - -/** - * Notification category icons - Alerts and status updates - * Based on Google's Material Design Icons taxonomy - */ -object NotificationIcons { - val icons = - listOf( - ProfileIcon("account_tree", Icons.Filled.AccountTree, "Account Tree"), - ProfileIcon("adb", Icons.Filled.Adb, "ADB"), - ProfileIcon("airline_seat_flat", Icons.Filled.AirlineSeatFlat, "Seat Flat"), - ProfileIcon("airline_seat_flat_angled", Icons.Filled.AirlineSeatFlatAngled, "Seat Angled"), - ProfileIcon( - "airline_seat_individual_suite", - Icons.Filled.AirlineSeatIndividualSuite, - "Seat Suite", - ), - ProfileIcon( - "airline_seat_legroom_extra", - Icons.Filled.AirlineSeatLegroomExtra, - "Legroom Extra", - ), - ProfileIcon( - "airline_seat_legroom_normal", - Icons.Filled.AirlineSeatLegroomNormal, - "Legroom Normal", - ), - ProfileIcon( - "airline_seat_legroom_reduced", - Icons.Filled.AirlineSeatLegroomReduced, - "Legroom Reduced", - ), - ProfileIcon( - "airline_seat_recline_extra", - Icons.Filled.AirlineSeatReclineExtra, - "Recline Extra", - ), - ProfileIcon( - "airline_seat_recline_normal", - Icons.Filled.AirlineSeatReclineNormal, - "Recline Normal", - ), - ProfileIcon("bluetooth_audio", Icons.Filled.BluetoothAudio, "Bluetooth Audio"), - ProfileIcon("confirmation_number", Icons.Filled.ConfirmationNumber, "Confirmation Number"), - ProfileIcon("directions_off", Icons.Filled.DirectionsOff, "Directions Off"), - ProfileIcon("disc_full", Icons.Filled.DiscFull, "Disc Full"), - ProfileIcon("do_disturb", Icons.Filled.DoDisturb, "Do Disturb"), - ProfileIcon("do_disturb_alt", Icons.Filled.DoDisturbAlt, "Do Disturb Alt"), - ProfileIcon("do_disturb_off", Icons.Filled.DoDisturbOff, "Do Disturb Off"), - ProfileIcon("do_disturb_on", Icons.Filled.DoDisturbOn, "Do Disturb On"), - ProfileIcon("do_not_disturb", Icons.Filled.DoNotDisturb, "Do Not Disturb"), - ProfileIcon("do_not_disturb_alt", Icons.Filled.DoNotDisturbAlt, "DND Alt"), - ProfileIcon("do_not_disturb_off", Icons.Filled.DoNotDisturbOff, "DND Off"), - ProfileIcon("do_not_disturb_on", Icons.Filled.DoNotDisturbOn, "DND On"), - ProfileIcon("drive_eta", Icons.Filled.DriveEta, "Drive ETA"), - ProfileIcon("enhanced_encryption", Icons.Filled.EnhancedEncryption, "Enhanced Encryption"), - ProfileIcon("event_available", Icons.Filled.EventAvailable, "Event Available"), - ProfileIcon("event_busy", Icons.Filled.EventBusy, "Event Busy"), - ProfileIcon("event_note", Icons.Filled.EventNote, "Event Note"), - ProfileIcon("folder_special", Icons.Filled.FolderSpecial, "Folder Special"), - ProfileIcon("imagesearch_roller", Icons.Filled.ImagesearchRoller, "Image Search Roller"), - ProfileIcon("live_tv", Icons.Filled.LiveTv, "Live TV"), - ProfileIcon("mms", Icons.Filled.Mms, "MMS"), - ProfileIcon("more", Icons.Filled.More, "More"), - ProfileIcon("network_check", Icons.Filled.NetworkCheck, "Network Check"), - ProfileIcon("network_locked", Icons.Filled.NetworkLocked, "Network Locked"), - ProfileIcon("no_encryption", Icons.Filled.NoEncryption, "No Encryption"), - ProfileIcon( - "no_encryption_gmailerrorred", - Icons.Filled.NoEncryptionGmailerrorred, - "No Encryption Error", - ), - ProfileIcon("ondemand_video", Icons.Filled.OndemandVideo, "On Demand Video"), - ProfileIcon("personal_video", Icons.Filled.PersonalVideo, "Personal Video"), - ProfileIcon( - "phone_bluetooth_speaker", - Icons.Filled.PhoneBluetoothSpeaker, - "Phone Bluetooth", - ), - ProfileIcon("phone_callback", Icons.Filled.PhoneCallback, "Phone Callback"), - ProfileIcon("phone_forwarded", Icons.Filled.PhoneForwarded, "Phone Forwarded"), - ProfileIcon("phone_in_talk", Icons.Filled.PhoneInTalk, "Phone In Talk"), - ProfileIcon("phone_locked", Icons.Filled.PhoneLocked, "Phone Locked"), - ProfileIcon("phone_missed", Icons.Filled.PhoneMissed, "Phone Missed"), - ProfileIcon("phone_paused", Icons.Filled.PhonePaused, "Phone Paused"), - ProfileIcon("power", Icons.Filled.Power, "Power"), - ProfileIcon("power_off", Icons.Filled.PowerOff, "Power Off"), - ProfileIcon("priority_high", Icons.Filled.PriorityHigh, "Priority High"), - ProfileIcon("running_with_errors", Icons.Filled.RunningWithErrors, "Running With Errors"), - ProfileIcon("sd_card_alert", Icons.Filled.SdCardAlert, "SD Card Alert"), - ProfileIcon("sim_card_alert", Icons.Filled.SimCardAlert, "SIM Card Alert"), - ProfileIcon("sms", Icons.Filled.Sms, "SMS"), - ProfileIcon("sms_failed", Icons.Filled.SmsFailed, "SMS Failed"), - ProfileIcon("support_agent", Icons.Filled.SupportAgent, "Support Agent"), - ProfileIcon("sync", Icons.Filled.Sync, "Sync"), - ProfileIcon("sync_disabled", Icons.Filled.SyncDisabled, "Sync Disabled"), - ProfileIcon("sync_lock", Icons.Filled.SyncLock, "Sync Lock"), - ProfileIcon("sync_problem", Icons.Filled.SyncProblem, "Sync Problem"), - ProfileIcon("system_update", Icons.Filled.SystemUpdate, "System Update"), - ProfileIcon("tap_and_play", Icons.Filled.TapAndPlay, "Tap and Play"), - ProfileIcon("time_to_leave", Icons.Filled.TimeToLeave, "Time to Leave"), - ProfileIcon("tv_off", Icons.Filled.TvOff, "TV Off"), - ProfileIcon("vibration", Icons.Filled.Vibration, "Vibration"), - ProfileIcon("video_chat", Icons.Filled.VideoChat, "Video Chat"), - ProfileIcon("voice_chat", Icons.Filled.VoiceChat, "Voice Chat"), - ProfileIcon("vpn_lock", Icons.Filled.VpnLock, "VPN Lock"), - ProfileIcon("wc", Icons.Filled.Wc, "WC"), - ProfileIcon("wifi", Icons.Filled.Wifi, "WiFi"), - ProfileIcon("wifi_calling", Icons.Filled.WifiCalling, "WiFi Calling"), - ProfileIcon("wifi_off", Icons.Filled.WifiOff, "WiFi Off"), - ) -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/PlacesIcons.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/PlacesIcons.kt deleted file mode 100644 index 46502bb167..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/PlacesIcons.kt +++ /dev/null @@ -1,179 +0,0 @@ -package io.nekohasekai.sfa.compose.util.icons - -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.AcUnit -import androidx.compose.material.icons.filled.AirportShuttle -import androidx.compose.material.icons.filled.AllInclusive -import androidx.compose.material.icons.filled.Apartment -import androidx.compose.material.icons.filled.BabyChangingStation -import androidx.compose.material.icons.filled.Backpack -import androidx.compose.material.icons.filled.Balcony -import androidx.compose.material.icons.filled.Bathtub -import androidx.compose.material.icons.filled.BeachAccess -import androidx.compose.material.icons.filled.Bento -import androidx.compose.material.icons.filled.Bungalow -import androidx.compose.material.icons.filled.BusinessCenter -import androidx.compose.material.icons.filled.Cabin -import androidx.compose.material.icons.filled.Cake -import androidx.compose.material.icons.filled.Casino -import androidx.compose.material.icons.filled.Chalet -import androidx.compose.material.icons.filled.ChargingStation -import androidx.compose.material.icons.filled.Checkroom -import androidx.compose.material.icons.filled.ChildCare -import androidx.compose.material.icons.filled.ChildFriendly -import androidx.compose.material.icons.filled.CorporateFare -import androidx.compose.material.icons.filled.Cottage -import androidx.compose.material.icons.filled.Countertops -import androidx.compose.material.icons.filled.Crib -import androidx.compose.material.icons.filled.Desk -import androidx.compose.material.icons.filled.DoNotStep -import androidx.compose.material.icons.filled.DoNotTouch -import androidx.compose.material.icons.filled.Dry -import androidx.compose.material.icons.filled.Elevator -import androidx.compose.material.icons.filled.Escalator -import androidx.compose.material.icons.filled.EscalatorWarning -import androidx.compose.material.icons.filled.FamilyRestroom -import androidx.compose.material.icons.filled.Fence -import androidx.compose.material.icons.filled.FitnessCenter -import androidx.compose.material.icons.filled.FoodBank -import androidx.compose.material.icons.filled.Foundation -import androidx.compose.material.icons.filled.FreeBreakfast -import androidx.compose.material.icons.filled.Gite -import androidx.compose.material.icons.filled.GolfCourse -import androidx.compose.material.icons.filled.Grass -import androidx.compose.material.icons.filled.HolidayVillage -import androidx.compose.material.icons.filled.HotTub -import androidx.compose.material.icons.filled.House -import androidx.compose.material.icons.filled.HouseSiding -import androidx.compose.material.icons.filled.Houseboat -import androidx.compose.material.icons.filled.Iron -import androidx.compose.material.icons.filled.Kitchen -import androidx.compose.material.icons.filled.MeetingRoom -import androidx.compose.material.icons.filled.Microwave -import androidx.compose.material.icons.filled.NightShelter -import androidx.compose.material.icons.filled.NoBackpack -import androidx.compose.material.icons.filled.NoCell -import androidx.compose.material.icons.filled.NoDrinks -import androidx.compose.material.icons.filled.NoFlash -import androidx.compose.material.icons.filled.NoFood -import androidx.compose.material.icons.filled.NoMeetingRoom -import androidx.compose.material.icons.filled.NoPhotography -import androidx.compose.material.icons.filled.NoStroller -import androidx.compose.material.icons.filled.OtherHouses -import androidx.compose.material.icons.filled.Pool -import androidx.compose.material.icons.filled.RiceBowl -import androidx.compose.material.icons.filled.Roofing -import androidx.compose.material.icons.filled.RoomPreferences -import androidx.compose.material.icons.filled.RoomService -import androidx.compose.material.icons.filled.RvHookup -import androidx.compose.material.icons.filled.Shower -import androidx.compose.material.icons.filled.SmokeFree -import androidx.compose.material.icons.filled.SmokingRooms -import androidx.compose.material.icons.filled.Soap -import androidx.compose.material.icons.filled.Spa -import androidx.compose.material.icons.filled.SportsBar -import androidx.compose.material.icons.filled.Stairs -import androidx.compose.material.icons.filled.Storefront -import androidx.compose.material.icons.filled.Stroller -import androidx.compose.material.icons.filled.Tapas -import androidx.compose.material.icons.filled.Tty -import androidx.compose.material.icons.filled.Umbrella -import androidx.compose.material.icons.filled.VapingRooms -import androidx.compose.material.icons.filled.Villa -import androidx.compose.material.icons.filled.Wash -import androidx.compose.material.icons.filled.WaterDamage -import androidx.compose.material.icons.filled.WheelchairPickup -import io.nekohasekai.sfa.compose.util.ProfileIcon - -/** - * Places category icons - Locations and venues - * Based on Google's Material Design Icons taxonomy - */ -object PlacesIcons { - val icons = - listOf( - ProfileIcon("ac_unit", Icons.Filled.AcUnit, "AC Unit"), - ProfileIcon("airport_shuttle", Icons.Filled.AirportShuttle, "Airport Shuttle"), - ProfileIcon("all_inclusive", Icons.Filled.AllInclusive, "All Inclusive"), - ProfileIcon("apartment", Icons.Filled.Apartment, "Apartment"), - ProfileIcon("baby_changing_station", Icons.Filled.BabyChangingStation, "Baby Station"), - ProfileIcon("backpack", Icons.Filled.Backpack, "Backpack"), - ProfileIcon("balcony", Icons.Filled.Balcony, "Balcony"), - ProfileIcon("bathtub", Icons.Filled.Bathtub, "Bathtub"), - ProfileIcon("beach_access", Icons.Filled.BeachAccess, "Beach Access"), - ProfileIcon("bento", Icons.Filled.Bento, "Bento"), - ProfileIcon("bungalow", Icons.Filled.Bungalow, "Bungalow"), - ProfileIcon("business_center", Icons.Filled.BusinessCenter, "Business Center"), - ProfileIcon("cabin", Icons.Filled.Cabin, "Cabin"), - ProfileIcon("cake", Icons.Filled.Cake, "Cake"), - ProfileIcon("casino", Icons.Filled.Casino, "Casino"), - ProfileIcon("chalet", Icons.Filled.Chalet, "Chalet"), - ProfileIcon("charging_station", Icons.Filled.ChargingStation, "Charging Station"), - ProfileIcon("checkroom", Icons.Filled.Checkroom, "Checkroom"), - ProfileIcon("child_care", Icons.Filled.ChildCare, "Child Care"), - ProfileIcon("child_friendly", Icons.Filled.ChildFriendly, "Child Friendly"), - ProfileIcon("corporate_fare", Icons.Filled.CorporateFare, "Corporate Fare"), - ProfileIcon("cottage", Icons.Filled.Cottage, "Cottage"), - ProfileIcon("countertops", Icons.Filled.Countertops, "Countertops"), - ProfileIcon("crib", Icons.Filled.Crib, "Crib"), - ProfileIcon("desk", Icons.Filled.Desk, "Desk"), - ProfileIcon("do_not_step", Icons.Filled.DoNotStep, "Do Not Step"), - ProfileIcon("do_not_touch", Icons.Filled.DoNotTouch, "Do Not Touch"), - ProfileIcon("dry", Icons.Filled.Dry, "Dry"), - ProfileIcon("elevator", Icons.Filled.Elevator, "Elevator"), - ProfileIcon("escalator", Icons.Filled.Escalator, "Escalator"), - ProfileIcon("escalator_warning", Icons.Filled.EscalatorWarning, "Escalator Warning"), - ProfileIcon("family_restroom", Icons.Filled.FamilyRestroom, "Family Restroom"), - ProfileIcon("fence", Icons.Filled.Fence, "Fence"), - // ProfileIcon("fire_hydrant", Icons.Filled.FireHydrant, "Fire Hydrant"), - ProfileIcon("fitness_center", Icons.Filled.FitnessCenter, "Fitness Center"), - ProfileIcon("food_bank", Icons.Filled.FoodBank, "Food Bank"), - ProfileIcon("foundation", Icons.Filled.Foundation, "Foundation"), - ProfileIcon("free_breakfast", Icons.Filled.FreeBreakfast, "Free Breakfast"), - ProfileIcon("gite", Icons.Filled.Gite, "Gite"), - ProfileIcon("golf_course", Icons.Filled.GolfCourse, "Golf Course"), - ProfileIcon("grass", Icons.Filled.Grass, "Grass"), - ProfileIcon("holiday_village", Icons.Filled.HolidayVillage, "Holiday Village"), - ProfileIcon("hot_tub", Icons.Filled.HotTub, "Hot Tub"), - ProfileIcon("house", Icons.Filled.House, "House"), - ProfileIcon("house_siding", Icons.Filled.HouseSiding, "House Siding"), - ProfileIcon("houseboat", Icons.Filled.Houseboat, "Houseboat"), - ProfileIcon("iron", Icons.Filled.Iron, "Iron"), - ProfileIcon("kitchen", Icons.Filled.Kitchen, "Kitchen"), - ProfileIcon("meeting_room", Icons.Filled.MeetingRoom, "Meeting Room"), - ProfileIcon("microwave", Icons.Filled.Microwave, "Microwave"), - ProfileIcon("night_shelter", Icons.Filled.NightShelter, "Night Shelter"), - ProfileIcon("no_backpack", Icons.Filled.NoBackpack, "No Backpack"), - ProfileIcon("no_cell", Icons.Filled.NoCell, "No Cell"), - ProfileIcon("no_drinks", Icons.Filled.NoDrinks, "No Drinks"), - ProfileIcon("no_flash", Icons.Filled.NoFlash, "No Flash"), - ProfileIcon("no_food", Icons.Filled.NoFood, "No Food"), - ProfileIcon("no_meeting_room", Icons.Filled.NoMeetingRoom, "No Meeting Room"), - ProfileIcon("no_photography", Icons.Filled.NoPhotography, "No Photography"), - ProfileIcon("no_stroller", Icons.Filled.NoStroller, "No Stroller"), - ProfileIcon("other_houses", Icons.Filled.OtherHouses, "Other Houses"), - ProfileIcon("pool", Icons.Filled.Pool, "Pool"), - ProfileIcon("rice_bowl", Icons.Filled.RiceBowl, "Rice Bowl"), - ProfileIcon("roofing", Icons.Filled.Roofing, "Roofing"), - ProfileIcon("room_preferences", Icons.Filled.RoomPreferences, "Room Preferences"), - ProfileIcon("room_service", Icons.Filled.RoomService, "Room Service"), - ProfileIcon("rv_hookup", Icons.Filled.RvHookup, "RV Hookup"), - ProfileIcon("shower", Icons.Filled.Shower, "Shower"), - ProfileIcon("smoke_free", Icons.Filled.SmokeFree, "Smoke Free"), - ProfileIcon("smoking_rooms", Icons.Filled.SmokingRooms, "Smoking Rooms"), - ProfileIcon("soap", Icons.Filled.Soap, "Soap"), - ProfileIcon("spa", Icons.Filled.Spa, "Spa"), - ProfileIcon("sports_bar", Icons.Filled.SportsBar, "Sports Bar"), - ProfileIcon("stairs", Icons.Filled.Stairs, "Stairs"), - ProfileIcon("storefront", Icons.Filled.Storefront, "Storefront"), - ProfileIcon("stroller", Icons.Filled.Stroller, "Stroller"), - ProfileIcon("tapas", Icons.Filled.Tapas, "Tapas"), - ProfileIcon("tty", Icons.Filled.Tty, "TTY"), - ProfileIcon("umbrella", Icons.Filled.Umbrella, "Umbrella"), - ProfileIcon("vaping_rooms", Icons.Filled.VapingRooms, "Vaping Rooms"), - ProfileIcon("villa", Icons.Filled.Villa, "Villa"), - ProfileIcon("wash", Icons.Filled.Wash, "Wash"), - ProfileIcon("water_damage", Icons.Filled.WaterDamage, "Water Damage"), - ProfileIcon("wheelchair_pickup", Icons.Filled.WheelchairPickup, "Wheelchair Pickup"), - ) -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/SocialIcons.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/SocialIcons.kt deleted file mode 100644 index 160dca9d2a..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/SocialIcons.kt +++ /dev/null @@ -1,422 +0,0 @@ -package io.nekohasekai.sfa.compose.util.icons - -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.AddModerator -import androidx.compose.material.icons.filled.AddReaction -import androidx.compose.material.icons.filled.Architecture -import androidx.compose.material.icons.filled.AssistWalker -import androidx.compose.material.icons.filled.BackHand -import androidx.compose.material.icons.filled.Blind -import androidx.compose.material.icons.filled.Boy -import androidx.compose.material.icons.filled.Cake -import androidx.compose.material.icons.filled.CatchingPokemon -import androidx.compose.material.icons.filled.CleanHands -import androidx.compose.material.icons.filled.Co2 -import androidx.compose.material.icons.filled.Compost -import androidx.compose.material.icons.filled.ConnectWithoutContact -import androidx.compose.material.icons.filled.Construction -import androidx.compose.material.icons.filled.Cookie -import androidx.compose.material.icons.filled.Coronavirus -import androidx.compose.material.icons.filled.CrueltyFree -import androidx.compose.material.icons.filled.Cyclone -import androidx.compose.material.icons.filled.Deck -import androidx.compose.material.icons.filled.Diversity1 -import androidx.compose.material.icons.filled.Diversity2 -import androidx.compose.material.icons.filled.Diversity3 -import androidx.compose.material.icons.filled.Domain -import androidx.compose.material.icons.filled.DomainAdd -import androidx.compose.material.icons.filled.DownhillSkiing -import androidx.compose.material.icons.filled.EditNotifications -import androidx.compose.material.icons.filled.Elderly -import androidx.compose.material.icons.filled.ElderlyWoman -import androidx.compose.material.icons.filled.EmojiEmotions -import androidx.compose.material.icons.filled.EmojiEvents -import androidx.compose.material.icons.filled.EmojiFlags -import androidx.compose.material.icons.filled.EmojiFoodBeverage -import androidx.compose.material.icons.filled.EmojiNature -import androidx.compose.material.icons.filled.EmojiObjects -import androidx.compose.material.icons.filled.EmojiPeople -import androidx.compose.material.icons.filled.EmojiSymbols -import androidx.compose.material.icons.filled.EmojiTransportation -import androidx.compose.material.icons.filled.Engineering -import androidx.compose.material.icons.filled.Face -import androidx.compose.material.icons.filled.Face2 -import androidx.compose.material.icons.filled.Face3 -import androidx.compose.material.icons.filled.Face4 -import androidx.compose.material.icons.filled.Face5 -import androidx.compose.material.icons.filled.Face6 -import androidx.compose.material.icons.filled.Facebook -import androidx.compose.material.icons.filled.Female -import androidx.compose.material.icons.filled.Fireplace -import androidx.compose.material.icons.filled.Fitbit -import androidx.compose.material.icons.filled.Flood -import androidx.compose.material.icons.filled.FollowTheSigns -import androidx.compose.material.icons.filled.FrontHand -import androidx.compose.material.icons.filled.Girl -import androidx.compose.material.icons.filled.Group -import androidx.compose.material.icons.filled.GroupAdd -import androidx.compose.material.icons.filled.GroupOff -import androidx.compose.material.icons.filled.GroupRemove -import androidx.compose.material.icons.filled.Groups -import androidx.compose.material.icons.filled.Groups2 -import androidx.compose.material.icons.filled.Groups3 -import androidx.compose.material.icons.filled.Handshake -import androidx.compose.material.icons.filled.HealthAndSafety -import androidx.compose.material.icons.filled.HeartBroken -import androidx.compose.material.icons.filled.Hiking -import androidx.compose.material.icons.filled.HistoryEdu -import androidx.compose.material.icons.filled.Hive -import androidx.compose.material.icons.filled.IceSkating -import androidx.compose.material.icons.filled.Interests -import androidx.compose.material.icons.filled.IosShare -import androidx.compose.material.icons.filled.Kayaking -import androidx.compose.material.icons.filled.KingBed -import androidx.compose.material.icons.filled.Kitesurfing -import androidx.compose.material.icons.filled.Landslide -import androidx.compose.material.icons.filled.LocationCity -import androidx.compose.material.icons.filled.Luggage -import androidx.compose.material.icons.filled.Male -import androidx.compose.material.icons.filled.Man -import androidx.compose.material.icons.filled.Man2 -import androidx.compose.material.icons.filled.Man3 -import androidx.compose.material.icons.filled.Man4 -import androidx.compose.material.icons.filled.Masks -import androidx.compose.material.icons.filled.MilitaryTech -import androidx.compose.material.icons.filled.Mood -import androidx.compose.material.icons.filled.MoodBad -import androidx.compose.material.icons.filled.NightsStay -import androidx.compose.material.icons.filled.NoAdultContent -import androidx.compose.material.icons.filled.NoLuggage -import androidx.compose.material.icons.filled.NordicWalking -import androidx.compose.material.icons.filled.Notifications -import androidx.compose.material.icons.filled.NotificationsActive -import androidx.compose.material.icons.filled.NotificationsNone -import androidx.compose.material.icons.filled.NotificationsOff -import androidx.compose.material.icons.filled.NotificationsPaused -import androidx.compose.material.icons.filled.OutdoorGrill -import androidx.compose.material.icons.filled.Pages -import androidx.compose.material.icons.filled.Paragliding -import androidx.compose.material.icons.filled.PartyMode -import androidx.compose.material.icons.filled.People -import androidx.compose.material.icons.filled.PeopleAlt -import androidx.compose.material.icons.filled.PeopleOutline -import androidx.compose.material.icons.filled.Person -import androidx.compose.material.icons.filled.Person2 -import androidx.compose.material.icons.filled.Person3 -import androidx.compose.material.icons.filled.Person4 -import androidx.compose.material.icons.filled.PersonAdd -import androidx.compose.material.icons.filled.PersonAddAlt -import androidx.compose.material.icons.filled.PersonAddAlt1 -import androidx.compose.material.icons.filled.PersonOff -import androidx.compose.material.icons.filled.PersonOutline -import androidx.compose.material.icons.filled.PersonRemove -import androidx.compose.material.icons.filled.PersonRemoveAlt1 -import androidx.compose.material.icons.filled.PersonalInjury -import androidx.compose.material.icons.filled.Piano -import androidx.compose.material.icons.filled.PianoOff -import androidx.compose.material.icons.filled.Pix -import androidx.compose.material.icons.filled.PlusOne -import androidx.compose.material.icons.filled.Poll -import androidx.compose.material.icons.filled.PrecisionManufacturing -import androidx.compose.material.icons.filled.Psychology -import androidx.compose.material.icons.filled.PsychologyAlt -import androidx.compose.material.icons.filled.Public -import androidx.compose.material.icons.filled.PublicOff -import androidx.compose.material.icons.filled.RealEstateAgent -import androidx.compose.material.icons.filled.Recommend -import androidx.compose.material.icons.filled.Recycling -import androidx.compose.material.icons.filled.ReduceCapacity -import androidx.compose.material.icons.filled.RemoveModerator -import androidx.compose.material.icons.filled.RollerSkating -import androidx.compose.material.icons.filled.SafetyDivider -import androidx.compose.material.icons.filled.Sanitizer -import androidx.compose.material.icons.filled.Scale -import androidx.compose.material.icons.filled.School -import androidx.compose.material.icons.filled.Science -import androidx.compose.material.icons.filled.Scoreboard -import androidx.compose.material.icons.filled.ScubaDiving -import androidx.compose.material.icons.filled.SelfImprovement -import androidx.compose.material.icons.filled.SentimentDissatisfied -import androidx.compose.material.icons.filled.SentimentNeutral -import androidx.compose.material.icons.filled.SentimentSatisfied -import androidx.compose.material.icons.filled.SentimentSatisfiedAlt -import androidx.compose.material.icons.filled.SentimentVeryDissatisfied -import androidx.compose.material.icons.filled.SentimentVerySatisfied -import androidx.compose.material.icons.filled.SevereCold -import androidx.compose.material.icons.filled.Share -import androidx.compose.material.icons.filled.Sick -import androidx.compose.material.icons.filled.SignLanguage -import androidx.compose.material.icons.filled.SingleBed -import androidx.compose.material.icons.filled.Skateboarding -import androidx.compose.material.icons.filled.Sledding -import androidx.compose.material.icons.filled.Snowboarding -import androidx.compose.material.icons.filled.Snowshoeing -import androidx.compose.material.icons.filled.SocialDistance -import androidx.compose.material.icons.filled.SouthAmerica -import androidx.compose.material.icons.filled.Sports -import androidx.compose.material.icons.filled.SportsBaseball -import androidx.compose.material.icons.filled.SportsBasketball -import androidx.compose.material.icons.filled.SportsCricket -import androidx.compose.material.icons.filled.SportsEsports -import androidx.compose.material.icons.filled.SportsFootball -import androidx.compose.material.icons.filled.SportsGolf -import androidx.compose.material.icons.filled.SportsGymnastics -import androidx.compose.material.icons.filled.SportsHandball -import androidx.compose.material.icons.filled.SportsHockey -import androidx.compose.material.icons.filled.SportsKabaddi -import androidx.compose.material.icons.filled.SportsMartialArts -import androidx.compose.material.icons.filled.SportsMma -import androidx.compose.material.icons.filled.SportsMotorsports -import androidx.compose.material.icons.filled.SportsRugby -import androidx.compose.material.icons.filled.SportsSoccer -import androidx.compose.material.icons.filled.SportsTennis -import androidx.compose.material.icons.filled.SportsVolleyball -import androidx.compose.material.icons.filled.Surfing -import androidx.compose.material.icons.filled.SwitchAccount -import androidx.compose.material.icons.filled.ThumbDownAlt -import androidx.compose.material.icons.filled.ThumbUpAlt -import androidx.compose.material.icons.filled.Thunderstorm -import androidx.compose.material.icons.filled.Tornado -import androidx.compose.material.icons.filled.Transgender -import androidx.compose.material.icons.filled.TravelExplore -import androidx.compose.material.icons.filled.Tsunami -import androidx.compose.material.icons.filled.Vaccines -import androidx.compose.material.icons.filled.Volcano -import androidx.compose.material.icons.filled.Wallet -import androidx.compose.material.icons.filled.WaterDrop -import androidx.compose.material.icons.filled.WavingHand -import androidx.compose.material.icons.filled.Whatshot -import androidx.compose.material.icons.filled.Woman -import androidx.compose.material.icons.filled.Woman2 -import androidx.compose.material.icons.filled.WorkspacePremium -import androidx.compose.material.icons.filled.Workspaces -import io.nekohasekai.sfa.compose.util.ProfileIcon - -/** - * Social category icons - Social media and sharing - * Based on Google's Material Design Icons taxonomy - */ -object SocialIcons { - val icons = - listOf( - // ProfileIcon("6_ft_apart", Icons.Filled.SixFtApart, "6 Ft Apart"), - ProfileIcon("add_moderator", Icons.Filled.AddModerator, "Add Moderator"), - ProfileIcon("add_reaction", Icons.Filled.AddReaction, "Add Reaction"), - ProfileIcon("architecture", Icons.Filled.Architecture, "Architecture"), - ProfileIcon("assist_walker", Icons.Filled.AssistWalker, "Assist Walker"), - ProfileIcon("back_hand", Icons.Filled.BackHand, "Back Hand"), - ProfileIcon("blind", Icons.Filled.Blind, "Blind"), - ProfileIcon("boy", Icons.Filled.Boy, "Boy"), - ProfileIcon("cake", Icons.Filled.Cake, "Cake"), - ProfileIcon("catching_pokemon", Icons.Filled.CatchingPokemon, "Catching Pokemon"), - ProfileIcon("clean_hands", Icons.Filled.CleanHands, "Clean Hands"), - ProfileIcon("co2", Icons.Filled.Co2, "CO2"), - ProfileIcon("compost", Icons.Filled.Compost, "Compost"), - ProfileIcon( - "connect_without_contact", - Icons.Filled.ConnectWithoutContact, - "Connect Without Contact", - ), - ProfileIcon("construction", Icons.Filled.Construction, "Construction"), - ProfileIcon("cookie", Icons.Filled.Cookie, "Cookie"), - ProfileIcon("coronavirus", Icons.Filled.Coronavirus, "Coronavirus"), - ProfileIcon("cruelty_free", Icons.Filled.CrueltyFree, "Cruelty Free"), - ProfileIcon("cyclone", Icons.Filled.Cyclone, "Cyclone"), - ProfileIcon("deck", Icons.Filled.Deck, "Deck"), - ProfileIcon("diversity_1", Icons.Filled.Diversity1, "Diversity 1"), - ProfileIcon("diversity_2", Icons.Filled.Diversity2, "Diversity 2"), - ProfileIcon("diversity_3", Icons.Filled.Diversity3, "Diversity 3"), - ProfileIcon("domain", Icons.Filled.Domain, "Domain"), - ProfileIcon("domain_add", Icons.Filled.DomainAdd, "Domain Add"), - ProfileIcon("downhill_skiing", Icons.Filled.DownhillSkiing, "Downhill Skiing"), - ProfileIcon("edit_notifications", Icons.Filled.EditNotifications, "Edit Notifications"), - ProfileIcon("elderly", Icons.Filled.Elderly, "Elderly"), - ProfileIcon("elderly_woman", Icons.Filled.ElderlyWoman, "Elderly Woman"), - ProfileIcon("emoji_emotions", Icons.Filled.EmojiEmotions, "Emoji Emotions"), - ProfileIcon("emoji_events", Icons.Filled.EmojiEvents, "Emoji Events"), - ProfileIcon("emoji_flags", Icons.Filled.EmojiFlags, "Emoji Flags"), - ProfileIcon("emoji_food_beverage", Icons.Filled.EmojiFoodBeverage, "Food Beverage"), - ProfileIcon("emoji_nature", Icons.Filled.EmojiNature, "Emoji Nature"), - ProfileIcon("emoji_objects", Icons.Filled.EmojiObjects, "Emoji Objects"), - ProfileIcon("emoji_people", Icons.Filled.EmojiPeople, "Emoji People"), - ProfileIcon("emoji_symbols", Icons.Filled.EmojiSymbols, "Emoji Symbols"), - ProfileIcon( - "emoji_transportation", - Icons.Filled.EmojiTransportation, - "Emoji Transportation", - ), - ProfileIcon("engineering", Icons.Filled.Engineering, "Engineering"), - ProfileIcon("face", Icons.Filled.Face, "Face"), - ProfileIcon("face_2", Icons.Filled.Face2, "Face 2"), - ProfileIcon("face_3", Icons.Filled.Face3, "Face 3"), - ProfileIcon("face_4", Icons.Filled.Face4, "Face 4"), - ProfileIcon("face_5", Icons.Filled.Face5, "Face 5"), - ProfileIcon("face_6", Icons.Filled.Face6, "Face 6"), - ProfileIcon("facebook", Icons.Filled.Facebook, "Facebook"), - ProfileIcon("female", Icons.Filled.Female, "Female"), - ProfileIcon("fireplace", Icons.Filled.Fireplace, "Fireplace"), - ProfileIcon("fitbit", Icons.Filled.Fitbit, "Fitbit"), - ProfileIcon("flood", Icons.Filled.Flood, "Flood"), - ProfileIcon("follow_the_signs", Icons.Filled.FollowTheSigns, "Follow Signs"), - ProfileIcon("front_hand", Icons.Filled.FrontHand, "Front Hand"), - ProfileIcon("girl", Icons.Filled.Girl, "Girl"), - ProfileIcon("group", Icons.Filled.Group, "Group"), - ProfileIcon("group_add", Icons.Filled.GroupAdd, "Group Add"), - ProfileIcon("group_off", Icons.Filled.GroupOff, "Group Off"), - ProfileIcon("group_remove", Icons.Filled.GroupRemove, "Group Remove"), - ProfileIcon("groups", Icons.Filled.Groups, "Groups"), - ProfileIcon("groups_2", Icons.Filled.Groups2, "Groups 2"), - ProfileIcon("groups_3", Icons.Filled.Groups3, "Groups 3"), - ProfileIcon("handshake", Icons.Filled.Handshake, "Handshake"), - ProfileIcon("health_and_safety", Icons.Filled.HealthAndSafety, "Health Safety"), - ProfileIcon("heart_broken", Icons.Filled.HeartBroken, "Heart Broken"), - ProfileIcon("hiking", Icons.Filled.Hiking, "Hiking"), - ProfileIcon("history_edu", Icons.Filled.HistoryEdu, "History Edu"), - ProfileIcon("hive", Icons.Filled.Hive, "Hive"), - ProfileIcon("ice_skating", Icons.Filled.IceSkating, "Ice Skating"), - ProfileIcon("interests", Icons.Filled.Interests, "Interests"), - ProfileIcon("ios_share", Icons.Filled.IosShare, "iOS Share"), - ProfileIcon("kayaking", Icons.Filled.Kayaking, "Kayaking"), - ProfileIcon("king_bed", Icons.Filled.KingBed, "King Bed"), - ProfileIcon("kitesurfing", Icons.Filled.Kitesurfing, "Kitesurfing"), - ProfileIcon("landslide", Icons.Filled.Landslide, "Landslide"), - ProfileIcon("location_city", Icons.Filled.LocationCity, "Location City"), - ProfileIcon("luggage", Icons.Filled.Luggage, "Luggage"), - ProfileIcon("male", Icons.Filled.Male, "Male"), - ProfileIcon("man", Icons.Filled.Man, "Man"), - ProfileIcon("man_2", Icons.Filled.Man2, "Man 2"), - ProfileIcon("man_3", Icons.Filled.Man3, "Man 3"), - ProfileIcon("man_4", Icons.Filled.Man4, "Man 4"), - ProfileIcon("masks", Icons.Filled.Masks, "Masks"), - ProfileIcon("military_tech", Icons.Filled.MilitaryTech, "Military Tech"), - ProfileIcon("mood", Icons.Filled.Mood, "Mood"), - ProfileIcon("mood_bad", Icons.Filled.MoodBad, "Mood Bad"), - ProfileIcon("nights_stay", Icons.Filled.NightsStay, "Nights Stay"), - ProfileIcon("no_adult_content", Icons.Filled.NoAdultContent, "No Adult Content"), - ProfileIcon("no_luggage", Icons.Filled.NoLuggage, "No Luggage"), - ProfileIcon("nordic_walking", Icons.Filled.NordicWalking, "Nordic Walking"), - ProfileIcon("notifications", Icons.Filled.Notifications, "Notifications"), - ProfileIcon( - "notifications_active", - Icons.Filled.NotificationsActive, - "Notifications Active", - ), - ProfileIcon("notifications_none", Icons.Filled.NotificationsNone, "Notifications None"), - ProfileIcon("notifications_off", Icons.Filled.NotificationsOff, "Notifications Off"), - ProfileIcon( - "notifications_paused", - Icons.Filled.NotificationsPaused, - "Notifications Paused", - ), - ProfileIcon("outdoor_grill", Icons.Filled.OutdoorGrill, "Outdoor Grill"), - ProfileIcon("pages", Icons.Filled.Pages, "Pages"), - ProfileIcon("paragliding", Icons.Filled.Paragliding, "Paragliding"), - ProfileIcon("party_mode", Icons.Filled.PartyMode, "Party Mode"), - ProfileIcon("people", Icons.Filled.People, "People"), - ProfileIcon("people_alt", Icons.Filled.PeopleAlt, "People Alt"), - ProfileIcon("people_outline", Icons.Filled.PeopleOutline, "People Outline"), - ProfileIcon("person", Icons.Filled.Person, "Person"), - ProfileIcon("person_2", Icons.Filled.Person2, "Person 2"), - ProfileIcon("person_3", Icons.Filled.Person3, "Person 3"), - ProfileIcon("person_4", Icons.Filled.Person4, "Person 4"), - ProfileIcon("person_add", Icons.Filled.PersonAdd, "Person Add"), - ProfileIcon("person_add_alt", Icons.Filled.PersonAddAlt, "Person Add Alt"), - ProfileIcon("person_add_alt_1", Icons.Filled.PersonAddAlt1, "Person Add Alt 1"), - ProfileIcon("person_off", Icons.Filled.PersonOff, "Person Off"), - ProfileIcon("person_outline", Icons.Filled.PersonOutline, "Person Outline"), - ProfileIcon("person_remove", Icons.Filled.PersonRemove, "Person Remove"), - ProfileIcon("person_remove_alt_1", Icons.Filled.PersonRemoveAlt1, "Person Remove Alt"), - ProfileIcon("personal_injury", Icons.Filled.PersonalInjury, "Personal Injury"), - ProfileIcon("piano", Icons.Filled.Piano, "Piano"), - ProfileIcon("piano_off", Icons.Filled.PianoOff, "Piano Off"), - ProfileIcon("pix", Icons.Filled.Pix, "Pix"), - ProfileIcon("plus_one", Icons.Filled.PlusOne, "Plus One"), - ProfileIcon("poll", Icons.Filled.Poll, "Poll"), - ProfileIcon( - "precision_manufacturing", - Icons.Filled.PrecisionManufacturing, - "Precision Manufacturing", - ), - ProfileIcon("psychology", Icons.Filled.Psychology, "Psychology"), - ProfileIcon("psychology_alt", Icons.Filled.PsychologyAlt, "Psychology Alt"), - ProfileIcon("public", Icons.Filled.Public, "Public"), - ProfileIcon("public_off", Icons.Filled.PublicOff, "Public Off"), - ProfileIcon("real_estate_agent", Icons.Filled.RealEstateAgent, "Real Estate Agent"), - ProfileIcon("recommend", Icons.Filled.Recommend, "Recommend"), - ProfileIcon("recycling", Icons.Filled.Recycling, "Recycling"), - ProfileIcon("reduce_capacity", Icons.Filled.ReduceCapacity, "Reduce Capacity"), - ProfileIcon("remove_moderator", Icons.Filled.RemoveModerator, "Remove Moderator"), - ProfileIcon("roller_skating", Icons.Filled.RollerSkating, "Roller Skating"), - ProfileIcon("safety_divider", Icons.Filled.SafetyDivider, "Safety Divider"), - ProfileIcon("sanitizer", Icons.Filled.Sanitizer, "Sanitizer"), - ProfileIcon("scale", Icons.Filled.Scale, "Scale"), - ProfileIcon("school", Icons.Filled.School, "School"), - ProfileIcon("science", Icons.Filled.Science, "Science"), - ProfileIcon("scoreboard", Icons.Filled.Scoreboard, "Scoreboard"), - ProfileIcon("scuba_diving", Icons.Filled.ScubaDiving, "Scuba Diving"), - ProfileIcon("self_improvement", Icons.Filled.SelfImprovement, "Self Improvement"), - ProfileIcon("sentiment_dissatisfied", Icons.Filled.SentimentDissatisfied, "Dissatisfied"), - ProfileIcon("sentiment_neutral", Icons.Filled.SentimentNeutral, "Neutral"), - ProfileIcon("sentiment_satisfied", Icons.Filled.SentimentSatisfied, "Satisfied"), - ProfileIcon("sentiment_satisfied_alt", Icons.Filled.SentimentSatisfiedAlt, "Satisfied Alt"), - ProfileIcon( - "sentiment_very_dissatisfied", - Icons.Filled.SentimentVeryDissatisfied, - "Very Dissatisfied", - ), - ProfileIcon( - "sentiment_very_satisfied", - Icons.Filled.SentimentVerySatisfied, - "Very Satisfied", - ), - ProfileIcon("severe_cold", Icons.Filled.SevereCold, "Severe Cold"), - ProfileIcon("share", Icons.Filled.Share, "Share"), - ProfileIcon("sick", Icons.Filled.Sick, "Sick"), - ProfileIcon("sign_language", Icons.Filled.SignLanguage, "Sign Language"), - ProfileIcon("single_bed", Icons.Filled.SingleBed, "Single Bed"), - ProfileIcon("skateboarding", Icons.Filled.Skateboarding, "Skateboarding"), - ProfileIcon("sledding", Icons.Filled.Sledding, "Sledding"), - ProfileIcon("snowboarding", Icons.Filled.Snowboarding, "Snowboarding"), - ProfileIcon("snowshoeing", Icons.Filled.Snowshoeing, "Snowshoeing"), - ProfileIcon("social_distance", Icons.Filled.SocialDistance, "Social Distance"), - ProfileIcon("south_america", Icons.Filled.SouthAmerica, "South America"), - ProfileIcon("sports", Icons.Filled.Sports, "Sports"), - ProfileIcon("sports_baseball", Icons.Filled.SportsBaseball, "Baseball"), - ProfileIcon("sports_basketball", Icons.Filled.SportsBasketball, "Basketball"), - ProfileIcon("sports_cricket", Icons.Filled.SportsCricket, "Cricket"), - ProfileIcon("sports_esports", Icons.Filled.SportsEsports, "Esports"), - ProfileIcon("sports_football", Icons.Filled.SportsFootball, "Football"), - ProfileIcon("sports_golf", Icons.Filled.SportsGolf, "Golf"), - ProfileIcon("sports_gymnastics", Icons.Filled.SportsGymnastics, "Gymnastics"), - ProfileIcon("sports_handball", Icons.Filled.SportsHandball, "Handball"), - ProfileIcon("sports_hockey", Icons.Filled.SportsHockey, "Hockey"), - ProfileIcon("sports_kabaddi", Icons.Filled.SportsKabaddi, "Kabaddi"), - ProfileIcon("sports_martial_arts", Icons.Filled.SportsMartialArts, "Martial Arts"), - ProfileIcon("sports_mma", Icons.Filled.SportsMma, "MMA"), - ProfileIcon("sports_motorsports", Icons.Filled.SportsMotorsports, "Motorsports"), - ProfileIcon("sports_rugby", Icons.Filled.SportsRugby, "Rugby"), - ProfileIcon("sports_soccer", Icons.Filled.SportsSoccer, "Soccer"), - ProfileIcon("sports_tennis", Icons.Filled.SportsTennis, "Tennis"), - ProfileIcon("sports_volleyball", Icons.Filled.SportsVolleyball, "Volleyball"), - ProfileIcon("surfing", Icons.Filled.Surfing, "Surfing"), - ProfileIcon("switch_account", Icons.Filled.SwitchAccount, "Switch Account"), - ProfileIcon("thumb_down_alt", Icons.Filled.ThumbDownAlt, "Thumb Down Alt"), - ProfileIcon("thumb_up_alt", Icons.Filled.ThumbUpAlt, "Thumb Up Alt"), - ProfileIcon("thunderstorm", Icons.Filled.Thunderstorm, "Thunderstorm"), - ProfileIcon("tornado", Icons.Filled.Tornado, "Tornado"), - ProfileIcon("transgender", Icons.Filled.Transgender, "Transgender"), - ProfileIcon("travel_explore", Icons.Filled.TravelExplore, "Travel Explore"), - ProfileIcon("tsunami", Icons.Filled.Tsunami, "Tsunami"), - ProfileIcon("vaccines", Icons.Filled.Vaccines, "Vaccines"), - ProfileIcon("volcano", Icons.Filled.Volcano, "Volcano"), - ProfileIcon("wallet", Icons.Filled.Wallet, "Wallet"), - ProfileIcon("water_drop", Icons.Filled.WaterDrop, "Water Drop"), - ProfileIcon("waving_hand", Icons.Filled.WavingHand, "Waving Hand"), - // ProfileIcon("whatsapp", Icons.Filled.WhatsApp, "WhatsApp"), - ProfileIcon("whatshot", Icons.Filled.Whatshot, "Whatshot"), - ProfileIcon("woman", Icons.Filled.Woman, "Woman"), - ProfileIcon("woman_2", Icons.Filled.Woman2, "Woman 2"), - ProfileIcon("workspace_premium", Icons.Filled.WorkspacePremium, "Workspace Premium"), - ProfileIcon("workspaces", Icons.Filled.Workspaces, "Workspaces"), - ) -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/ToggleIcons.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/ToggleIcons.kt deleted file mode 100644 index f147d2769e..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/ToggleIcons.kt +++ /dev/null @@ -1,44 +0,0 @@ -package io.nekohasekai.sfa.compose.util.icons - -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.CheckBox -import androidx.compose.material.icons.filled.CheckBoxOutlineBlank -import androidx.compose.material.icons.filled.IndeterminateCheckBox -import androidx.compose.material.icons.filled.RadioButtonChecked -import androidx.compose.material.icons.filled.RadioButtonUnchecked -import androidx.compose.material.icons.filled.Star -import androidx.compose.material.icons.filled.StarBorder -import androidx.compose.material.icons.filled.StarBorderPurple500 -import androidx.compose.material.icons.filled.StarHalf -import androidx.compose.material.icons.filled.StarOutline -import androidx.compose.material.icons.filled.StarPurple500 -import androidx.compose.material.icons.filled.ToggleOff -import androidx.compose.material.icons.filled.ToggleOn -import io.nekohasekai.sfa.compose.util.ProfileIcon - -/** - * Toggle category icons - Switches and toggles - * Based on Google's Material Design Icons taxonomy - */ -object ToggleIcons { - val icons = - listOf( - ProfileIcon("check_box", Icons.Filled.CheckBox, "Check Box"), - ProfileIcon( - "check_box_outline_blank", - Icons.Filled.CheckBoxOutlineBlank, - "Check Box Blank", - ), - ProfileIcon("indeterminate_check_box", Icons.Filled.IndeterminateCheckBox, "Indeterminate"), - ProfileIcon("radio_button_checked", Icons.Filled.RadioButtonChecked, "Radio Checked"), - ProfileIcon("radio_button_unchecked", Icons.Filled.RadioButtonUnchecked, "Radio Unchecked"), - ProfileIcon("star", Icons.Filled.Star, "Star"), - ProfileIcon("star_border", Icons.Filled.StarBorder, "Star Border"), - ProfileIcon("star_border_purple500", Icons.Filled.StarBorderPurple500, "Star Purple"), - ProfileIcon("star_half", Icons.Filled.StarHalf, "Star Half"), - ProfileIcon("star_outline", Icons.Filled.StarOutline, "Star Outline"), - ProfileIcon("star_purple500", Icons.Filled.StarPurple500, "Star Purple"), - ProfileIcon("toggle_off", Icons.Filled.ToggleOff, "Toggle Off"), - ProfileIcon("toggle_on", Icons.Filled.ToggleOn, "Toggle On"), - ) -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/constant/Action.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/constant/Action.kt index fc9e41e35b..c0bb9478fd 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/constant/Action.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/constant/Action.kt @@ -4,4 +4,4 @@ object Action { const val SERVICE = "io.nekohasekai.sfa.SERVICE" const val SERVICE_CLOSE = "io.nekohasekai.sfa.SERVICE_CLOSE" const val OPEN_URL = "io.nekohasekai.sfa.SERVICE_OPEN_URL" -} +} \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/constant/Alert.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/constant/Alert.kt index d2c9882fd2..ce0c41880b 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/constant/Alert.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/constant/Alert.kt @@ -7,5 +7,5 @@ enum class Alert { EmptyConfiguration, StartCommandServer, CreateService, - StartService, -} + StartService +} \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/constant/Bugs.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/constant/Bugs.kt index 680c0fcef2..1dfc0c1e15 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/constant/Bugs.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/constant/Bugs.kt @@ -4,11 +4,11 @@ import android.os.Build import io.nekohasekai.sfa.BuildConfig object Bugs { + // TODO: remove launch after fixed // https://github.com/golang/go/issues/68760 - val fixAndroidStack = - BuildConfig.DEBUG || - Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && - Build.VERSION.SDK_INT <= Build.VERSION_CODES.N_MR1 || + val fixAndroidStack = BuildConfig.DEBUG || + Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && Build.VERSION.SDK_INT <= Build.VERSION_CODES.N_MR1 || Build.VERSION.SDK_INT >= Build.VERSION_CODES.P -} + +} \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/constant/EnabledType.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/constant/EnabledType.kt index fb70bf50bf..1bf8527dd7 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/constant/EnabledType.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/constant/EnabledType.kt @@ -4,22 +4,27 @@ import android.content.Context import io.nekohasekai.sfa.R enum class EnabledType(val boolValue: Boolean) { - Enabled(true), - Disabled(false), - ; + Enabled(true), Disabled(false); - fun getString(context: Context): String = when (this) { - Enabled -> context.getString(R.string.enabled) - Disabled -> context.getString(R.string.disabled) - } - - companion object { - fun from(value: Boolean): EnabledType = if (value) Enabled else Disabled - - fun valueOf(context: Context, value: String): EnabledType = when (value) { - context.getString(R.string.enabled) -> Enabled - context.getString(R.string.disabled) -> Disabled - else -> Disabled + fun getString(context: Context): String { + return when (this) { + Enabled -> context.getString(R.string.enabled) + Disabled -> context.getString(R.string.disabled) } } -} + + + companion object { + fun from(value: Boolean): EnabledType { + return if (value) Enabled else Disabled + } + + fun valueOf(context: Context, value: String): EnabledType { + return when (value) { + context.getString(R.string.enabled) -> Enabled + context.getString(R.string.disabled) -> Disabled + else -> Disabled + } + } + } +} \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/constant/Path.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/constant/Path.kt index e9d07a86e1..c731b6127a 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/constant/Path.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/constant/Path.kt @@ -3,4 +3,4 @@ package io.nekohasekai.sfa.constant object Path { const val SETTINGS_DATABASE_PATH = "settings.db" const val PROFILES_DATABASE_PATH = "profiles.db" -} +} \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/constant/PerAppProxyUpdateType.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/constant/PerAppProxyUpdateType.kt new file mode 100644 index 0000000000..00e8110b3a --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/constant/PerAppProxyUpdateType.kt @@ -0,0 +1,41 @@ +package io.nekohasekai.sfa.constant + +import android.content.Context +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.database.Settings + +enum class PerAppProxyUpdateType { + Disabled, Select, Deselect; + + fun value() = when (this) { + Disabled -> Settings.PER_APP_PROXY_DISABLED + Select -> Settings.PER_APP_PROXY_INCLUDE + Deselect -> Settings.PER_APP_PROXY_EXCLUDE + } + + fun getString(context: Context): String { + return when (this) { + Disabled -> context.getString(R.string.disabled) + Select -> context.getString(R.string.action_select) + Deselect -> context.getString(R.string.action_deselect) + } + } + + companion object { + fun valueOf(value: Int): PerAppProxyUpdateType = when (value) { + Settings.PER_APP_PROXY_DISABLED -> Disabled + Settings.PER_APP_PROXY_INCLUDE -> Select + Settings.PER_APP_PROXY_EXCLUDE -> Deselect + else -> throw IllegalArgumentException() + } + + fun valueOf(context: Context, value: String): PerAppProxyUpdateType { + return when (value) { + context.getString(R.string.disabled) -> Disabled + context.getString(R.string.action_select) -> Select + context.getString(R.string.action_deselect) -> Deselect + else -> Disabled + } + } + } +} \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/constant/ServiceMode.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/constant/ServiceMode.kt index 8b7f8c54e6..1bb0ad98d3 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/constant/ServiceMode.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/constant/ServiceMode.kt @@ -3,4 +3,4 @@ package io.nekohasekai.sfa.constant object ServiceMode { const val NORMAL = "normal" const val VPN = "vpn" -} +} \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/constant/SettingsKey.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/constant/SettingsKey.kt index 3109681a1d..25a7cc6a39 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/constant/SettingsKey.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/constant/SettingsKey.kt @@ -1,39 +1,22 @@ package io.nekohasekai.sfa.constant object SettingsKey { + const val SELECTED_PROFILE = "selected_profile" const val SERVICE_MODE = "service_mode" const val CHECK_UPDATE_ENABLED = "check_update_enabled" - const val UPDATE_CHECK_PROMPTED = "update_check_prompted" - const val UPDATE_TRACK = "update_track" - const val SILENT_INSTALL_ENABLED = "silent_install_enabled" - const val SILENT_INSTALL_METHOD = "silent_install_method" - const val AUTO_UPDATE_ENABLED = "auto_update_enabled" + const val DISABLE_MEMORY_LIMIT = "disable_memory_limit" const val DYNAMIC_NOTIFICATION = "dynamic_notification" - const val DISABLE_DEPRECATED_WARNINGS = "disable_deprecated_warnings" - const val AUTO_REDIRECT = "auto_redirect" const val PER_APP_PROXY_ENABLED = "per_app_proxy_enabled" const val PER_APP_PROXY_MODE = "per_app_proxy_mode" const val PER_APP_PROXY_LIST = "per_app_proxy_list" - const val PER_APP_PROXY_MANAGED_MODE = "per_app_proxy_managed_mode" - const val PER_APP_PROXY_MANAGED_LIST = "per_app_proxy_managed_list" - const val PER_APP_PROXY_PACKAGE_QUERY_MODE = "per_app_proxy_package_query_mode" + const val PER_APP_PROXY_UPDATE_ON_CHANGE = "per_app_proxy_update_on_change" const val SYSTEM_PROXY_ENABLED = "system_proxy_enabled" - const val PRIVILEGE_SETTINGS_ENABLED = "hide_settings_enabled" - const val PRIVILEGE_SETTINGS_LIST = "hide_settings_list" - const val PRIVILEGE_SETTINGS_INTERFACE_RENAME_ENABLED = "hide_settings_interface_rename_enabled" - const val PRIVILEGE_SETTINGS_INTERFACE_PREFIX = "hide_settings_interface_prefix" - - // dashboard - const val DASHBOARD_ITEM_ORDER = "dashboard_item_order" - const val DASHBOARD_DISABLED_ITEMS = "dashboard_disabled_items" - // cache + const val STARTED_BY_USER = "started_by_user" - const val CACHED_UPDATE_INFO = "cached_update_info" - const val CACHED_APK_PATH = "cached_apk_path" - const val LAST_SHOWN_UPDATE_VERSION = "last_shown_update_version" -} + +} \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/constant/Status.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/constant/Status.kt index 49d1da3e2a..740637f7fc 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/constant/Status.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/constant/Status.kt @@ -5,4 +5,4 @@ enum class Status { Starting, Started, Stopping, -} +} \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/database/Profile.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/database/Profile.kt index cdc3a74a64..57c1d69b96 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/database/Profile.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/database/Profile.kt @@ -1,7 +1,6 @@ package io.nekohasekai.sfa.database import android.os.Parcelable -import androidx.room.ColumnInfo import androidx.room.Delete import androidx.room.Entity import androidx.room.Insert @@ -20,11 +19,12 @@ class Profile( @PrimaryKey(autoGenerate = true) var id: Long = 0L, var userOrder: Long = 0L, var name: String = "", - @ColumnInfo(defaultValue = "NULL") var icon: String? = null, - var typed: TypedProfile = TypedProfile(), + var typed: TypedProfile = TypedProfile() ) : Parcelable { + @androidx.room.Dao interface Dao { + @Insert fun insert(profile: Profile): Long @@ -54,5 +54,8 @@ class Profile( @Query("SELECT MAX(id) + 1 FROM profiles") fun nextFileID(): Long? + } + } + diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/database/ProfileDatabase.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/database/ProfileDatabase.kt index e7eee0f296..24cbff0c6d 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/database/ProfileDatabase.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/database/ProfileDatabase.kt @@ -2,24 +2,12 @@ package io.nekohasekai.sfa.database import androidx.room.Database import androidx.room.RoomDatabase -import androidx.room.migration.Migration -import androidx.sqlite.db.SupportSQLiteDatabase @Database( - entities = [Profile::class], - version = 2, - exportSchema = true, + entities = [Profile::class], version = 1 ) abstract class ProfileDatabase : RoomDatabase() { + abstract fun profileDao(): Profile.Dao - companion object { - val MIGRATION_1_2 = - object : Migration(1, 2) { - override fun migrate(database: SupportSQLiteDatabase) { - // Add icon column to profiles table with default value null - database.execSQL("ALTER TABLE profiles ADD COLUMN icon TEXT DEFAULT NULL") - } - } - } -} +} \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/database/ProfileManager.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/database/ProfileManager.kt index b0e16434cd..61f1dc6bf5 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/database/ProfileManager.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/database/ProfileManager.kt @@ -9,6 +9,7 @@ import kotlinx.coroutines.launch @Suppress("RedundantSuspendModifier") object ProfileManager { + private val callbacks = mutableListOf<() -> Unit>() fun registerCallback(callback: () -> Unit) { @@ -26,26 +27,29 @@ object ProfileManager { .databaseBuilder( Application.application, ProfileDatabase::class.java, - Path.PROFILES_DATABASE_PATH, + Path.PROFILES_DATABASE_PATH ) - .addMigrations(ProfileDatabase.MIGRATION_1_2) - .fallbackToDestructiveMigrationOnDowngrade() + .fallbackToDestructiveMigration() .enableMultiInstanceInvalidation() .setQueryExecutor { GlobalScope.launch { it.run() } } .build() } - suspend fun nextOrder(): Long = instance.profileDao().nextOrder() ?: 0 + suspend fun nextOrder(): Long { + return instance.profileDao().nextOrder() ?: 0 + } - suspend fun nextFileID(): Long = instance.profileDao().nextFileID() ?: 1 + suspend fun nextFileID(): Long { + return instance.profileDao().nextFileID() ?: 1 + } - suspend fun get(id: Long): Profile? = instance.profileDao().get(id) - suspend fun create(profile: Profile, andSelect: Boolean = false): Profile { + suspend fun get(id: Long): Profile? { + return instance.profileDao().get(id) + } + + suspend fun create(profile: Profile): Profile { profile.id = instance.profileDao().insert(profile) - if (andSelect) { - Settings.selectedProfile = profile.id - } for (callback in callbacks.toList()) { callback() } @@ -92,5 +96,8 @@ object ProfileManager { } } - suspend fun list(): List = instance.profileDao().list() -} + suspend fun list(): List { + return instance.profileDao().list() + } + +} \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/database/Settings.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/database/Settings.kt index 23b1d8b90c..6f5bb79be7 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/database/Settings.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/database/Settings.kt @@ -1,9 +1,7 @@ package io.nekohasekai.sfa.database -import android.os.Build import androidx.room.Room import io.nekohasekai.sfa.Application -import io.nekohasekai.sfa.BuildConfig import io.nekohasekai.sfa.bg.ProxyService import io.nekohasekai.sfa.bg.VPNService import io.nekohasekai.sfa.constant.Path @@ -23,13 +21,14 @@ import org.json.JSONObject import java.io.File object Settings { + @OptIn(DelicateCoroutinesApi::class) private val instance by lazy { Application.application.getDatabasePath(Path.SETTINGS_DATABASE_PATH).parentFile?.mkdirs() Room.databaseBuilder( Application.application, KeyValueDatabase::class.java, - Path.SETTINGS_DATABASE_PATH, + Path.SETTINGS_DATABASE_PATH ).allowMainThreadQueries() .fallbackToDestructiveMigration() .enableMultiInstanceInvalidation() @@ -41,77 +40,27 @@ object Settings { var serviceMode by dataStore.string(SettingsKey.SERVICE_MODE) { ServiceMode.NORMAL } var startedByUser by dataStore.boolean(SettingsKey.STARTED_BY_USER) - var checkUpdateEnabled by dataStore.boolean(SettingsKey.CHECK_UPDATE_ENABLED) { false } - var updateCheckPrompted by dataStore.boolean(SettingsKey.UPDATE_CHECK_PROMPTED) { false } - var updateTrack by dataStore.string(SettingsKey.UPDATE_TRACK) { - val versionName = BuildConfig.VERSION_NAME.lowercase() - if (versionName.contains("-alpha") || - versionName.contains("-beta") || - versionName.contains("-rc") - ) { - "beta" - } else { - "stable" - } - } - var silentInstallEnabled by dataStore.boolean(SettingsKey.SILENT_INSTALL_ENABLED) { false } - var silentInstallMethod by dataStore.string(SettingsKey.SILENT_INSTALL_METHOD) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - "PACKAGE_INSTALLER" - } else { - "SHIZUKU" - } - } - var autoUpdateEnabled by dataStore.boolean(SettingsKey.AUTO_UPDATE_ENABLED) { false } + var checkUpdateEnabled by dataStore.boolean(SettingsKey.CHECK_UPDATE_ENABLED) { true } + var disableMemoryLimit by dataStore.boolean(SettingsKey.DISABLE_MEMORY_LIMIT) var dynamicNotification by dataStore.boolean(SettingsKey.DYNAMIC_NOTIFICATION) { true } - var disableDeprecatedWarnings by dataStore.boolean(SettingsKey.DISABLE_DEPRECATED_WARNINGS) { false } + const val PER_APP_PROXY_DISABLED = 0 const val PER_APP_PROXY_EXCLUDE = 1 const val PER_APP_PROXY_INCLUDE = 2 - var autoRedirect by dataStore.boolean(SettingsKey.AUTO_REDIRECT) { false } var perAppProxyEnabled by dataStore.boolean(SettingsKey.PER_APP_PROXY_ENABLED) { false } var perAppProxyMode by dataStore.int(SettingsKey.PER_APP_PROXY_MODE) { PER_APP_PROXY_EXCLUDE } var perAppProxyList by dataStore.stringSet(SettingsKey.PER_APP_PROXY_LIST) { emptySet() } - var perAppProxyManagedMode by dataStore.boolean(SettingsKey.PER_APP_PROXY_MANAGED_MODE) { false } - var perAppProxyManagedList by dataStore.stringSet(SettingsKey.PER_APP_PROXY_MANAGED_LIST) { emptySet() } - - const val PACKAGE_QUERY_MODE_SHIZUKU = "SHIZUKU" - const val PACKAGE_QUERY_MODE_ROOT = "ROOT" - var perAppProxyPackageQueryMode by dataStore.string(SettingsKey.PER_APP_PROXY_PACKAGE_QUERY_MODE) { PACKAGE_QUERY_MODE_SHIZUKU } - - fun getEffectivePerAppProxyMode(): Int = if (perAppProxyManagedMode) { - PER_APP_PROXY_EXCLUDE - } else { - perAppProxyMode - } - - fun getEffectivePerAppProxyList(): Set = if (perAppProxyManagedMode) { - perAppProxyManagedList - } else { - perAppProxyList - } + var perAppProxyUpdateOnChange by dataStore.int(SettingsKey.PER_APP_PROXY_UPDATE_ON_CHANGE) { PER_APP_PROXY_DISABLED } var systemProxyEnabled by dataStore.boolean(SettingsKey.SYSTEM_PROXY_ENABLED) { true } - var privilegeSettingsEnabled by dataStore.boolean(SettingsKey.PRIVILEGE_SETTINGS_ENABLED) { false } - var privilegeSettingsList by dataStore.stringSet(SettingsKey.PRIVILEGE_SETTINGS_LIST) { emptySet() } - var privilegeSettingsInterfaceRenameEnabled by dataStore.boolean( - SettingsKey.PRIVILEGE_SETTINGS_INTERFACE_RENAME_ENABLED, - ) { false } - var privilegeSettingsInterfacePrefix by dataStore.string(SettingsKey.PRIVILEGE_SETTINGS_INTERFACE_PREFIX) { "wlan" } - - var dashboardItemOrder by dataStore.string(SettingsKey.DASHBOARD_ITEM_ORDER) { "" } - var dashboardDisabledItems by dataStore.stringSet(SettingsKey.DASHBOARD_DISABLED_ITEMS) { emptySet() } - - var cachedUpdateInfo by dataStore.string(SettingsKey.CACHED_UPDATE_INFO) { "" } - var cachedApkPath by dataStore.string(SettingsKey.CACHED_APK_PATH) { "" } - var lastShownUpdateVersion by dataStore.int(SettingsKey.LAST_SHOWN_UPDATE_VERSION) { 0 } - - fun serviceClass(): Class<*> = when (serviceMode) { - ServiceMode.VPN -> VPNService::class.java - else -> ProxyService::class.java + fun serviceClass(): Class<*> { + return when (serviceMode) { + ServiceMode.VPN -> VPNService::class.java + else -> ProxyService::class.java + } } suspend fun rebuildServiceMode(): Boolean { @@ -143,4 +92,5 @@ object Settings { } return false } -} + +} \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/database/TypedProfile.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/database/TypedProfile.kt index 77c826f310..6350b568ac 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/database/TypedProfile.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/database/TypedProfile.kt @@ -11,14 +11,15 @@ import io.nekohasekai.sfa.ktx.unmarshall import java.util.Date class TypedProfile() : Parcelable { - enum class Type { - Local, - Remote, - ; - fun getString(context: Context): String = when (this) { - Local -> context.getString(R.string.profile_type_local) - Remote -> context.getString(R.string.profile_type_remote) + enum class Type { + Local, Remote; + + fun getString(context: Context): String { + return when (this) { + Local -> context.getString(R.string.profile_type_local) + Remote -> context.getString(R.string.profile_type_remote) + } } companion object { @@ -62,19 +63,29 @@ class TypedProfile() : Parcelable { writer.writeInt(autoUpdateInterval) } - override fun describeContents(): Int = 0 + override fun describeContents(): Int { + return 0 + } companion object CREATOR : Parcelable.Creator { - override fun createFromParcel(parcel: Parcel): TypedProfile = TypedProfile(parcel) + override fun createFromParcel(parcel: Parcel): TypedProfile { + return TypedProfile(parcel) + } - override fun newArray(size: Int): Array = arrayOfNulls(size) + override fun newArray(size: Int): Array { + return arrayOfNulls(size) + } } class Convertor { + @TypeConverter fun marshall(profile: TypedProfile) = profile.marshall() @TypeConverter - fun unmarshall(content: ByteArray) = content.unmarshall(::TypedProfile) + fun unmarshall(content: ByteArray) = + content.unmarshall(::TypedProfile) + } -} + +} \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/database/preference/KeyValueDatabase.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/database/preference/KeyValueDatabase.kt index 97102915ec..d94ad921ee 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/database/preference/KeyValueDatabase.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/database/preference/KeyValueDatabase.kt @@ -4,9 +4,10 @@ import androidx.room.Database import androidx.room.RoomDatabase @Database( - entities = [KeyValueEntity::class], - version = 1, + entities = [KeyValueEntity::class], version = 1 ) abstract class KeyValueDatabase : RoomDatabase() { + abstract fun keyValuePairDao(): KeyValueEntity.Dao + } diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/database/preference/KeyValueEntity.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/database/preference/KeyValueEntity.kt index 29c0500ffb..2389b35376 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/database/preference/KeyValueEntity.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/database/preference/KeyValueEntity.kt @@ -22,16 +22,20 @@ class KeyValueEntity() : Parcelable { const val TYPE_STRING_SET = 5 @JvmField - val CREATOR = - object : Parcelable.Creator { - override fun createFromParcel(parcel: Parcel): KeyValueEntity = KeyValueEntity(parcel) - - override fun newArray(size: Int): Array = arrayOfNulls(size) + val CREATOR = object : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel): KeyValueEntity { + return KeyValueEntity(parcel) } + + override fun newArray(size: Int): Array { + return arrayOfNulls(size) + } + } } @androidx.room.Dao interface Dao { + @Query("SELECT * FROM KeyValueEntity") fun all(): List @@ -67,19 +71,16 @@ class KeyValueEntity() : Parcelable { val string: String? get() = if (valueType == TYPE_STRING) String(value) else null val stringSet: Set? - get() = - if (valueType == TYPE_STRING_SET) { - val buffer = ByteBuffer.wrap(value) - val result = HashSet() - while (buffer.hasRemaining()) { - val chArr = ByteArray(buffer.int) - buffer.get(chArr) - result.add(String(chArr)) - } - result - } else { - null + get() = if (valueType == TYPE_STRING_SET) { + val buffer = ByteBuffer.wrap(value) + val result = HashSet() + while (buffer.hasRemaining()) { + val chArr = ByteArray(buffer.int) + buffer.get(chArr) + result.add(String(chArr)) } + result + } else null @Ignore constructor(key: String) : this() { @@ -125,14 +126,16 @@ class KeyValueEntity() : Parcelable { } @Suppress("IMPLICIT_CAST_TO_ANY") - override fun toString(): String = when (valueType) { - TYPE_BOOLEAN -> boolean - TYPE_FLOAT -> float - TYPE_LONG -> long - TYPE_STRING -> string - TYPE_STRING_SET -> stringSet - else -> null - }?.toString() ?: "null" + override fun toString(): String { + return when (valueType) { + TYPE_BOOLEAN -> boolean + TYPE_FLOAT -> float + TYPE_LONG -> long + TYPE_STRING -> string + TYPE_STRING_SET -> stringSet + else -> null + }?.toString() ?: "null" + } constructor(parcel: Parcel) : this() { key = parcel.readString()!! @@ -146,5 +149,8 @@ class KeyValueEntity() : Parcelable { parcel.writeByteArray(value) } - override fun describeContents(): Int = 0 + override fun describeContents(): Int { + return 0 + } + } diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/database/preference/RoomPreferenceDataStore.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/database/preference/RoomPreferenceDataStore.kt index c868e45a2f..ac106939f1 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/database/preference/RoomPreferenceDataStore.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/database/preference/RoomPreferenceDataStore.kt @@ -3,41 +3,35 @@ package io.nekohasekai.sfa.database.preference import androidx.preference.PreferenceDataStore @Suppress("MemberVisibilityCanBePrivate", "unused") -open class RoomPreferenceDataStore(private val kvPairDao: KeyValueEntity.Dao) : PreferenceDataStore() { +open class RoomPreferenceDataStore(private val kvPairDao: KeyValueEntity.Dao) : + PreferenceDataStore() { + fun getBoolean(key: String) = kvPairDao[key]?.boolean - fun getFloat(key: String) = kvPairDao[key]?.float - fun getInt(key: String) = kvPairDao[key]?.long?.toInt() - fun getLong(key: String) = kvPairDao[key]?.long - fun getString(key: String) = kvPairDao[key]?.string - fun getStringSet(key: String) = kvPairDao[key]?.stringSet - fun reset() = kvPairDao.reset() override fun getBoolean(key: String, defValue: Boolean) = getBoolean(key) ?: defValue - override fun getFloat(key: String, defValue: Float) = getFloat(key) ?: defValue - override fun getInt(key: String, defValue: Int) = getInt(key) ?: defValue - override fun getLong(key: String, defValue: Long) = getLong(key) ?: defValue - override fun getString(key: String, defValue: String?) = getString(key) ?: defValue + override fun getStringSet(key: String, defValue: MutableSet?) = + getStringSet(key) ?: defValue - override fun getStringSet(key: String, defValue: MutableSet?) = getStringSet(key) ?: defValue + fun putBoolean(key: String, value: Boolean?) = + if (value == null) remove(key) else putBoolean(key, value) - fun putBoolean(key: String, value: Boolean?) = if (value == null) remove(key) else putBoolean(key, value) + fun putFloat(key: String, value: Float?) = + if (value == null) remove(key) else putFloat(key, value) - fun putFloat(key: String, value: Float?) = if (value == null) remove(key) else putFloat(key, value) - - fun putInt(key: String, value: Int?) = if (value == null) remove(key) else putLong(key, value.toLong()) + fun putInt(key: String, value: Int?) = + if (value == null) remove(key) else putLong(key, value.toLong()) fun putLong(key: String, value: Long?) = if (value == null) remove(key) else putLong(key, value) - override fun putBoolean(key: String, value: Boolean) { kvPairDao.put(KeyValueEntity(key).put(value)) fireChangeListener(key) @@ -58,19 +52,16 @@ open class RoomPreferenceDataStore(private val kvPairDao: KeyValueEntity.Dao) : fireChangeListener(key) } - override fun putString(key: String, value: String?) = if (value == null) { - remove(key) - } else { + override fun putString(key: String, value: String?) = if (value == null) remove(key) else { kvPairDao.put(KeyValueEntity(key).put(value)) fireChangeListener(key) } - override fun putStringSet(key: String, values: MutableSet?) = if (values == null) { - remove(key) - } else { - kvPairDao.put(KeyValueEntity(key).put(values)) - fireChangeListener(key) - } + override fun putStringSet(key: String, values: MutableSet?) = + if (values == null) remove(key) else { + kvPairDao.put(KeyValueEntity(key).put(values)) + fireChangeListener(key) + } fun remove(key: String) { kvPairDao.delete(key) @@ -78,12 +69,10 @@ open class RoomPreferenceDataStore(private val kvPairDao: KeyValueEntity.Dao) : } private val listeners = HashSet() - private fun fireChangeListener(key: String) { - val listeners = - synchronized(listeners) { - listeners.toList() - } + val listeners = synchronized(listeners) { + listeners.toList() + } listeners.forEach { it.onPreferenceDataStoreChanged(this, key) } } @@ -98,4 +87,4 @@ open class RoomPreferenceDataStore(private val kvPairDao: KeyValueEntity.Dao) : listeners.remove(listener) } } -} +} \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Browsers.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Browsers.kt index ffb86313ec..0c4786df0b 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Browsers.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Browsers.kt @@ -14,13 +14,13 @@ fun Context.launchCustomTab(link: String) { CustomTabsIntent.COLOR_SCHEME_LIGHT, CustomTabColorSchemeParams.Builder().apply { setToolbarColor(color) - }.build(), + }.build() ) setColorSchemeParams( CustomTabsIntent.COLOR_SCHEME_DARK, CustomTabColorSchemeParams.Builder().apply { setToolbarColor(color) - }.build(), + }.build() ) }.build().launchUrl(this, Uri.parse(link)) -} +} \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Clips.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Clips.kt index b5654eae24..48ee0f42cb 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Clips.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Clips.kt @@ -9,4 +9,4 @@ var clipboardText: String? if (plainText != null) { Application.clipboard.setPrimaryClip(ClipData.newPlainText(null, plainText)) } - } + } \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Colors.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Colors.kt index 44fc46714b..17631bbb18 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Colors.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Colors.kt @@ -9,8 +9,13 @@ import androidx.annotation.ColorInt import androidx.core.content.ContextCompat import com.google.android.material.color.MaterialColors + @ColorInt -fun Context.getAttrColor(@AttrRes attrColor: Int, typedValue: TypedValue = TypedValue(), resolveRefs: Boolean = true): Int { +fun Context.getAttrColor( + @AttrRes attrColor: Int, + typedValue: TypedValue = TypedValue(), + resolveRefs: Boolean = true +): Int { theme.resolveAttribute(attrColor, typedValue, resolveRefs) return typedValue.data } @@ -39,4 +44,4 @@ fun colorForURLTestDelay(context: Context, urlTestDelay: Int): Int { } } return MaterialColors.harmonizeWithPrimary(context, ContextCompat.getColor(context, colorRes)) -} +} \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Context.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Context.kt index 76a8b64e7b..ac31b706b2 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Context.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Context.kt @@ -4,4 +4,6 @@ import android.content.Context import android.content.pm.PackageManager import androidx.core.content.ContextCompat -fun Context.hasPermission(permission: String): Boolean = ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED +fun Context.hasPermission(permission: String): Boolean { + return ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Continuations.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Continuations.kt index b1fe740e84..aad9f83e3f 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Continuations.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Continuations.kt @@ -2,6 +2,7 @@ package io.nekohasekai.sfa.ktx import kotlin.coroutines.Continuation + fun Continuation.tryResume(value: T) { try { resumeWith(Result.success(value)) diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Dialogs.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Dialogs.kt index 591e1cc3c2..80a98662cd 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Dialogs.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Dialogs.kt @@ -1,46 +1,24 @@ package io.nekohasekai.sfa.ktx -import android.content.ClipData -import android.content.ClipboardManager import android.content.Context -import android.widget.ScrollView -import android.widget.TextView -import android.widget.Toast import androidx.annotation.StringRes import com.google.android.material.dialog.MaterialAlertDialogBuilder import io.nekohasekai.sfa.R -fun Context.errorDialogBuilder(@StringRes messageId: Int): MaterialAlertDialogBuilder = errorDialogBuilder(getString(messageId)) - -fun Context.errorDialogBuilder(message: String): MaterialAlertDialogBuilder { - val contentView = buildSelectableMessageView(message) +fun Context.errorDialogBuilder(@StringRes messageId: Int): MaterialAlertDialogBuilder { return MaterialAlertDialogBuilder(this) .setTitle(R.string.error_title) - .setView(contentView) - .setNeutralButton(R.string.per_app_proxy_action_copy) { _, _ -> - copyToClipboard(message) - } + .setMessage(messageId) .setPositiveButton(android.R.string.ok, null) } -fun Context.errorDialogBuilder(exception: Throwable): MaterialAlertDialogBuilder = errorDialogBuilder(exception.localizedMessage ?: exception.toString()) - -private fun Context.buildSelectableMessageView(message: String): ScrollView { - val density = resources.displayMetrics.density - val padding = (16 * density).toInt() - val textView = - TextView(this).apply { - text = message - setTextIsSelectable(true) - setPadding(padding, padding, padding, padding) - } - return ScrollView(this).apply { - addView(textView) - } +fun Context.errorDialogBuilder(message: String): MaterialAlertDialogBuilder { + return MaterialAlertDialogBuilder(this) + .setTitle(R.string.error_title) + .setMessage(message) + .setPositiveButton(android.R.string.ok, null) } -private fun Context.copyToClipboard(text: String) { - val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - clipboard.setPrimaryClip(ClipData.newPlainText(getString(R.string.error_title), text)) - Toast.makeText(this, getString(R.string.copied_to_clipboard), Toast.LENGTH_SHORT).show() -} +fun Context.errorDialogBuilder(exception: Throwable): MaterialAlertDialogBuilder { + return errorDialogBuilder(exception.localizedMessage ?: exception.toString()) +} \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Dimens.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Dimens.kt index a5a4d46a3c..bb2dcebcf1 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Dimens.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Dimens.kt @@ -5,6 +5,10 @@ import kotlin.math.ceil private val density = Resources.getSystem().displayMetrics.density -fun dp2pxf(dpValue: Int): Float = density * dpValue +fun dp2pxf(dpValue: Int): Float { + return density * dpValue +} -fun dp2px(dpValue: Int): Int = ceil(dp2pxf(dpValue)).toInt() +fun dp2px(dpValue: Int): Int { + return ceil(dp2pxf(dpValue)).toInt() +} \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Inputs.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Inputs.kt index d7e0e2e7a6..4116af759c 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Inputs.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Inputs.kt @@ -18,6 +18,7 @@ var TextInputLayout.error: String editText?.error = value } + fun TextInputLayout.setSimpleItems(@ArrayRes redId: Int) { (editText as? MaterialAutoCompleteTextView)?.setSimpleItems(redId) } @@ -40,10 +41,11 @@ fun TextInputLayout.showErrorIfEmpty(): Boolean { return false } + fun TextInputLayout.addTextChangedListener(listener: (String) -> Unit) { addOnEditTextAttachedListener { editText?.addTextChangedListener { listener(it?.toString() ?: "") } } -} +} \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Intents.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Intents.kt index 224df75270..aea48075ae 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Intents.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Intents.kt @@ -6,7 +6,9 @@ import androidx.activity.result.ActivityResultLauncher import com.google.android.material.dialog.MaterialAlertDialogBuilder import io.nekohasekai.sfa.R -fun Activity.startFilesForResult(launcher: ActivityResultLauncher, input: String) { +fun Activity.startFilesForResult( + launcher: ActivityResultLauncher, input: String +) { try { return launcher.launch(input) } catch (_: ActivityNotFoundException) { @@ -16,4 +18,4 @@ fun Activity.startFilesForResult(launcher: ActivityResultLauncher, input builder.setPositiveButton(resources.getString(android.R.string.ok), null) builder.setMessage(R.string.file_manager_missing) builder.show() -} +} \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Preferences.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Preferences.kt index bd0fcbd53b..f38effea51 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Preferences.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Preferences.kt @@ -3,33 +3,60 @@ package io.nekohasekai.sfa.ktx import androidx.preference.PreferenceDataStore import kotlin.reflect.KProperty -fun PreferenceDataStore.string(name: String, defaultValue: () -> String = { "" }) = PreferenceProxy(name, defaultValue, ::getString, ::putString) +fun PreferenceDataStore.string( + name: String, + defaultValue: () -> String = { "" }, +) = PreferenceProxy(name, defaultValue, ::getString, ::putString) -fun PreferenceDataStore.stringNotBlack(name: String, defaultValue: () -> String = { "" }) = PreferenceProxy(name, defaultValue, { key, default -> +fun PreferenceDataStore.stringNotBlack( + name: String, + defaultValue: () -> String = { "" }, +) = PreferenceProxy(name, defaultValue, { key, default -> getString(key, default)?.takeIf { it.isNotBlank() } ?: default }, { key, value -> putString(key, value.takeIf { it.isNotBlank() } ?: defaultValue()) }) -fun PreferenceDataStore.boolean(name: String, defaultValue: () -> Boolean = { false }) = PreferenceProxy(name, defaultValue, ::getBoolean, ::putBoolean) +fun PreferenceDataStore.boolean( + name: String, + defaultValue: () -> Boolean = { false }, +) = PreferenceProxy(name, defaultValue, ::getBoolean, ::putBoolean) -fun PreferenceDataStore.int(name: String, defaultValue: () -> Int = { 0 }) = PreferenceProxy(name, defaultValue, ::getInt, ::putInt) +fun PreferenceDataStore.int( + name: String, + defaultValue: () -> Int = { 0 }, +) = PreferenceProxy(name, defaultValue, ::getInt, ::putInt) -fun PreferenceDataStore.stringToInt(name: String, defaultValue: () -> Int = { 0 }) = PreferenceProxy(name, defaultValue, { key, default -> +fun PreferenceDataStore.stringToInt( + name: String, + defaultValue: () -> Int = { 0 }, +) = PreferenceProxy(name, defaultValue, { key, default -> getString(key, "$default")?.toIntOrNull() ?: default }, { key, value -> putString(key, "$value") }) -fun PreferenceDataStore.stringToIntIfExists(name: String, defaultValue: () -> Int = { 0 }) = PreferenceProxy(name, defaultValue, { key, default -> +fun PreferenceDataStore.stringToIntIfExists( + name: String, + defaultValue: () -> Int = { 0 }, +) = PreferenceProxy(name, defaultValue, { key, default -> getString(key, "$default")?.toIntOrNull() ?: default }, { key, value -> putString(key, value.takeIf { it > 0 }?.toString() ?: "") }) -fun PreferenceDataStore.long(name: String, defaultValue: () -> Long = { 0L }) = PreferenceProxy(name, defaultValue, ::getLong, ::putLong) +fun PreferenceDataStore.long( + name: String, + defaultValue: () -> Long = { 0L }, +) = PreferenceProxy(name, defaultValue, ::getLong, ::putLong) -fun PreferenceDataStore.stringToLong(name: String, defaultValue: () -> Long = { 0L }) = PreferenceProxy(name, defaultValue, { key, default -> +fun PreferenceDataStore.stringToLong( + name: String, + defaultValue: () -> Long = { 0L }, +) = PreferenceProxy(name, defaultValue, { key, default -> getString(key, "$default")?.toLongOrNull() ?: default }, { key, value -> putString(key, "$value") }) -fun PreferenceDataStore.stringSet(name: String, defaultValue: () -> Set = { emptySet() }) = PreferenceProxy(name, defaultValue, ::getStringSet, ::putStringSet) +fun PreferenceDataStore.stringSet( + name: String, + defaultValue: () -> Set = { emptySet() } +) = PreferenceProxy(name, defaultValue, ::getStringSet, ::putStringSet) class PreferenceProxy( val name: String, @@ -37,7 +64,8 @@ class PreferenceProxy( val getter: (String, T) -> T?, val setter: (String, value: T) -> Unit, ) { - operator fun setValue(thisObj: Any?, property: KProperty<*>, value: T) = setter(name, value) + operator fun setValue(thisObj: Any?, property: KProperty<*>, value: T) = setter(name, value) operator fun getValue(thisObj: Any?, property: KProperty<*>) = getter(name, defaultValue())!! -} + +} \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Room.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Room.kt index 54635fb690..f8c672f251 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Room.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Room.kt @@ -18,4 +18,4 @@ fun ByteArray.unmarshall(constructor: (Parcel) -> T): T { val result = constructor(parcel) parcel.recycle() return result -} +} \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Shares.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Shares.kt index 5e50613b1a..52dc78e0b8 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Shares.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Shares.kt @@ -2,14 +2,21 @@ package io.nekohasekai.sfa.ktx import android.content.Context import android.content.Intent +import android.graphics.Bitmap +import android.graphics.Color import androidx.core.content.FileProvider +import androidx.fragment.app.FragmentActivity +import com.google.android.material.R +import com.google.zxing.BarcodeFormat +import com.google.zxing.qrcode.QRCodeWriter +import io.nekohasekai.libbox.Libbox import io.nekohasekai.libbox.ProfileContent import io.nekohasekai.sfa.database.Profile import io.nekohasekai.sfa.database.TypedProfile +import io.nekohasekai.sfa.ui.shared.QRCodeDialog import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.io.File -import androidx.appcompat.R as AppCompatR suspend fun Context.shareProfile(profile: Profile) { val content = ProfileContent() @@ -39,25 +46,32 @@ suspend fun Context.shareProfile(profile: Profile) { Intent(Intent.ACTION_SEND).setType("application/octet-stream") .setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) .putExtra(Intent.EXTRA_STREAM, uri), - getString(AppCompatR.string.abc_shareactionprovider_share_with), - ), + getString(R.string.abc_shareactionprovider_share_with) + ) ) } } -suspend fun Context.shareProfileAsJson(profile: Profile) { - val configDirectory = File(cacheDir, "share").also { it.mkdirs() } - val jsonFile = File(configDirectory, "${profile.name}.json") - jsonFile.writeText(File(profile.typed.path).readText()) - val uri = FileProvider.getUriForFile(this, "$packageName.cache", jsonFile) - withContext(Dispatchers.Main) { - startActivity( - Intent.createChooser( - Intent(Intent.ACTION_SEND).setType("application/json") - .setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - .putExtra(Intent.EXTRA_STREAM, uri), - getString(AppCompatR.string.abc_shareactionprovider_share_with), - ), - ) +fun FragmentActivity.shareProfileURL(profile: Profile) { + val link = Libbox.generateRemoteProfileImportLink( + profile.name, + profile.typed.remoteURL + ) + val imageSize = dp2px(256) + val color = getAttrColor(com.google.android.material.R.attr.colorPrimary) + val image = QRCodeWriter().encode(link, BarcodeFormat.QR_CODE, imageSize, imageSize, null) + val imageWidth = image.width + val imageHeight = image.height + val imageArray = IntArray(imageWidth * imageHeight) + for (y in 0 until imageHeight) { + val offset = y * imageWidth + for (x in 0 until imageWidth) { + imageArray[offset + x] = if (image.get(x, y)) color else Color.TRANSPARENT + + } } -} + val bitmap = Bitmap.createBitmap(imageWidth, imageHeight, Bitmap.Config.ARGB_8888) + bitmap.setPixels(imageArray, 0, imageSize, 0, 0, imageWidth, imageHeight) + val dialog = QRCodeDialog(bitmap) + dialog.show(supportFragmentManager, "share-profile-url") +} \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Wrappers.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Wrappers.kt index 1dbf3b5b42..bd8b2f6d50 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Wrappers.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Wrappers.kt @@ -3,14 +3,10 @@ package io.nekohasekai.sfa.ktx import android.net.IpPrefix import android.os.Build import androidx.annotation.RequiresApi -import io.nekohasekai.libbox.ConnectionIterator -import io.nekohasekai.libbox.LogEntry -import io.nekohasekai.libbox.LogIterator import io.nekohasekai.libbox.RoutePrefix import io.nekohasekai.libbox.StringBox import io.nekohasekai.libbox.StringIterator import java.net.InetAddress -import io.nekohasekai.libbox.Connection as LibboxConnection val StringBox?.unwrap: String get() { @@ -27,29 +23,23 @@ fun Iterable.toStringIterator(): StringIterator { return 0 } - override fun hasNext(): Boolean = iterator.hasNext() + override fun hasNext(): Boolean { + return iterator.hasNext() + } - override fun next(): String = iterator.next() + override fun next(): String { + return iterator.next() + } } } -fun StringIterator.toList(): List = mutableListOf().apply { - while (hasNext()) { - add(next()) - } -} - -fun LogIterator.toList(): List = mutableListOf().apply { - while (hasNext()) { - add(next()) +fun StringIterator.toList(): List { + return mutableListOf().apply { + while (hasNext()) { + add(next()) + } } } @RequiresApi(Build.VERSION_CODES.TIRAMISU) -fun RoutePrefix.toIpPrefix() = IpPrefix(InetAddress.getByName(address()), prefix()) - -fun ConnectionIterator.toList(): List = mutableListOf().apply { - while (hasNext()) { - add(next()) - } -} +fun RoutePrefix.toIpPrefix() = IpPrefix(InetAddress.getByName(address()), prefix()) \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/qrs/ByteArrayExtensions.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/qrs/ByteArrayExtensions.kt deleted file mode 100644 index d222c87fb0..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/qrs/ByteArrayExtensions.kt +++ /dev/null @@ -1,20 +0,0 @@ -package io.nekohasekai.sfa.qrs - -fun ByteArray.readIntLE(offset: Int): Int = (this[offset].toInt() and 0xFF) or - ((this[offset + 1].toInt() and 0xFF) shl 8) or - ((this[offset + 2].toInt() and 0xFF) shl 16) or - ((this[offset + 3].toInt() and 0xFF) shl 24) - -fun ByteArray.writeIntLE(offset: Int, value: Int) { - this[offset] = value.toByte() - this[offset + 1] = (value shr 8).toByte() - this[offset + 2] = (value shr 16).toByte() - this[offset + 3] = (value shr 24).toByte() -} - -fun ByteArray.writeIntBE(offset: Int, value: Int) { - this[offset] = (value shr 24).toByte() - this[offset + 1] = (value shr 16).toByte() - this[offset + 2] = (value shr 8).toByte() - this[offset + 3] = value.toByte() -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/qrs/LubyCodec.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/qrs/LubyCodec.kt deleted file mode 100644 index 4eed13be85..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/qrs/LubyCodec.kt +++ /dev/null @@ -1,321 +0,0 @@ -package io.nekohasekai.sfa.qrs - -import java.util.zip.CRC32 -import kotlin.random.Random - -class LubyCodec(private val sliceSize: Int = QRSConstants.DEFAULT_SLICE_SIZE) { - internal class IntArrayKey(val indices: IntArray) { - private val hash = indices.contentHashCode() - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other !is IntArrayKey) return false - return indices.contentEquals(other.indices) - } - - override fun hashCode(): Int = hash - } - - data class EncodedBlock( - val degree: Int, - val indices: IntArray, - val totalBlocks: Int, - val compressedSize: Int, - val checksum: Long, - val data: ByteArray, - ) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - other as EncodedBlock - return degree == other.degree && - indices.contentEquals(other.indices) && - totalBlocks == other.totalBlocks && - compressedSize == other.compressedSize && - checksum == other.checksum && - data.contentEquals(other.data) - } - - override fun hashCode(): Int { - var result = degree - result = 31 * result + indices.contentHashCode() - result = 31 * result + totalBlocks - result = 31 * result + compressedSize - result = 31 * result + checksum.hashCode() - result = 31 * result + data.contentHashCode() - return result - } - } - - class DecodingState(val totalBlocks: Int, val compressedSize: Int, val checksum: Long) { - val decodedBlocks: Array = arrayOfNulls(totalBlocks) - var decodedCount: Int = 0 - - internal val blockKeyMap: MutableMap = mutableMapOf() - internal val blockSubkeyMap: MutableMap> = mutableMapOf() - val blockIndexMap: MutableMap> = mutableMapOf() - val blockDisposeMap: MutableMap Unit>> = mutableMapOf() - - class PendingBlock(var indices: MutableList, var data: ByteArray) - } - - fun encode(originalData: ByteArray, compressedData: ByteArray, compressedSize: Int): Sequence = sequence { - val k = (compressedData.size + sliceSize - 1) / sliceSize - if (k == 0) return@sequence - - val paddedData = compressedData.copyOf(k * sliceSize) - val blocks = (0 until k).map { i -> - paddedData.copyOfRange(i * sliceSize, (i + 1) * sliceSize) - } - - val crc = CRC32() - crc.update(originalData) - // Official: (raw_crc ^ k ^ 0xFFFFFFFF) - // Java CRC32.getValue() = raw_crc ^ 0xFFFFFFFF - // So: official = getValue() ^ 0xFFFFFFFF ^ k ^ 0xFFFFFFFF = getValue() ^ k - val checksum = (crc.value xor k.toLong()) and 0xFFFFFFFFL - - var seed = 0L - while (true) { - val random = Random(seed++) - val degree = SolitonDistribution.sample(k, random) - val indices = selectIndices(k, degree, random) - val blockData = xorBlocks(blocks, indices) - - yield( - EncodedBlock( - degree = degree, - indices = indices, - totalBlocks = k, - compressedSize = compressedSize, - checksum = checksum, - data = blockData, - ), - ) - } - } - - fun createDecodingState(firstBlock: EncodedBlock): DecodingState = DecodingState( - totalBlocks = firstBlock.totalBlocks, - compressedSize = firstBlock.compressedSize, - checksum = firstBlock.checksum, - ) - - fun processBlock(state: DecodingState, block: EncodedBlock): Boolean { - val queue = ArrayDeque() - queue.add(DecodingState.PendingBlock(block.indices.sorted().toMutableList(), block.data.clone())) - - while (queue.isNotEmpty()) { - val pending = queue.removeFirst() - processPendingBlock(state, pending, queue) - } - - return state.decodedCount == state.totalBlocks - } - - private fun processPendingBlock( - state: DecodingState, - pending: DecodingState.PendingBlock, - queue: ArrayDeque, - ) { - var indices = pending.indices - val data = pending.data - - val key = indicesToKey(indices) - if (state.blockKeyMap.containsKey(key) || indices.all { state.decodedBlocks[it] != null }) { - return - } - - // XOR with already decoded blocks - if (indices.size > 1) { - val toRemove = mutableListOf() - for (idx in indices) { - state.decodedBlocks[idx]?.let { - xorInPlace(data, it) - toRemove.add(idx) - } - } - if (toRemove.isNotEmpty()) { - indices.removeAll(toRemove) - } - } - - // Try subset lookup: [1,2,3] XOR [1,2] = [3] - if (indices.size > 2) { - for (i in indices.indices) { - val subkey = indicesToKey(indices.filterIndexed { j, _ -> j != i }) - state.blockKeyMap[subkey]?.let { subblock -> - xorInPlace(data, subblock.data) - indices = mutableListOf(indices[i]) - pending.indices = indices - return@let - } - } - } - - // Still pending: store and register for future matching - if (indices.size > 1) { - val newKey = indicesToKey(indices) - state.blockKeyMap[newKey] = pending - - // Register for single-index lookups - for (idx in indices) { - state.blockIndexMap.getOrPut(idx) { mutableSetOf() }.add(pending) - } - - // Register subkeys for superset matching (degree > 2) - if (indices.size > 2) { - for (i in indices.indices) { - val subkey = indicesToKey(indices.filterIndexed { j, _ -> j != i }) - val dispose: () -> Unit = { state.blockSubkeyMap[subkey]?.remove(pending) } - state.blockSubkeyMap.getOrPut(subkey) { mutableSetOf() }.add(pending) - state.blockDisposeMap.getOrPut(indices[i]) { mutableListOf() }.add(dispose) - } - } - - // Check if this block can help decode any supersets - state.blockSubkeyMap[newKey]?.let { supersets -> - state.blockSubkeyMap.remove(newKey) - for (superblock in supersets.toList()) { - // Remove old registrations before modifying - val oldKey = indicesToKey(superblock.indices) - state.blockKeyMap.remove(oldKey) - for (idx in superblock.indices) { - state.blockIndexMap[idx]?.remove(superblock) - } - - xorInPlace(superblock.data, data) - superblock.indices.removeAll(indices) - - // Re-process through queue - queue.add(superblock) - } - } - } else if (indices.size == 1) { - val idx = indices[0] - if (state.decodedBlocks[idx] == null) { - state.decodedBlocks[idx] = data - state.decodedCount++ - propagateDecoding(state, idx, queue) - } - } - } - - private fun indicesToKey(indices: List): IntArrayKey = IntArrayKey(indices.sorted().toIntArray()) - - private fun propagateDecoding(state: DecodingState, decodedIdx: Int, queue: ArrayDeque) { - val toProcess = ArrayDeque() - toProcess.add(decodedIdx) - - while (toProcess.isNotEmpty()) { - val idx = toProcess.removeFirst() - val decodedData = state.decodedBlocks[idx] ?: continue - - // Dispose subkey registrations for this index - state.blockDisposeMap.remove(idx)?.forEach { it() } - - // Find and process blocks containing this index - val blocks = state.blockIndexMap.remove(idx) ?: continue - for (pending in blocks) { - val oldKey = indicesToKey(pending.indices) - state.blockKeyMap.remove(oldKey) - - xorInPlace(pending.data, decodedData) - pending.indices.remove(idx) - - // Remove from other index maps - for (otherIdx in pending.indices) { - state.blockIndexMap[otherIdx]?.remove(pending) - } - - if (pending.indices.size == 1) { - val newIdx = pending.indices[0] - if (state.decodedBlocks[newIdx] == null) { - state.decodedBlocks[newIdx] = pending.data - state.decodedCount++ - toProcess.add(newIdx) - } - } else if (pending.indices.size > 1) { - // Re-process through queue to properly update all registrations - queue.add(pending) - } - } - } - } - - fun assembleData(state: DecodingState): ByteArray { - val result = ByteArray(state.totalBlocks * sliceSize) - for (i in state.decodedBlocks.indices) { - state.decodedBlocks[i]?.copyInto(result, i * sliceSize) - } - return result - } - - fun verifyChecksum(originalData: ByteArray, expectedChecksum: Long, k: Int): Boolean { - val crc = CRC32() - crc.update(originalData) - // Official: (raw_crc ^ k ^ 0xFFFFFFFF) - // Java CRC32.getValue() = raw_crc ^ 0xFFFFFFFF - // So: official = getValue() ^ 0xFFFFFFFF ^ k ^ 0xFFFFFFFF = getValue() ^ k - val computed = (crc.value xor k.toLong()) and 0xFFFFFFFFL - return computed == expectedChecksum - } - - private fun selectIndices(k: Int, degree: Int, random: Random): IntArray { - val indices = (0 until k).shuffled(random).take(degree.coerceAtMost(k)) - return indices.toIntArray() - } - - private fun xorBlocks(blocks: List, indices: IntArray): ByteArray { - val result = blocks[indices[0]].clone() - for (i in 1 until indices.size) { - xorInPlace(result, blocks[indices[i]]) - } - return result - } - - private fun xorInPlace(dest: ByteArray, src: ByteArray) { - val len = minOf(dest.size, src.size) - var i = 0 - - // Process 8 bytes at a time using Long - while (i + 7 < len) { - val destLong = ((dest[i].toLong() and 0xFF) shl 56) or - ((dest[i + 1].toLong() and 0xFF) shl 48) or - ((dest[i + 2].toLong() and 0xFF) shl 40) or - ((dest[i + 3].toLong() and 0xFF) shl 32) or - ((dest[i + 4].toLong() and 0xFF) shl 24) or - ((dest[i + 5].toLong() and 0xFF) shl 16) or - ((dest[i + 6].toLong() and 0xFF) shl 8) or - (dest[i + 7].toLong() and 0xFF) - - val srcLong = ((src[i].toLong() and 0xFF) shl 56) or - ((src[i + 1].toLong() and 0xFF) shl 48) or - ((src[i + 2].toLong() and 0xFF) shl 40) or - ((src[i + 3].toLong() and 0xFF) shl 32) or - ((src[i + 4].toLong() and 0xFF) shl 24) or - ((src[i + 5].toLong() and 0xFF) shl 16) or - ((src[i + 6].toLong() and 0xFF) shl 8) or - (src[i + 7].toLong() and 0xFF) - - val result = destLong xor srcLong - - dest[i] = (result shr 56).toByte() - dest[i + 1] = (result shr 48).toByte() - dest[i + 2] = (result shr 40).toByte() - dest[i + 3] = (result shr 32).toByte() - dest[i + 4] = (result shr 24).toByte() - dest[i + 5] = (result shr 16).toByte() - dest[i + 6] = (result shr 8).toByte() - dest[i + 7] = result.toByte() - - i += 8 - } - - // Process remaining bytes - while (i < len) { - dest[i] = (dest[i].toInt() xor src[i].toInt()).toByte() - i++ - } - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/qrs/QRSConstants.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/qrs/QRSConstants.kt deleted file mode 100644 index 8893873461..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/qrs/QRSConstants.kt +++ /dev/null @@ -1,25 +0,0 @@ -package io.nekohasekai.sfa.qrs - -object QRSConstants { - const val OFFICIAL_URL_PREFIX = "https://qrss.netlify.app/#" - - const val DEFAULT_FRAME_COUNT = 200 - const val BITMAP_BUFFER_SIZE = 30 - const val RECOVERY_FACTOR = 1.3 - - // FPS settings - const val DEFAULT_FPS = 10 - const val MIN_FPS = 1 - const val MAX_FPS = 60 - - // Slice Size settings - const val DEFAULT_SLICE_SIZE = 500 - const val MIN_SLICE_SIZE = 100 - const val MAX_SLICE_SIZE = 1500 - - fun calculateRequiredFrames(dataSize: Int, sliceSize: Int): Int { - val k = (dataSize + sliceSize - 1) / sliceSize - if (k == 0) return 1 - return (k * RECOVERY_FACTOR).toInt().coerceAtLeast(k + 5) - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/qrs/QRSDecoder.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/qrs/QRSDecoder.kt deleted file mode 100644 index c33c8bb57d..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/qrs/QRSDecoder.kt +++ /dev/null @@ -1,181 +0,0 @@ -package io.nekohasekai.sfa.qrs - -import java.io.ByteArrayOutputStream -import java.util.Base64 -import java.util.zip.Inflater - -class QRSDecoder { - private var codec: LubyCodec? = null - private var state: LubyCodec.DecodingState? = null - private val processedHashes = mutableSetOf() - - private val inflater = Inflater() - private val decompressBuffer = ByteArray(8192) - private val outputBuffer = ByteArrayOutputStream(32768) - - data class DecodeProgress( - val decodedBlocks: Int, - val totalBlocks: Int, - val isComplete: Boolean, - val data: ByteArray? = null, - val error: String? = null, - ) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - other as DecodeProgress - return decodedBlocks == other.decodedBlocks && - totalBlocks == other.totalBlocks && - isComplete == other.isComplete && - data.contentEquals(other.data) && - error == other.error - } - - override fun hashCode(): Int { - var result = decodedBlocks - result = 31 * result + totalBlocks - result = 31 * result + isComplete.hashCode() - result = 31 * result + (data?.contentHashCode() ?: 0) - result = 31 * result + (error?.hashCode() ?: 0) - return result - } - } - - @Synchronized - fun processFrame(base64Content: String): DecodeProgress? { - val payload = try { - Base64.getDecoder().decode(base64Content) - } catch (e: Exception) { - return null - } - return processFrame(payload) - } - - @Synchronized - fun processFrame(payload: ByteArray): DecodeProgress? { - val hash = payload.contentHashCode() - if (hash in processedHashes) { - return state?.let { - DecodeProgress(it.decodedCount, it.totalBlocks, it.decodedCount == it.totalBlocks) - } - } - processedHashes.add(hash) - - val block = parsePayload(payload) ?: return null - - // Auto-detect dataset switch: if checksum changes, reset decoder - if (state != null && state!!.checksum != block.checksum) { - reset() - } - - if (codec == null) { - codec = LubyCodec(sliceSize = block.data.size) - state = codec!!.createDecodingState(block) - } - - val currentState = state!! - val complete = codec!!.processBlock(currentState, block) - - return if (complete) { - val assembledData = codec!!.assembleData(currentState) - val compressedData = assembledData.copyOf(currentState.compressedSize) - - val decompressedData = try { - decompress(compressedData) - } catch (e: Exception) { - null - } - - if (decompressedData != null) { - val checksumValid = codec!!.verifyChecksum( - decompressedData, - currentState.checksum, - currentState.totalBlocks, - ) - if (checksumValid) { - return DecodeProgress( - currentState.decodedCount, - currentState.totalBlocks, - true, - decompressedData, - ) - } - } - - val rawChecksumValid = codec!!.verifyChecksum( - compressedData, - currentState.checksum, - currentState.totalBlocks, - ) - if (rawChecksumValid) { - DecodeProgress(currentState.decodedCount, currentState.totalBlocks, true, compressedData) - } else { - DecodeProgress( - currentState.decodedCount, - currentState.totalBlocks, - true, - error = "Checksum verification failed", - ) - } - } else { - DecodeProgress(currentState.decodedCount, currentState.totalBlocks, false) - } - } - - @Synchronized - fun reset() { - codec = null - state = null - processedHashes.clear() - } - - val progress: DecodeProgress? - @Synchronized get() = state?.let { - DecodeProgress(it.decodedCount, it.totalBlocks, it.decodedCount == it.totalBlocks) - } - - private fun parsePayload(payload: ByteArray): LubyCodec.EncodedBlock? { - if (payload.size < 16) return null - - var offset = 0 - val degree = payload.readIntLE(offset) - offset += 4 - - if (degree <= 0 || payload.size < 4 + 4 * degree + 12) return null - - val indices = IntArray(degree) { - val idx = payload.readIntLE(offset) - offset += 4 - idx - } - - val totalBlocks = payload.readIntLE(offset) - offset += 4 - - val compressedSize = payload.readIntLE(offset) - offset += 4 - - val checksum = payload.readIntLE(offset).toLong() and 0xFFFFFFFFL - offset += 4 - - if (offset > payload.size) return null - - val data = payload.copyOfRange(offset, payload.size) - - return LubyCodec.EncodedBlock(degree, indices, totalBlocks, compressedSize, checksum, data) - } - - private fun decompress(data: ByteArray): ByteArray { - inflater.reset() - inflater.setInput(data) - outputBuffer.reset() - - while (!inflater.finished()) { - val count = inflater.inflate(decompressBuffer) - if (count == 0 && inflater.needsInput()) break - outputBuffer.write(decompressBuffer, 0, count) - } - - return outputBuffer.toByteArray() - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/qrs/QRSEncoder.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/qrs/QRSEncoder.kt deleted file mode 100644 index bd19bca20b..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/qrs/QRSEncoder.kt +++ /dev/null @@ -1,108 +0,0 @@ -package io.nekohasekai.sfa.qrs - -import java.io.ByteArrayOutputStream -import java.util.Base64 -import java.util.zip.Deflater - -class QRSEncoder(private val sliceSize: Int = QRSConstants.DEFAULT_SLICE_SIZE) { - private val codec = LubyCodec(sliceSize) - - companion object { - fun appendFileHeaderMeta(data: ByteArray, filename: String? = null, contentType: String? = null): ByteArray { - val meta = buildString { - append("{") - var hasContent = false - filename?.let { - append("\"filename\":\"") - append(escapeJson(it)) - append("\"") - hasContent = true - } - contentType?.let { - if (hasContent) append(",") - append("\"contentType\":\"") - append(escapeJson(it)) - append("\"") - } - append("}") - } - val metaBytes = meta.toByteArray(Charsets.ISO_8859_1) - - val result = ByteArray(4 + metaBytes.size + 4 + data.size) - var offset = 0 - - result.writeIntBE(offset, metaBytes.size) - offset += 4 - metaBytes.copyInto(result, offset) - offset += metaBytes.size - - result.writeIntBE(offset, data.size) - offset += 4 - data.copyInto(result, offset) - - return result - } - - private fun escapeJson(s: String): String = s.replace("\\", "\\\\") - .replace("\"", "\\\"") - .replace("\n", "\\n") - .replace("\r", "\\r") - .replace("\t", "\\t") - } - - data class QRSFrame(val content: String, val frameIndex: Int, val totalBlocks: Int) - - fun encode(data: ByteArray, urlPrefix: String = ""): Sequence { - val compressed = compress(data) - - return codec.encode(data, compressed, compressed.size).mapIndexed { index, block -> - val payload = buildPayload(block) - val base64 = Base64.getEncoder().encodeToString(payload) - QRSFrame("$urlPrefix$base64", index, block.totalBlocks) - } - } - - private fun compress(data: ByteArray): ByteArray { - val deflater = Deflater(Deflater.DEFAULT_COMPRESSION) - deflater.setInput(data) - deflater.finish() - - val outputStream = ByteArrayOutputStream(data.size) - val buffer = ByteArray(1024) - - while (!deflater.finished()) { - val count = deflater.deflate(buffer) - outputStream.write(buffer, 0, count) - } - - deflater.end() - return outputStream.toByteArray() - } - - private fun buildPayload(block: LubyCodec.EncodedBlock): ByteArray { - val headerSize = 4 + 4 * block.indices.size + 4 + 4 + 4 - val payload = ByteArray(headerSize + block.data.size) - var offset = 0 - - payload.writeIntLE(offset, block.degree) - offset += 4 - - for (idx in block.indices) { - payload.writeIntLE(offset, idx) - offset += 4 - } - - payload.writeIntLE(offset, block.totalBlocks) - offset += 4 - - payload.writeIntLE(offset, block.compressedSize) - offset += 4 - - payload.writeIntLE(offset, block.checksum.toInt()) - offset += 4 - - block.data.copyInto(payload, offset) - - return payload - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/qrs/SolitonDistribution.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/qrs/SolitonDistribution.kt deleted file mode 100644 index d588aa4245..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/qrs/SolitonDistribution.kt +++ /dev/null @@ -1,19 +0,0 @@ -package io.nekohasekai.sfa.qrs - -import kotlin.random.Random - -object SolitonDistribution { - fun sample(k: Int, random: Random): Int { - if (k <= 0) return 1 - - val p = random.nextDouble() - var cdf = 1.0 / k - if (p < cdf) return 1 - - for (d in 2..k) { - cdf += 1.0 / (d * (d - 1)) - if (p < cdf) return d - } - return k - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/MainActivity.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/MainActivity.kt new file mode 100644 index 0000000000..319157f3c0 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/MainActivity.kt @@ -0,0 +1,435 @@ +package io.nekohasekai.sfa.ui + +import android.Manifest +import android.annotation.SuppressLint +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.net.VpnService +import android.os.Build +import android.os.Bundle +import android.text.Html +import androidx.activity.result.contract.ActivityResultContract +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.RequiresApi +import androidx.core.content.ContextCompat +import androidx.core.view.isVisible +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.lifecycleScope +import androidx.navigation.NavController +import androidx.navigation.NavDestination +import androidx.navigation.fragment.NavHostFragment +import androidx.navigation.ui.AppBarConfiguration +import androidx.navigation.ui.navigateUp +import androidx.navigation.ui.setupActionBarWithNavController +import androidx.navigation.ui.setupWithNavController +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.libbox.ProfileContent +import io.nekohasekai.sfa.Application +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.bg.ServiceConnection +import io.nekohasekai.sfa.bg.ServiceNotification +import io.nekohasekai.sfa.constant.Action +import io.nekohasekai.sfa.constant.Alert +import io.nekohasekai.sfa.constant.ServiceMode +import io.nekohasekai.sfa.constant.Status +import io.nekohasekai.sfa.database.Profile +import io.nekohasekai.sfa.database.ProfileManager +import io.nekohasekai.sfa.database.Settings +import io.nekohasekai.sfa.database.TypedProfile +import io.nekohasekai.sfa.databinding.ActivityMainBinding +import io.nekohasekai.sfa.ktx.errorDialogBuilder +import io.nekohasekai.sfa.ktx.hasPermission +import io.nekohasekai.sfa.ktx.launchCustomTab +import io.nekohasekai.sfa.ui.profile.NewProfileActivity +import io.nekohasekai.sfa.ui.shared.AbstractActivity +import io.nekohasekai.sfa.utils.MIUIUtils +import io.nekohasekai.sfa.vendor.Vendor +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File +import java.util.Date + +class MainActivity : AbstractActivity(), + ServiceConnection.Callback { + + companion object { + private const val TAG = "MainActivity" + } + + private lateinit var navHostFragment: NavHostFragment + private lateinit var navController: NavController + private lateinit var appBarConfiguration: AppBarConfiguration + + private val connection = ServiceConnection(this, this) + + val serviceStatus = MutableLiveData(Status.Stopped) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + navHostFragment = + supportFragmentManager.findFragmentById(R.id.nav_host_fragment_activity_my) as NavHostFragment + navController = navHostFragment.navController + navController.setGraph(R.navigation.mobile_navigation) + navController.addOnDestinationChangedListener(::onDestinationChanged) + appBarConfiguration = + AppBarConfiguration( + setOf( + R.id.navigation_dashboard, + R.id.navigation_log, + R.id.navigation_configuration, + R.id.navigation_settings, + ) + ) + setupActionBarWithNavController(navController, appBarConfiguration) + binding.navView.setupWithNavController(navController) + reconnect() + startIntegration() + + onNewIntent(intent) + } + + override fun onSupportNavigateUp(): Boolean { + return navController.navigateUp(appBarConfiguration) + } + + @Suppress("UNUSED_PARAMETER") + private fun onDestinationChanged( + navController: NavController, + navDestination: NavDestination, + bundle: Bundle? + ) { + val destinationId = navDestination.id + binding.dashboardTabContainer.isVisible = destinationId == R.id.navigation_dashboard + } + + override public fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + val uri = intent.data ?: return + when (intent.action) { + Action.OPEN_URL -> { + launchCustomTab(uri.toString()) + return + } + } + if (uri.scheme == "sing-box" && uri.host == "import-remote-profile") { + val profile = try { + Libbox.parseRemoteProfileImportLink(uri.toString()) + } catch (e: Exception) { + errorDialogBuilder(e).show() + return + } + MaterialAlertDialogBuilder(this) + .setTitle(R.string.import_remote_profile) + .setMessage( + getString( + R.string.import_remote_profile_message, + profile.name, + profile.host + ) + ) + .setPositiveButton(R.string.ok) { _, _ -> + startActivity(Intent(this, NewProfileActivity::class.java).apply { + putExtra("importName", profile.name) + putExtra("importURL", profile.url) + }) + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } else if (intent.action == Intent.ACTION_VIEW) { + try { + val data = contentResolver.openInputStream(uri)?.use { it.readBytes() } ?: return + val content = Libbox.decodeProfileContent(data) + MaterialAlertDialogBuilder(this) + .setTitle(R.string.import_profile) + .setMessage( + getString( + R.string.import_profile_message, + content.name + ) + ) + .setPositiveButton(R.string.ok) { _, _ -> + lifecycleScope.launch { + withContext(Dispatchers.IO) { + runCatching { + importProfile(content) + }.onFailure { + withContext(Dispatchers.Main) { + errorDialogBuilder(it).show() + } + } + } + } + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } catch (e: Exception) { + errorDialogBuilder(e).show() + } + } + } + + private suspend fun importProfile(content: ProfileContent) { + val typedProfile = TypedProfile() + val profile = Profile(name = content.name, typed = typedProfile) + profile.userOrder = ProfileManager.nextOrder() + when (content.type) { + Libbox.ProfileTypeLocal -> { + typedProfile.type = TypedProfile.Type.Local + } + + Libbox.ProfileTypeiCloud -> { + errorDialogBuilder(R.string.icloud_profile_unsupported).show() + return + } + + Libbox.ProfileTypeRemote -> { + typedProfile.type = TypedProfile.Type.Remote + typedProfile.remoteURL = content.remotePath + typedProfile.autoUpdate = content.autoUpdate + typedProfile.autoUpdateInterval = content.autoUpdateInterval + typedProfile.lastUpdated = Date(content.lastUpdated) + } + } + val configDirectory = File(filesDir, "configs").also { it.mkdirs() } + val configFile = File(configDirectory, "${profile.userOrder}.json") + configFile.writeText(content.config) + typedProfile.path = configFile.path + ProfileManager.create(profile) + } + + fun reconnect() { + connection.reconnect() + } + + private fun startIntegration() { + if (Vendor.checkUpdateAvailable()) { + lifecycleScope.launch(Dispatchers.IO) { + if (Settings.checkUpdateEnabled) { + Vendor.checkUpdate(this@MainActivity, false) + } + } + } + } + + @SuppressLint("NewApi") + fun startService() { + if (!ServiceNotification.checkPermission()) { + notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + return + } + startService0() + } + + private fun startService0() { + lifecycleScope.launch(Dispatchers.IO) { + if (Settings.rebuildServiceMode()) { + reconnect() + } + if (Settings.serviceMode == ServiceMode.VPN) { + if (prepare()) { + return@launch + } + } + val intent = Intent(Application.application, Settings.serviceClass()) + withContext(Dispatchers.Main) { + ContextCompat.startForegroundService(Application.application, intent) + } + } + } + + private val notificationPermissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestPermission() + ) { + if (Settings.dynamicNotification && !it) { + onServiceAlert(Alert.RequestNotificationPermission, null) + } else { + startService0() + } + } + + private val locationPermissionLauncher = + registerForActivityResult(ActivityResultContracts.RequestPermission()) { + if (it) { + if (it && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + requestBackgroundLocationPermission() + } else { + startService() + } + } + } + + private val backgroundLocationPermissionLauncher = + registerForActivityResult(ActivityResultContracts.RequestPermission()) { + if (it) { + startService() + } + } + + private val prepareLauncher = registerForActivityResult(PrepareService()) { + if (it) { + startService() + } else { + onServiceAlert(Alert.RequestVPNPermission, null) + } + } + + private class PrepareService : ActivityResultContract() { + override fun createIntent(context: Context, input: Intent): Intent { + return input + } + + override fun parseResult(resultCode: Int, intent: Intent?): Boolean { + return resultCode == RESULT_OK + } + } + + private suspend fun prepare() = withContext(Dispatchers.Main) { + try { + val intent = VpnService.prepare(this@MainActivity) + if (intent != null) { + prepareLauncher.launch(intent) + true + } else { + false + } + } catch (e: Exception) { + onServiceAlert(Alert.RequestVPNPermission, e.message) + false + } + } + + override fun onServiceStatusChanged(status: Status) { + serviceStatus.postValue(status) + } + + override fun onServiceAlert(type: Alert, message: String?) { + serviceStatus.value = Status.Stopped + + when (type) { + Alert.RequestLocationPermission -> { + return requestLocationPermission() + } + + else -> {} + } + + val builder = MaterialAlertDialogBuilder(this) + builder.setPositiveButton(R.string.ok, null) + when (type) { + Alert.RequestVPNPermission -> { + builder.setMessage(getString(R.string.service_error_missing_permission)) + } + + Alert.RequestNotificationPermission -> { + builder.setTitle(R.string.notification_permission_title) + builder.setMessage(R.string.notification_permission_required_description) + } + + Alert.EmptyConfiguration -> { + builder.setMessage(getString(R.string.service_error_empty_configuration)) + } + + Alert.StartCommandServer -> { + builder.setTitle(getString(R.string.service_error_title_start_command_server)) + builder.setMessage(message) + } + + Alert.CreateService -> { + builder.setTitle(getString(R.string.service_error_title_create_service)) + builder.setMessage(message) + } + + Alert.StartService -> { + builder.setTitle(getString(R.string.service_error_title_start_service)) + builder.setMessage(message) + + } + + else -> {} + } + builder.show() + } + + private fun requestLocationPermission() { + if (!hasPermission(Manifest.permission.ACCESS_FINE_LOCATION)) { + requestFineLocationPermission() + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + requestBackgroundLocationPermission() + } + } + + private fun requestFineLocationPermission() { + val message = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + Html.fromHtml( + getString(R.string.location_permission_description), + Html.FROM_HTML_MODE_LEGACY + ) + } else { + @Suppress("DEPRECATION") + Html.fromHtml(getString(R.string.location_permission_description)) + } + MaterialAlertDialogBuilder(this) + .setTitle(R.string.location_permission_title) + .setMessage(message) + .setPositiveButton(R.string.ok) { _, _ -> + requestFineLocationPermission0() + } + .setNegativeButton(R.string.no_thanks, null) + .setCancelable(false) + .show() + } + + private fun requestFineLocationPermission0() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + locationPermissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION) + } else { + openPermissionSettings() + } + } + + @RequiresApi(Build.VERSION_CODES.Q) + private fun requestBackgroundLocationPermission() { + MaterialAlertDialogBuilder(this) + .setTitle(R.string.location_permission_title) + .setMessage( + Html.fromHtml( + getString(R.string.location_permission_background_description), + Html.FROM_HTML_MODE_LEGACY + ) + ) + .setPositiveButton(R.string.ok) { _, _ -> + backgroundLocationPermissionLauncher.launch(Manifest.permission.ACCESS_BACKGROUND_LOCATION) + } + .setNegativeButton(R.string.no_thanks, null) + .setCancelable(false) + .show() + } + + private fun openPermissionSettings() { + if (MIUIUtils.isMIUI) { + try { + MIUIUtils.openPermissionSettings(this) + return + } catch (ignored: Exception) { + } + } + + try { + val intent = Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS) + intent.data = Uri.parse("package:$packageName") + startActivity(intent) + } catch (e: Exception) { + errorDialogBuilder(e).show() + } + } + + override fun onDestroy() { + connection.disconnect() + super.onDestroy() + } + +} \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/ShortcutActivity.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/ShortcutActivity.kt new file mode 100644 index 0000000000..a91e0b6c35 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/ShortcutActivity.kt @@ -0,0 +1,94 @@ +package io.nekohasekai.sfa.ui + +import android.app.Activity +import android.app.KeyguardManager +import android.content.Intent +import android.content.pm.ShortcutManager +import android.os.Build +import android.os.Bundle +import androidx.core.content.getSystemService +import androidx.core.content.pm.ShortcutInfoCompat +import androidx.core.content.pm.ShortcutManagerCompat +import androidx.core.graphics.drawable.IconCompat +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.bg.BoxService +import io.nekohasekai.sfa.bg.ServiceConnection +import io.nekohasekai.sfa.constant.Status + +class ShortcutActivity : Activity(), ServiceConnection.Callback { + + private val connection = ServiceConnection(this, this, false) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (intent.action == Intent.ACTION_CREATE_SHORTCUT) { + setResult( + RESULT_OK, ShortcutManagerCompat.createShortcutResultIntent( + this, + ShortcutInfoCompat.Builder(this, "toggle") + .setIntent( + Intent( + this, + ShortcutActivity::class.java + ).setAction(Intent.ACTION_MAIN) + ) + .setIcon( + IconCompat.createWithResource( + this, + R.mipmap.ic_launcher + ) + ) + .setShortLabel(getString(R.string.quick_toggle)) + .build() + ) + ) + finish() + } else { + val keyguardManager = getSystemService() + if (keyguardManager?.isKeyguardLocked == true) { + if (Build.VERSION.SDK_INT >= 26) { + keyguardManager.requestDismissKeyguard(this, object : KeyguardManager.KeyguardDismissCallback() { + override fun onDismissSucceeded() { + super.onDismissSucceeded() + connectAndToggle() + } + override fun onDismissCancelled() { + super.onDismissCancelled() + finish() + } + override fun onDismissError() { + super.onDismissError() + finish() + } + }) + } else { + finish() + } + } else { + connectAndToggle() + } + } + } + + private fun connectAndToggle() { + connection.connect() + if (Build.VERSION.SDK_INT >= 25) { + getSystemService()?.reportShortcutUsed("toggle") + } + } + + override fun onServiceStatusChanged(status: Status) { + when (status) { + Status.Started -> BoxService.stop() + Status.Stopped -> BoxService.start() + else -> {} + } + finish() + } + + override fun onDestroy() { + connection.disconnect() + super.onDestroy() + } + +} \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/model/Groups.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/dashboard/Groups.kt similarity index 78% rename from sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/model/Groups.kt rename to sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/dashboard/Groups.kt index 5c92160977..4a9cb19f0c 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/model/Groups.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/dashboard/Groups.kt @@ -1,6 +1,5 @@ -package io.nekohasekai.sfa.compose.model +package io.nekohasekai.sfa.ui.dashboard -import androidx.compose.runtime.Immutable import io.nekohasekai.libbox.OutboundGroup import io.nekohasekai.libbox.OutboundGroupItem import io.nekohasekai.libbox.OutboundGroupItemIterator @@ -11,7 +10,7 @@ data class Group( val selectable: Boolean, var selected: String, var isExpand: Boolean, - val items: List, + var items: List, ) { constructor(item: OutboundGroup) : this( item.tag, @@ -23,8 +22,12 @@ data class Group( ) } -@Immutable -data class GroupItem(val tag: String, val type: String, val urlTestTime: Long, val urlTestDelay: Int) { +data class GroupItem( + val tag: String, + val type: String, + val urlTestTime: Long, + val urlTestDelay: Int, +) { constructor(item: OutboundGroupItem) : this( item.tag, item.type, @@ -39,4 +42,4 @@ internal fun OutboundGroupItemIterator.toList(): List { list.add(next()) } return list -} +} \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/dashboard/GroupsFragment.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/dashboard/GroupsFragment.kt new file mode 100644 index 0000000000..b851bb5976 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/dashboard/GroupsFragment.kt @@ -0,0 +1,328 @@ +package io.nekohasekai.sfa.ui.dashboard + +import android.annotation.SuppressLint +import android.os.Bundle +import android.text.TextWatcher +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.isInvisible +import androidx.core.view.isVisible +import androidx.core.widget.addTextChangedListener +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.SimpleItemAnimator +import com.google.android.material.textfield.MaterialAutoCompleteTextView +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.libbox.OutboundGroup +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.constant.Status +import io.nekohasekai.sfa.databinding.FragmentDashboardGroupsBinding +import io.nekohasekai.sfa.databinding.ViewDashboardGroupBinding +import io.nekohasekai.sfa.databinding.ViewDashboardGroupItemBinding +import io.nekohasekai.sfa.ktx.colorForURLTestDelay +import io.nekohasekai.sfa.ktx.errorDialogBuilder +import io.nekohasekai.sfa.ktx.text +import io.nekohasekai.sfa.ui.MainActivity +import io.nekohasekai.sfa.utils.CommandClient +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + + +class GroupsFragment : Fragment(), CommandClient.Handler { + + private val activity: MainActivity? get() = super.getActivity() as MainActivity? + private var binding: FragmentDashboardGroupsBinding? = null + private var adapter: Adapter? = null + private val commandClient = + CommandClient(lifecycleScope, CommandClient.ConnectionType.Groups, this) + + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? + ): View { + val binding = FragmentDashboardGroupsBinding.inflate(inflater, container, false) + this.binding = binding + onCreate() + return binding.root + } + + private fun onCreate() { + val activity = activity ?: return + val binding = binding ?: return + adapter = Adapter() + binding.container.adapter = adapter + binding.container.layoutManager = LinearLayoutManager(requireContext()) + activity.serviceStatus.observe(viewLifecycleOwner) { + if (it == Status.Started) { + commandClient.connect() + } + } + } + + override fun onDestroyView() { + super.onDestroyView() + binding = null + commandClient.disconnect() + } + + private var displayed = false + private fun updateDisplayed(newValue: Boolean) { + val binding = binding ?: return + if (displayed != newValue) { + displayed = newValue + binding.statusText.isVisible = !displayed + binding.container.isVisible = displayed + } + } + + override fun onConnected() { + lifecycleScope.launch(Dispatchers.Main) { + updateDisplayed(true) + } + } + + override fun onDisconnected() { + lifecycleScope.launch(Dispatchers.Main) { + updateDisplayed(false) + } + } + + @SuppressLint("NotifyDataSetChanged") + override fun updateGroups(newGroups: MutableList) { + val adapter = adapter ?: return + activity?.runOnUiThread { + updateDisplayed(newGroups.isNotEmpty()) + adapter.setGroups(newGroups.map(::Group)) + } + } + + private class Adapter : RecyclerView.Adapter() { + + private lateinit var groups: MutableList + + @SuppressLint("NotifyDataSetChanged") + fun setGroups(newGroups: List) { + if (!::groups.isInitialized || groups.size != newGroups.size) { + groups = newGroups.toMutableList() + notifyDataSetChanged() + } else { + newGroups.forEachIndexed { index, group -> + if (this.groups[index] != group) { + this.groups[index] = group + notifyItemChanged(index) + } + } + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GroupView { + return GroupView( + ViewDashboardGroupBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ), + ) + } + + override fun getItemCount(): Int { + if (!::groups.isInitialized) { + return 0 + } + return groups.size + } + + override fun onBindViewHolder(holder: GroupView, position: Int) { + holder.bind(groups[position]) + } + } + + private class GroupView(val binding: ViewDashboardGroupBinding) : + RecyclerView.ViewHolder(binding.root) { + + private lateinit var group: Group + private lateinit var items: List + private lateinit var adapter: ItemAdapter + private var textWatcher: TextWatcher? = null + + @OptIn(DelicateCoroutinesApi::class) + @SuppressLint("NotifyDataSetChanged") + fun bind(group: Group) { + this.group = group + binding.groupName.text = group.tag + binding.groupType.text = Libbox.proxyDisplayType(group.type) + binding.urlTestButton.setOnClickListener { + GlobalScope.launch { + runCatching { + Libbox.newStandaloneCommandClient().urlTest(group.tag) + }.onFailure { + withContext(Dispatchers.Main) { + binding.root.context.errorDialogBuilder(it).show() + } + } + } + } + items = group.items + if (!::adapter.isInitialized) { + adapter = ItemAdapter(this, group, items.toMutableList()) + binding.itemList.adapter = adapter + (binding.itemList.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = + false + binding.itemList.layoutManager = GridLayoutManager(binding.root.context, 2) + } else { + adapter.group = group + adapter.setItems(items) + } + updateExpand() + } + + @OptIn(DelicateCoroutinesApi::class) + private fun updateExpand(isExpand: Boolean? = null) { + val newExpandStatus = isExpand ?: group.isExpand + if (isExpand != null) { + GlobalScope.launch { + runCatching { + Libbox.newStandaloneCommandClient().setGroupExpand(group.tag, isExpand) + }.onFailure { + withContext(Dispatchers.Main) { + binding.root.context.errorDialogBuilder(it).show() + } + } + } + } + binding.itemList.isVisible = newExpandStatus + binding.groupSelected.isVisible = !newExpandStatus + val textView = (binding.groupSelected.editText as MaterialAutoCompleteTextView) + if (textWatcher != null) { + textView.removeTextChangedListener(textWatcher) + } + if (!newExpandStatus) { + binding.groupSelected.text = group.selected + binding.groupSelected.isEnabled = group.selectable + if (group.selectable) { + textView.setSimpleItems(group.items.toList().map { it.tag }.toTypedArray()) + textWatcher = textView.addTextChangedListener { + val selected = textView.text.toString() + if (selected != group.selected) { + updateSelected(group, selected) + } + GlobalScope.launch { + runCatching { + Libbox.newStandaloneCommandClient() + .selectOutbound(group.tag, selected) + }.onFailure { + withContext(Dispatchers.Main) { + binding.root.context.errorDialogBuilder(it).show() + } + } + } + } + } + } + if (newExpandStatus) { + binding.urlTestButton.isVisible = true + binding.expandButton.setImageResource(R.drawable.ic_expand_less_24) + } else { + binding.urlTestButton.isVisible = false + binding.expandButton.setImageResource(R.drawable.ic_expand_more_24) + } + binding.expandButton.setOnClickListener { + updateExpand(!binding.itemList.isVisible) + } + } + + fun updateSelected(group: Group, itemTag: String) { + val oldSelected = items.indexOfFirst { it.tag == group.selected } + group.selected = itemTag + if (oldSelected != -1) { + adapter.notifyItemChanged(oldSelected) + } + } + } + + private class ItemAdapter( + val groupView: GroupView, + var group: Group, + private var items: MutableList = mutableListOf() + ) : + RecyclerView.Adapter() { + + @SuppressLint("NotifyDataSetChanged") + fun setItems(newItems: List) { + if (items.size != newItems.size) { + items = newItems.toMutableList() + notifyDataSetChanged() + } else { + newItems.forEachIndexed { index, item -> + if (items[index] != item) { + items[index] = item + notifyItemChanged(index) + } + } + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ItemGroupView { + return ItemGroupView( + ViewDashboardGroupItemBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + ) + } + + override fun getItemCount(): Int { + return items.size + } + + override fun onBindViewHolder(holder: ItemGroupView, position: Int) { + holder.bind(groupView, group, items[position]) + } + } + + private class ItemGroupView(val binding: ViewDashboardGroupItemBinding) : + RecyclerView.ViewHolder(binding.root) { + + @OptIn(DelicateCoroutinesApi::class) + fun bind(groupView: GroupView, group: Group, item: GroupItem) { + if (group.selectable) { + binding.itemCard.setOnClickListener { + binding.selectedView.isVisible = true + groupView.updateSelected(group, item.tag) + GlobalScope.launch { + runCatching { + Libbox.newStandaloneCommandClient().selectOutbound(group.tag, item.tag) + }.onFailure { + withContext(Dispatchers.Main) { + binding.root.context.errorDialogBuilder("select outbound: ${it.localizedMessage}") + .show() + } + } + } + } + } + binding.selectedView.isInvisible = group.selected != item.tag + binding.itemName.text = item.tag + binding.itemType.text = Libbox.proxyDisplayType(item.type) + binding.itemStatus.isVisible = item.urlTestTime > 0 + if (item.urlTestTime > 0) { + binding.itemStatus.text = "${item.urlTestDelay}ms" + binding.itemStatus.setTextColor( + colorForURLTestDelay( + binding.root.context, + item.urlTestDelay + ) + ) + } + } + } +} + diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/dashboard/OverviewFragment.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/dashboard/OverviewFragment.kt new file mode 100644 index 0000000000..75ac88cb35 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/dashboard/OverviewFragment.kt @@ -0,0 +1,379 @@ +package io.nekohasekai.sfa.ui.dashboard + +import android.annotation.SuppressLint +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.divider.MaterialDividerItemDecoration +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.libbox.StatusMessage +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.bg.BoxService +import io.nekohasekai.sfa.constant.Status +import io.nekohasekai.sfa.database.Profile +import io.nekohasekai.sfa.database.ProfileManager +import io.nekohasekai.sfa.database.Settings +import io.nekohasekai.sfa.databinding.FragmentDashboardOverviewBinding +import io.nekohasekai.sfa.databinding.ViewClashModeButtonBinding +import io.nekohasekai.sfa.databinding.ViewProfileItemBinding +import io.nekohasekai.sfa.ktx.errorDialogBuilder +import io.nekohasekai.sfa.ktx.getAttrColor +import io.nekohasekai.sfa.ui.MainActivity +import io.nekohasekai.sfa.utils.CommandClient +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class OverviewFragment : Fragment() { + + private val activity: MainActivity? get() = super.getActivity() as MainActivity? + private var binding: FragmentDashboardOverviewBinding? = null + private val statusClient = + CommandClient(lifecycleScope, CommandClient.ConnectionType.Status, StatusClient()) + private val clashModeClient = + CommandClient(lifecycleScope, CommandClient.ConnectionType.ClashMode, ClashModeClient()) + + private var adapter: Adapter? = null + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? + ): View { + val binding = FragmentDashboardOverviewBinding.inflate(inflater, container, false) + this.binding = binding + onCreate() + return binding.root + } + + private fun onCreate() { + val activity = activity ?: return + val binding = binding ?: return + binding.profileList.adapter = Adapter(lifecycleScope, binding).apply { + adapter = this + reload() + } + binding.profileList.layoutManager = LinearLayoutManager(requireContext()) + val divider = MaterialDividerItemDecoration(requireContext(), LinearLayoutManager.VERTICAL) + divider.isLastItemDecorated = false + binding.profileList.addItemDecoration(divider) + activity.serviceStatus.observe(viewLifecycleOwner) { + binding.statusContainer.isVisible = it == Status.Starting || it == Status.Started + when (it) { + Status.Stopped -> { + binding.clashModeCard.isVisible = false + binding.systemProxyCard.isVisible = false + + } + + Status.Started -> { + statusClient.connect() + clashModeClient.connect() + reloadSystemProxyStatus() + + } + + else -> {} + } + } + + ProfileManager.registerCallback(this::updateProfiles) + } + + override fun onDestroyView() { + super.onDestroyView() + adapter = null + binding = null + statusClient.disconnect() + clashModeClient.disconnect() + ProfileManager.unregisterCallback(this::updateProfiles) + } + + private fun updateProfiles() { + adapter?.reload() + } + + private fun reloadSystemProxyStatus() { + val binding = binding ?: return + lifecycleScope.launch(Dispatchers.IO) { + val status = Libbox.newStandaloneCommandClient().systemProxyStatus + withContext(Dispatchers.Main) { + binding.systemProxyCard.isVisible = status.available + binding.systemProxySwitch.setOnCheckedChangeListener(null) + binding.systemProxySwitch.isChecked = status.enabled + var reloading = false + binding.systemProxySwitch.setOnCheckedChangeListener { buttonView, isChecked -> + synchronized(this@OverviewFragment) { + if (reloading) return@setOnCheckedChangeListener + reloading = true + binding.systemProxySwitch.isEnabled = false + lifecycleScope.launch(Dispatchers.IO) { + Settings.systemProxyEnabled = isChecked + runCatching { + Libbox.newStandaloneCommandClient().setSystemProxyEnabled(isChecked) + }.onFailure { + withContext(Dispatchers.Main) { + buttonView.context.errorDialogBuilder(it).show() + } + } + withContext(Dispatchers.Main) { + delay(1000L) + binding.systemProxySwitch.isEnabled = true + } + } + } + } + } + } + } + + inner class StatusClient : CommandClient.Handler { + + override fun onConnected() { + val binding = binding ?: return + lifecycleScope.launch(Dispatchers.Main) { + binding.memoryText.text = getString(R.string.loading) + binding.goroutinesText.text = getString(R.string.loading) + } + } + + override fun onDisconnected() { + val binding = binding ?: return + lifecycleScope.launch(Dispatchers.Main) { + binding.memoryText.text = getString(R.string.loading) + binding.goroutinesText.text = getString(R.string.loading) + } + } + + override fun updateStatus(status: StatusMessage) { + val binding = binding ?: return + lifecycleScope.launch(Dispatchers.Main) { + binding.memoryText.text = Libbox.formatBytes(status.memory) + binding.goroutinesText.text = status.goroutines.toString() + val trafficAvailable = status.trafficAvailable + binding.trafficContainer.isVisible = trafficAvailable + if (trafficAvailable) { + binding.inboundConnectionsText.text = status.connectionsIn.toString() + binding.outboundConnectionsText.text = status.connectionsOut.toString() + binding.uplinkText.text = Libbox.formatBytes(status.uplink) + "/s" + binding.downlinkText.text = Libbox.formatBytes(status.downlink) + "/s" + binding.uplinkTotalText.text = Libbox.formatBytes(status.uplinkTotal) + binding.downlinkTotalText.text = Libbox.formatBytes(status.downlinkTotal) + } + } + } + + } + + inner class ClashModeClient : CommandClient.Handler { + + override fun initializeClashMode(modeList: List, currentMode: String) { + val binding = binding ?: return + if (modeList.size > 1) { + lifecycleScope.launch(Dispatchers.Main) { + binding.clashModeCard.isVisible = true + binding.clashModeList.adapter = ClashModeAdapter(modeList, currentMode) + binding.clashModeList.layoutManager = + GridLayoutManager( + requireContext(), + if (modeList.size < 3) modeList.size else 3 + ) + } + } else { + lifecycleScope.launch(Dispatchers.Main) { + binding.clashModeCard.isVisible = false + } + } + } + + @SuppressLint("NotifyDataSetChanged") + override fun updateClashMode(newMode: String) { + val binding = binding ?: return + val adapter = binding.clashModeList.adapter as? ClashModeAdapter ?: return + adapter.selected = newMode + lifecycleScope.launch(Dispatchers.Main) { + adapter.notifyDataSetChanged() + } + } + + } + + private inner class ClashModeAdapter( + val items: List, + var selected: String + ) : + RecyclerView.Adapter() { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ClashModeItemView { + val view = ClashModeItemView( + ViewClashModeButtonBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + ) + view.binding.clashModeButton.clipToOutline = true + return view + } + + override fun getItemCount(): Int { + return items.size + } + + override fun onBindViewHolder(holder: ClashModeItemView, position: Int) { + holder.bind(items[position], selected) + } + } + + private inner class ClashModeItemView(val binding: ViewClashModeButtonBinding) : + RecyclerView.ViewHolder(binding.root) { + + @OptIn(DelicateCoroutinesApi::class) + fun bind(item: String, selected: String) { + binding.clashModeButtonText.text = item + if (item != selected) { + binding.clashModeButtonText.setTextColor( + binding.root.context.getAttrColor(com.google.android.material.R.attr.colorOnPrimaryContainer) + ) + binding.clashModeButton.setBackgroundResource(R.drawable.bg_rounded_rectangle) + binding.clashModeButton.setOnClickListener { + runCatching { + Libbox.newStandaloneCommandClient().setClashMode(item) + clashModeClient.connect() + }.onFailure { + GlobalScope.launch(Dispatchers.Main) { + binding.root.context.errorDialogBuilder(it).show() + } + } + } + } else { + binding.clashModeButtonText.setTextColor( + binding.root.context.getAttrColor(com.google.android.material.R.attr.colorOnPrimary) + ) + binding.clashModeButton.setBackgroundResource(R.drawable.bg_rounded_rectangle_active) + binding.clashModeButton.isClickable = false + } + + } + } + + + class Adapter( + internal val scope: CoroutineScope, + internal val parent: FragmentDashboardOverviewBinding + ) : + RecyclerView.Adapter() { + + internal var items: MutableList = mutableListOf() + internal var selectedProfileID = -1L + internal var lastSelectedIndex: Int? = null + internal fun reload() { + scope.launch(Dispatchers.IO) { + items = ProfileManager.list().toMutableList() + if (items.isNotEmpty()) { + selectedProfileID = Settings.selectedProfile + for ((index, profile) in items.withIndex()) { + if (profile.id == selectedProfileID) { + lastSelectedIndex = index + break + } + } + if (lastSelectedIndex == null) { + lastSelectedIndex = 0 + selectedProfileID = items[0].id + Settings.selectedProfile = selectedProfileID + } + } + withContext(Dispatchers.Main) { + parent.statusText.isVisible = items.isEmpty() + parent.container.isVisible = items.isNotEmpty() + notifyDataSetChanged() + } + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder { + return Holder( + this, + ViewProfileItemBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + ) + } + + override fun onBindViewHolder(holder: Holder, position: Int) { + holder.bind(items[position]) + } + + override fun getItemCount(): Int { + return items.size + } + + } + + class Holder( + private val adapter: Adapter, + private val binding: ViewProfileItemBinding + ) : + RecyclerView.ViewHolder(binding.root) { + + internal fun bind(profile: Profile) { + binding.profileName.text = profile.name + binding.profileSelected.setOnCheckedChangeListener(null) + binding.profileSelected.isChecked = profile.id == adapter.selectedProfileID + binding.profileSelected.setOnCheckedChangeListener { _, isChecked -> + if (isChecked) { + adapter.parent.profileList.isClickable = false + adapter.selectedProfileID = profile.id + adapter.lastSelectedIndex?.let { index -> + adapter.notifyItemChanged(index) + } + adapter.lastSelectedIndex = adapterPosition + adapter.scope.launch(Dispatchers.IO) { + switchProfile(profile) + withContext(Dispatchers.Main) { + adapter.parent.profileList.isEnabled = true + } + } + } + } + binding.root.setOnClickListener { + binding.profileSelected.toggle() + } + } + + private suspend fun switchProfile(profile: Profile) { + Settings.selectedProfile = profile.id + val mainActivity = (binding.root.context as? MainActivity) ?: return + val started = mainActivity.serviceStatus.value == Status.Started + if (!started) { + return + } + val restart = Settings.rebuildServiceMode() + if (restart) { + mainActivity.reconnect() + BoxService.stop() + delay(1000L) + mainActivity.startService() + return + } + runCatching { + Libbox.newStandaloneCommandClient().serviceReload() + }.onFailure { + withContext(Dispatchers.Main) { + mainActivity.errorDialogBuilder(it).show() + } + } + } + } + +} \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/debug/DebugActivity.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/debug/DebugActivity.kt new file mode 100644 index 0000000000..cf837910b7 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/debug/DebugActivity.kt @@ -0,0 +1,19 @@ +package io.nekohasekai.sfa.ui.debug + +import android.content.Intent +import android.os.Bundle +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.databinding.ActivityDebugBinding +import io.nekohasekai.sfa.ui.shared.AbstractActivity + +class DebugActivity : AbstractActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setTitle(R.string.title_debug) + binding.scanVPNButton.setOnClickListener { + startActivity(Intent(this, VPNScanActivity::class.java)) + } + } +} \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/debug/VPNScanActivity.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/debug/VPNScanActivity.kt new file mode 100644 index 0000000000..8439d197eb --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/debug/VPNScanActivity.kt @@ -0,0 +1,262 @@ +package io.nekohasekai.sfa.ui.debug + +import android.Manifest +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.os.Build +import android.os.Bundle +import android.util.Log +import android.view.ViewGroup +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.isVisible +import androidx.core.view.updatePadding +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.android.tools.smali.dexlib2.dexbacked.DexBackedDexFile +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.databinding.ActivityVpnScanBinding +import io.nekohasekai.sfa.databinding.ViewVpnAppItemBinding +import io.nekohasekai.sfa.ktx.dp2px +import io.nekohasekai.sfa.ktx.toStringIterator +import io.nekohasekai.sfa.ui.shared.AbstractActivity +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File +import java.util.zip.ZipFile +import kotlin.math.roundToInt + +class VPNScanActivity : AbstractActivity() { + + private var adapter: Adapter? = null + private val appInfoList = mutableListOf() + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setTitle(R.string.title_scan_vpn) + + ViewCompat.setOnApplyWindowInsetsListener(binding.scanVPNResult) { view, windowInsets -> + val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + view.updatePadding(bottom = insets.bottom + dp2px(16)) + WindowInsetsCompat.CONSUMED + } + + binding.scanVPNResult.adapter = Adapter().also { + adapter = it + } + binding.scanVPNResult.layoutManager = LinearLayoutManager(this) + lifecycleScope.launch(Dispatchers.IO) { + scanVPN() + } + } + + class VPNType( + val appType: String?, + val coreType: VPNCoreType?, + ) + + class VPNCoreType( + val coreType: String, + val corePath: String, + val goVersion: String + ) + + class AppInfo( + val packageInfo: PackageInfo, + val vpnType: VPNType, + ) + + inner class Adapter : RecyclerView.Adapter() { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder { + return Holder(ViewVpnAppItemBinding.inflate(layoutInflater, parent, false)) + } + + override fun getItemCount(): Int { + return appInfoList.size + } + + override fun onBindViewHolder(holder: Holder, position: Int) { + holder.bind(appInfoList[position]) + } + } + + class Holder( + private val binding: ViewVpnAppItemBinding + ) : + RecyclerView.ViewHolder(binding.root) { + + fun bind(element: AppInfo) { + binding.appIcon.setImageDrawable(element.packageInfo.applicationInfo!!.loadIcon(binding.root.context.packageManager)) + binding.appName.text = + element.packageInfo.applicationInfo!!.loadLabel(binding.root.context.packageManager) + binding.packageName.text = element.packageInfo.packageName + val appType = element.vpnType.appType + if (appType != null) { + binding.appTypeText.text = element.vpnType.appType + } else { + binding.appTypeText.setText(R.string.vpn_app_type_other) + } + val coreType = element.vpnType.coreType?.coreType + if (coreType != null) { + binding.coreTypeText.text = element.vpnType.coreType.coreType + } else { + binding.coreTypeText.setText(R.string.vpn_core_type_unknown) + } + val corePath = element.vpnType.coreType?.corePath.takeIf { !it.isNullOrBlank() } + if (corePath != null) { + binding.corePathLayout.isVisible = true + binding.corePathText.text = corePath + } else { + binding.corePathLayout.isVisible = false + } + + val goVersion = element.vpnType.coreType?.goVersion.takeIf { !it.isNullOrBlank() } + if (goVersion != null) { + binding.goVersionLayout.isVisible = true + binding.goVersionText.text = goVersion + } else { + binding.goVersionLayout.isVisible = false + } + } + } + + private suspend fun scanVPN() { + val adapter = adapter ?: return + val flag = + PackageManager.GET_SERVICES or if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + PackageManager.MATCH_UNINSTALLED_PACKAGES + } else { + @Suppress("DEPRECATION") + PackageManager.GET_UNINSTALLED_PACKAGES + } + val installedPackages = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + packageManager.getInstalledPackages(PackageManager.PackageInfoFlags.of(flag.toLong())) + } else { + @Suppress("DEPRECATION") + packageManager.getInstalledPackages(flag) + } + val vpnAppList = + installedPackages.filter { + it.services?.any { it.permission == Manifest.permission.BIND_VPN_SERVICE && it.applicationInfo != null } + ?: false + } + for ((index, packageInfo) in vpnAppList.withIndex()) { + val appType = runCatching { getVPNAppType(packageInfo) }.getOrNull() + val coreType = runCatching { getVPNCoreType(packageInfo) }.getOrNull() + appInfoList.add(AppInfo(packageInfo, VPNType(appType, coreType))) + withContext(Dispatchers.Main) { + adapter.notifyItemInserted(index) + binding.scanVPNResult.scrollToPosition(index) + binding.scanVPNProgress.setProgressCompat( + (((index + 1).toFloat() / vpnAppList.size.toFloat()) * 100).roundToInt(), + true + ) + } + System.gc() + } + withContext(Dispatchers.Main) { + binding.scanVPNProgress.isVisible = false + } + } + + companion object { + + private val v2rayNGClasses = listOf( + "com.v2ray.ang", + ".dto.V2rayConfig", + ".service.V2RayVpnService", + ) + + private val clashForAndroidClasses = listOf( + "com.github.kr328.clash", + ".core.Clash", + ".service.TunService", + ) + + private val sfaClasses = listOf( + "io.nekohasekai.sfa" + ) + + private val legacySagerNetClasses = listOf( + "io.nekohasekai.sagernet", + ".fmt.ConfigBuilder" + ) + + private val shadowsocksAndroidClasses = listOf( + "com.github.shadowsocks", + ".bg.VpnService", + "GuardedProcessPool" + ) + } + + private fun getVPNAppType(packageInfo: PackageInfo): String? { + ZipFile(File(packageInfo.applicationInfo!!.publicSourceDir)).use { packageFile -> + for (packageEntry in packageFile.entries()) { + if (!(packageEntry.name.startsWith("classes") && packageEntry.name.endsWith( + ".dex" + )) + ) { + continue + } + if (packageEntry.size > 15000000) { + continue + } + val input = packageFile.getInputStream(packageEntry).buffered() + val dexFile = try { + DexBackedDexFile.fromInputStream(null, input) + } catch (e: Exception) { + Log.e("VPNScanActivity", "Failed to read dex file", e) + continue + } + for (clazz in dexFile.classes) { + val clazzName = clazz.type.substring(1, clazz.type.length - 1) + .replace("/", ".") + .replace("$", ".") + for (v2rayNGClass in v2rayNGClasses) { + if (clazzName.contains(v2rayNGClass)) { + return "V2RayNG" + } + } + for (clashForAndroidClass in clashForAndroidClasses) { + if (clazzName.contains(clashForAndroidClass)) { + return "ClashForAndroid" + } + } + for (sfaClass in sfaClasses) { + if (clazzName.contains(sfaClass)) { + return "sing-box" + } + } + for (legacySagerNetClass in legacySagerNetClasses) { + if (clazzName.contains(legacySagerNetClass)) { + return "LegacySagerNet" + } + } + for (shadowsocksAndroidClass in shadowsocksAndroidClasses) { + if (clazzName.contains(shadowsocksAndroidClass)) { + return "shadowsocks-android" + } + } + } + } + return null + } + } + + private fun getVPNCoreType(packageInfo: PackageInfo): VPNCoreType? { + val packageFiles = mutableListOf(packageInfo.applicationInfo!!.publicSourceDir) + packageInfo.applicationInfo!!.splitPublicSourceDirs?.also { + packageFiles.addAll(it) + } + val vpnType = try { + Libbox.readAndroidVPNType(packageFiles.toStringIterator()) + } catch (ignored: Exception) { + return null + } + return VPNCoreType(vpnType.coreType, vpnType.corePath, vpnType.goVersion) + } + +} \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/main/ConfigurationFragment.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/main/ConfigurationFragment.kt new file mode 100644 index 0000000000..5d40991052 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/main/ConfigurationFragment.kt @@ -0,0 +1,281 @@ +package io.nekohasekai.sfa.ui.main + +import android.annotation.SuppressLint +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.widget.PopupMenu +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.database.Profile +import io.nekohasekai.sfa.database.ProfileManager +import io.nekohasekai.sfa.database.TypedProfile +import io.nekohasekai.sfa.databinding.FragmentConfigurationBinding +import io.nekohasekai.sfa.databinding.SheetAddProfileBinding +import io.nekohasekai.sfa.databinding.ViewConfigutationItemBinding +import io.nekohasekai.sfa.ktx.errorDialogBuilder +import io.nekohasekai.sfa.ktx.shareProfile +import io.nekohasekai.sfa.ktx.shareProfileURL +import io.nekohasekai.sfa.ui.MainActivity +import io.nekohasekai.sfa.ui.profile.EditProfileActivity +import io.nekohasekai.sfa.ui.profile.NewProfileActivity +import io.nekohasekai.sfa.ui.profile.QRScanActivity +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.text.DateFormat +import java.util.Collections + +class ConfigurationFragment : Fragment() { + + private var adapter: Adapter? = null + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? + ): View { + val binding = FragmentConfigurationBinding.inflate(inflater, container, false) + val adapter = Adapter(binding) + this.adapter = adapter + binding.profileList.also { + it.layoutManager = LinearLayoutManager(requireContext()) + it.adapter = adapter + ItemTouchHelper(object : + ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP or ItemTouchHelper.DOWN, 0) { + + override fun onMove( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + target: RecyclerView.ViewHolder + ): Boolean { + return adapter.move(viewHolder.adapterPosition, target.adapterPosition) + } + + override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { + } + + override fun onSelectedChanged( + viewHolder: RecyclerView.ViewHolder?, + actionState: Int + ) { + super.onSelectedChanged(viewHolder, actionState) + if (actionState == ItemTouchHelper.ACTION_STATE_IDLE) { + adapter.updateUserOrder() + } + } + }).attachToRecyclerView(it) + } + adapter.reload() + binding.fab.setOnClickListener { + AddProfileDialog().show(childFragmentManager, "add_profile") + } + ProfileManager.registerCallback(this::updateProfiles) + return binding.root + } + + class AddProfileDialog : BottomSheetDialogFragment(R.layout.sheet_add_profile) { + + private val importFromFile = + registerForActivityResult(ActivityResultContracts.GetContent(), ::onImportResult) + + private val scanQrCode = + registerForActivityResult(QRScanActivity.Contract(), ::onScanResult) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + val binding = SheetAddProfileBinding.bind(view) + binding.importFromFile.setOnClickListener { + importFromFile.launch("*/*") + } + binding.scanQrCode.setOnClickListener { + scanQrCode.launch(null) + } + binding.createManually.setOnClickListener { + dismiss() + startActivity(Intent(requireContext(), NewProfileActivity::class.java)) + } + } + + private fun onImportResult(result: Uri?) { + dismiss() + (activity as? MainActivity ?: return).onNewIntent(Intent(Intent.ACTION_VIEW, result)) + } + + private fun onScanResult(result: Intent?) { + dismiss() + (activity as? MainActivity ?: return).onNewIntent(result ?: return) + } + } + + override fun onResume() { + super.onResume() + adapter?.reload() + } + + override fun onDestroyView() { + super.onDestroyView() + ProfileManager.unregisterCallback(this::updateProfiles) + adapter = null + } + + private fun updateProfiles() { + adapter?.reload() + } + + inner class Adapter( + private val parent: FragmentConfigurationBinding + ) : + RecyclerView.Adapter() { + + internal var items: MutableList = mutableListOf() + internal val scope = lifecycleScope + internal val fragmentActivity = requireActivity() + + @SuppressLint("NotifyDataSetChanged") + internal fun reload() { + lifecycleScope.launch(Dispatchers.IO) { + val newItems = ProfileManager.list().toMutableList() + withContext(Dispatchers.Main) { + items = newItems + notifyDataSetChanged() + if (items.isEmpty()) { + parent.statusText.isVisible = true + parent.profileList.isVisible = false + } else if (parent.statusText.isVisible) { + parent.statusText.isVisible = false + parent.profileList.isVisible = true + } + } + } + } + + internal fun move(from: Int, to: Int): Boolean { + if (from < to) { + for (i in from until to) { + Collections.swap(items, i, i + 1) + } + } else { + for (i in from downTo to + 1) { + Collections.swap(items, i, i - 1) + } + } + notifyItemMoved(from, to) + return true + } + + @OptIn(DelicateCoroutinesApi::class) + internal fun updateUserOrder() { + items.forEachIndexed { index, profile -> + profile.userOrder = index.toLong() + } + GlobalScope.launch(Dispatchers.IO) { + ProfileManager.update(items) + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder { + return Holder( + this, + ViewConfigutationItemBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + ) + } + + override fun onBindViewHolder(holder: Holder, position: Int) { + holder.bind(items[position]) + } + + override fun getItemCount(): Int { + return items.size + } + + } + + class Holder(private val adapter: Adapter, private val binding: ViewConfigutationItemBinding) : + RecyclerView.ViewHolder(binding.root) { + + internal fun bind(profile: Profile) { + binding.profileName.text = profile.name + if (profile.typed.type == TypedProfile.Type.Remote) { + binding.profileLastUpdated.isVisible = true + binding.profileLastUpdated.text = binding.root.context.getString( + R.string.profile_item_last_updated, + DateFormat.getDateTimeInstance().format(profile.typed.lastUpdated) + ) + } else { + binding.profileLastUpdated.isVisible = false + } + binding.root.setOnClickListener { + val intent = Intent(binding.root.context, EditProfileActivity::class.java) + intent.putExtra("profile_id", profile.id) + it.context.startActivity(intent) + } + binding.moreButton.setOnClickListener { button -> + val popup = PopupMenu(button.context, button) + popup.setForceShowIcon(true) + popup.menuInflater.inflate(R.menu.profile_menu, popup.menu) + if (profile.typed.type != TypedProfile.Type.Remote) { + popup.menu.removeItem(R.id.action_share_url) + } + popup.setOnMenuItemClickListener { + when (it.itemId) { + R.id.action_share -> { + adapter.scope.launch(Dispatchers.IO) { + try { + button.context.shareProfile(profile) + } catch (e: Exception) { + withContext(Dispatchers.Main) { + button.context.errorDialogBuilder(e).show() + } + } + } + true + } + + R.id.action_share_url -> { + adapter.scope.launch(Dispatchers.IO) { + try { + adapter.fragmentActivity.shareProfileURL(profile) + } catch (e: Exception) { + withContext(Dispatchers.Main) { + button.context.errorDialogBuilder(e).show() + } + } + } + true + } + + R.id.action_delete -> { + adapter.items.remove(profile) + adapter.notifyItemRemoved(adapterPosition) + adapter.scope.launch(Dispatchers.IO) { + runCatching { + ProfileManager.delete(profile) + } + } + true + } + + else -> false + } + } + popup.show() + } + } + } + +} \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/main/DashboardFragment.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/main/DashboardFragment.kt new file mode 100644 index 0000000000..9a8367914e --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/main/DashboardFragment.kt @@ -0,0 +1,181 @@ +package io.nekohasekai.sfa.ui.main + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.annotation.StringRes +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.viewpager2.adapter.FragmentStateAdapter +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.tabs.TabLayoutMediator +import io.nekohasekai.libbox.DeprecatedNoteIterator +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.bg.BoxService +import io.nekohasekai.sfa.constant.Status +import io.nekohasekai.sfa.databinding.FragmentDashboardBinding +import io.nekohasekai.sfa.ktx.errorDialogBuilder +import io.nekohasekai.sfa.ktx.launchCustomTab +import io.nekohasekai.sfa.ui.MainActivity +import io.nekohasekai.sfa.ui.dashboard.GroupsFragment +import io.nekohasekai.sfa.ui.dashboard.OverviewFragment +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class DashboardFragment : Fragment(R.layout.fragment_dashboard) { + + private val activity: MainActivity? get() = super.getActivity() as MainActivity? + private var binding: FragmentDashboardBinding? = null + private var mediator: TabLayoutMediator? = null + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? + ): View { + val binding = FragmentDashboardBinding.inflate(inflater, container, false) + this.binding = binding + onCreate() + return binding.root + } + + private val adapter by lazy { Adapter(this) } + private fun onCreate() { + val activity = activity ?: return + val binding = binding ?: return + binding.dashboardPager.adapter = adapter + binding.dashboardPager.offscreenPageLimit = Page.values().size + activity.serviceStatus.observe(viewLifecycleOwner) { + when (it) { + Status.Stopped -> { + disablePager() + binding.fab.setImageResource(R.drawable.ic_play_arrow_24) + binding.fab.show() + binding.fab.isEnabled = true + } + + Status.Starting -> { + binding.fab.hide() + } + + Status.Started -> { + checkDeprecatedNotes() + enablePager() + binding.fab.setImageResource(R.drawable.ic_stop_24) + binding.fab.show() + binding.fab.isEnabled = true + } + + Status.Stopping -> { + disablePager() + binding.fab.hide() + } + + else -> {} + } + } + binding.fab.setOnClickListener { + when (activity.serviceStatus.value) { + Status.Stopped -> { + it.isEnabled = false + activity.startService() + } + + Status.Started -> { + BoxService.stop() + } + + else -> {} + } + } + } + + override fun onStart() { + super.onStart() + val activityBinding = activity?.binding ?: return + val binding = binding ?: return + if (mediator != null) return + mediator = TabLayoutMediator( + activityBinding.dashboardTabLayout, + binding.dashboardPager + ) { tab, position -> + tab.setText(Page.values()[position].titleRes) + }.apply { attach() } + } + + override fun onDestroyView() { + super.onDestroyView() + mediator?.detach() + mediator = null + binding?.dashboardPager?.adapter = null + binding = null + } + + private fun checkDeprecatedNotes() { + GlobalScope.launch(Dispatchers.IO) { + runCatching { + val notes = Libbox.newStandaloneCommandClient().deprecatedNotes + if (notes.hasNext()) { + withContext(Dispatchers.Main) { + loopShowDeprecatedNotes(notes) + } + } + }.onFailure { + withContext(Dispatchers.Main) { + activity?.errorDialogBuilder(it)?.show() + } + } + } + } + + private fun loopShowDeprecatedNotes(notes: DeprecatedNoteIterator) { + if (notes.hasNext()) { + val note = notes.next() + val builder = MaterialAlertDialogBuilder(requireContext()) + builder.setTitle(getString(R.string.service_error_title_deprecated_warning)) + builder.setMessage(note.message()) + builder.setPositiveButton(R.string.ok) { _, _ -> + loopShowDeprecatedNotes(notes) + } + if (!note.migrationLink.isNullOrBlank()) { + builder.setNeutralButton(R.string.service_error_deprecated_warning_documentation) { _, _ -> + requireContext().launchCustomTab(note.migrationLink) + loopShowDeprecatedNotes(notes) + } + } + builder.show() + } + } + + private fun enablePager() { + val activity = activity ?: return + val binding = binding ?: return + activity.binding.dashboardTabLayout.isVisible = true + binding.dashboardPager.isUserInputEnabled = true + } + + private fun disablePager() { + val activity = activity ?: return + val binding = binding ?: return + activity.binding.dashboardTabLayout.isVisible = false + binding.dashboardPager.isUserInputEnabled = false + binding.dashboardPager.setCurrentItem(0, false) + } + + enum class Page(@StringRes val titleRes: Int, val fragmentClass: Class) { + Overview(R.string.title_overview, OverviewFragment::class.java), + Groups(R.string.title_groups, GroupsFragment::class.java); + } + + class Adapter(parent: Fragment) : FragmentStateAdapter(parent) { + override fun getItemCount(): Int { + return Page.entries.size + } + + override fun createFragment(position: Int): Fragment { + return Page.entries[position].fragmentClass.getConstructor().newInstance() + } + } + +} \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/main/LogFragment.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/main/LogFragment.kt new file mode 100644 index 0000000000..2ae6da0f82 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/main/LogFragment.kt @@ -0,0 +1,181 @@ +package io.nekohasekai.sfa.ui.main + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.bg.BoxService +import io.nekohasekai.sfa.constant.Status +import io.nekohasekai.sfa.databinding.FragmentLogBinding +import io.nekohasekai.sfa.databinding.ViewLogTextItemBinding +import io.nekohasekai.sfa.ui.MainActivity +import io.nekohasekai.sfa.utils.ColorUtils +import io.nekohasekai.sfa.utils.CommandClient +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.util.LinkedList + +class LogFragment : Fragment(), CommandClient.Handler { + private val activity: MainActivity? get() = super.getActivity() as MainActivity? + private var binding: FragmentLogBinding? = null + private var adapter: Adapter? = null + private val commandClient = + CommandClient(lifecycleScope, CommandClient.ConnectionType.Log, this) + private val logList = LinkedList() + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? + ): View { + val binding = FragmentLogBinding.inflate(inflater, container, false) + this.binding = binding + onCreate() + return binding.root + } + + private fun onCreate() { + val activity = activity ?: return + val binding = binding ?: return + binding.logView.layoutManager = LinearLayoutManager(requireContext()) + binding.logView.adapter = Adapter(logList).also { adapter = it } + updateViews() + activity.serviceStatus.observe(viewLifecycleOwner) { + when (it) { + Status.Stopped -> { + binding.fab.setImageResource(R.drawable.ic_play_arrow_24) + binding.fab.show() + binding.statusText.setText(R.string.status_default) + } + + Status.Starting -> { + binding.fab.hide() + binding.statusText.setText(R.string.status_starting) + } + + Status.Started -> { + commandClient.connect() + binding.fab.setImageResource(R.drawable.ic_stop_24) + binding.fab.show() + binding.fab.isEnabled = true + binding.statusText.setText(R.string.status_started) + } + + Status.Stopping -> { + binding.fab.hide() + binding.statusText.setText(R.string.status_stopping) + } + + else -> {} + } + } + binding.fab.setOnClickListener { + when (activity.serviceStatus.value) { + Status.Stopped -> { + it.isEnabled = false + activity.startService() + } + + Status.Started -> { + BoxService.stop() + } + + else -> {} + } + } + } + + private fun updateViews(removeLen: Int = 0, insertLen: Int = 0) { + val activity = activity ?: return + val logAdapter = adapter ?: return + val binding = binding ?: return + if (logList.isEmpty()) { + binding.logView.isVisible = false + binding.statusText.isVisible = true + } else if (!binding.logView.isVisible) { + binding.logView.isVisible = true + binding.statusText.isVisible = false + } + if (insertLen == 0) { + logAdapter.notifyDataSetChanged() + if (logList.size > 0) { + binding.logView.scrollToPosition(logList.size - 1) + } + } else { + if (logList.size == 300) { + logAdapter.notifyItemRangeRemoved(0, removeLen) + } + logAdapter.notifyItemRangeInserted(logList.size - insertLen, insertLen) + binding.logView.scrollToPosition(logList.size - 1) + } + } + + override fun onDestroyView() { + super.onDestroyView() + commandClient.disconnect() + binding = null + adapter = null + } + + override fun onConnected() { + lifecycleScope.launch(Dispatchers.Main) { + logList.clear() + updateViews() + } + } + + override fun clearLogs() { + lifecycleScope.launch(Dispatchers.Main) { + logList.clear() + updateViews() + } + } + + override fun appendLogs(messageList: List) { + lifecycleScope.launch(Dispatchers.Main) { + val messageLen = messageList.size + val removeLen = logList.size + messageLen - 300 + logList.addAll(messageList) + if (removeLen > 0) { + repeat(removeLen) { + logList.removeFirst() + } + } + updateViews(removeLen, messageLen) + } + } + + + class Adapter(private val logList: LinkedList) : + RecyclerView.Adapter() { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): LogViewHolder { + return LogViewHolder( + ViewLogTextItemBinding.inflate( + LayoutInflater.from(parent.context), parent, false + ) + ) + } + + override fun onBindViewHolder(holder: LogViewHolder, position: Int) { + holder.bind(logList.getOrElse(position) { "" }) + } + + override fun getItemCount(): Int { + return logList.size + } + + } + + class LogViewHolder(private val binding: ViewLogTextItemBinding) : + RecyclerView.ViewHolder(binding.root) { + + fun bind(message: String) { + binding.text.text = ColorUtils.ansiEscapeToSpannable(binding.root.context, message) + } + } + +} \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/main/SettingsFragment.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/main/SettingsFragment.kt new file mode 100644 index 0000000000..93f7d38045 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/main/SettingsFragment.kt @@ -0,0 +1,152 @@ +package io.nekohasekai.sfa.ui.main + +import android.annotation.SuppressLint +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.RequiresApi +import androidx.core.view.isGone +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.sfa.Application +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.constant.EnabledType +import io.nekohasekai.sfa.database.Settings +import io.nekohasekai.sfa.databinding.FragmentSettingsBinding +import io.nekohasekai.sfa.ktx.addTextChangedListener +import io.nekohasekai.sfa.ktx.launchCustomTab +import io.nekohasekai.sfa.ktx.setSimpleItems +import io.nekohasekai.sfa.ktx.text +import io.nekohasekai.sfa.ui.MainActivity +import io.nekohasekai.sfa.ui.debug.DebugActivity +import io.nekohasekai.sfa.ui.profileoverride.ProfileOverrideActivity +import io.nekohasekai.sfa.vendor.Vendor +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class SettingsFragment : Fragment() { + + private lateinit var binding: FragmentSettingsBinding + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? + ): View { + binding = FragmentSettingsBinding.inflate(inflater, container, false) + onCreate() + return binding.root + } + + @RequiresApi(Build.VERSION_CODES.M) + private val requestIgnoreBatteryOptimizations = registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { result -> + if (Application.powerManager.isIgnoringBatteryOptimizations(Application.application.packageName)) { + binding.backgroundPermissionCard.isGone = true + } + } + + @SuppressLint("BatteryLife") + private fun onCreate() { + val activity = activity as MainActivity? ?: return + val binding = binding ?: return + binding.versionText.text = Libbox.version() + binding.clearButton.setOnClickListener { + lifecycleScope.launch(Dispatchers.IO) { + activity.getExternalFilesDir(null)?.deleteRecursively() + reloadSettings() + } + } + if (!Vendor.checkUpdateAvailable()) { + binding.checkUpdateEnabled.isVisible = false + binding.checkUpdateButton.isVisible = false + } + binding.checkUpdateEnabled.addTextChangedListener { + lifecycleScope.launch(Dispatchers.IO) { + val newValue = EnabledType.valueOf(requireContext(), it).boolValue + Settings.checkUpdateEnabled = newValue + } + } + binding.checkUpdateButton.setOnClickListener { + Vendor.checkUpdate(activity, true) + } + binding.openPrivacyPolicyButton.setOnClickListener { + activity.launchCustomTab("https://sing-box.sagernet.org/clients/privacy/") + } + binding.disableMemoryLimit.addTextChangedListener { + lifecycleScope.launch(Dispatchers.IO) { + val newValue = EnabledType.valueOf(requireContext(), it).boolValue + Settings.disableMemoryLimit = !newValue + } + } + binding.dynamicNotificationEnabled.addTextChangedListener { + lifecycleScope.launch(Dispatchers.IO) { + val newValue = EnabledType.valueOf(requireContext(), it).boolValue + Settings.dynamicNotification = newValue + } + } + + binding.dontKillMyAppButton.setOnClickListener { + it.context.launchCustomTab("https://dontkillmyapp.com/") + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + binding.requestIgnoreBatteryOptimizationsButton.setOnClickListener { + requestIgnoreBatteryOptimizations.launch( + Intent( + android.provider.Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, + Uri.parse("package:${Application.application.packageName}") + ) + ) + } + } + binding.configureOverridesButton.setOnClickListener { + startActivity(Intent(requireContext(), ProfileOverrideActivity::class.java)) + } + binding.openDebugButton.setOnClickListener { + startActivity(Intent(requireContext(), DebugActivity::class.java)) + } + binding.startSponserButton.setOnClickListener { + activity.launchCustomTab("https://sekai.icu/sponsors/") + } + lifecycleScope.launch(Dispatchers.IO) { + reloadSettings() + } + } + + private suspend fun reloadSettings() { + val activity = activity ?: return + val binding = binding ?: return + val dataSize = Libbox.formatBytes( + (activity.getExternalFilesDir(null) ?: activity.filesDir) + .walkTopDown().filter { it.isFile }.map { it.length() }.sum() + ) + val checkUpdateEnabled = Settings.checkUpdateEnabled + val removeBackgroundPermissionPage = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + Application.powerManager.isIgnoringBatteryOptimizations(Application.application.packageName) + } else { + true + } + val dynamicNotification = Settings.dynamicNotification + withContext(Dispatchers.Main) { + binding.dataSizeText.text = dataSize + binding.checkUpdateEnabled.text = + EnabledType.from(checkUpdateEnabled).getString(requireContext()) + binding.checkUpdateEnabled.setSimpleItems(R.array.enabled) + binding.disableMemoryLimit.text = + EnabledType.from(!Settings.disableMemoryLimit).getString(requireContext()) + binding.disableMemoryLimit.setSimpleItems(R.array.enabled) + binding.backgroundPermissionCard.isGone = removeBackgroundPermissionPage + binding.dynamicNotificationEnabled.text = + EnabledType.from(dynamicNotification).getString(requireContext()) + binding.dynamicNotificationEnabled.setSimpleItems(R.array.enabled) + } + } + +} \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/profile/EditProfileActivity.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/profile/EditProfileActivity.kt new file mode 100644 index 0000000000..da26bbe617 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/profile/EditProfileActivity.kt @@ -0,0 +1,202 @@ +package io.nekohasekai.sfa.ui.profile + +import android.content.Intent +import android.os.Bundle +import android.view.View +import androidx.core.view.isVisible +import androidx.lifecycle.lifecycleScope +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.bg.UpdateProfileWork +import io.nekohasekai.sfa.constant.EnabledType +import io.nekohasekai.sfa.database.Profile +import io.nekohasekai.sfa.database.ProfileManager +import io.nekohasekai.sfa.database.Settings +import io.nekohasekai.sfa.database.TypedProfile +import io.nekohasekai.sfa.databinding.ActivityEditProfileBinding +import io.nekohasekai.sfa.ktx.addTextChangedListener +import io.nekohasekai.sfa.ktx.errorDialogBuilder +import io.nekohasekai.sfa.ktx.setSimpleItems +import io.nekohasekai.sfa.ktx.text +import io.nekohasekai.sfa.ui.shared.AbstractActivity +import io.nekohasekai.sfa.utils.HTTPClient +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File +import java.text.DateFormat +import java.util.Date + +class EditProfileActivity : AbstractActivity() { + + private lateinit var profile: Profile + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setTitle(R.string.title_edit_profile) + lifecycleScope.launch(Dispatchers.IO) { + runCatching { + loadProfile() + }.onFailure { + withContext(Dispatchers.Main) { + errorDialogBuilder(it) + .setPositiveButton(R.string.ok) { _, _ -> finish() } + .show() + } + } + } + } + + private suspend fun loadProfile() { + delay(200L) + val profileId = intent.getLongExtra("profile_id", -1L) + if (profileId == -1L) error("invalid arguments") + profile = ProfileManager.get(profileId) ?: error("invalid arguments") + withContext(Dispatchers.Main) { + binding.name.text = profile.name + binding.name.addTextChangedListener { + lifecycleScope.launch(Dispatchers.IO) { + try { + profile.name = it + ProfileManager.update(profile) + } catch (e: Exception) { + withContext(Dispatchers.Main) { + errorDialogBuilder(e).show() + } + } + } + } + binding.type.text = profile.typed.type.getString(this@EditProfileActivity) + binding.editButton.setOnClickListener { + startActivity( + Intent( + this@EditProfileActivity, + EditProfileContentActivity::class.java + ).apply { + putExtra("profile_id", profile.id) + }) + } + when (profile.typed.type) { + TypedProfile.Type.Local -> { + binding.editButton.isVisible = true + binding.remoteFields.isVisible = false + } + + TypedProfile.Type.Remote -> { + binding.editButton.isVisible = false + binding.remoteFields.isVisible = true + binding.remoteURL.text = profile.typed.remoteURL + binding.lastUpdated.text = + DateFormat.getDateTimeInstance().format(profile.typed.lastUpdated) + binding.autoUpdate.text = EnabledType.from(profile.typed.autoUpdate) + .getString(this@EditProfileActivity) + binding.autoUpdate.setSimpleItems(R.array.enabled) + binding.autoUpdateInterval.isVisible = profile.typed.autoUpdate + binding.autoUpdateInterval.text = profile.typed.autoUpdateInterval.toString() + } + } + binding.remoteURL.addTextChangedListener(this@EditProfileActivity::updateRemoteURL) + binding.autoUpdate.addTextChangedListener(this@EditProfileActivity::updateAutoUpdate) + binding.autoUpdateInterval.addTextChangedListener(this@EditProfileActivity::updateAutoUpdateInterval) + binding.updateButton.setOnClickListener(this@EditProfileActivity::updateProfile) + binding.profileLayout.isVisible = true + binding.progressView.isVisible = false + } + } + + + private fun updateRemoteURL(newValue: String) { + profile.typed.remoteURL = newValue + updateProfile() + } + + private fun updateAutoUpdate(newValue: String) { + val boolValue = EnabledType.valueOf(this, newValue).boolValue + if (profile.typed.autoUpdate == boolValue) { + return + } + binding.autoUpdateInterval.isVisible = boolValue + profile.typed.autoUpdate = boolValue + if (boolValue) { + lifecycleScope.launch(Dispatchers.IO) { + UpdateProfileWork.reconfigureUpdater() + } + } + updateProfile() + } + + private fun updateAutoUpdateInterval(newValue: String) { + if (newValue.isBlank()) { + binding.autoUpdateInterval.error = getString(R.string.profile_input_required) + return + } + val intValue = try { + newValue.toInt() + } catch (e: Exception) { + binding.autoUpdateInterval.error = e.localizedMessage + return + } + if (intValue < 15) { + binding.autoUpdateInterval.error = + getString(R.string.profile_auto_update_interval_minimum_hint) + return + } + binding.autoUpdateInterval.error = null + profile.typed.autoUpdateInterval = intValue + updateProfile() + } + + private fun updateProfile() { + binding.progressView.isVisible = true + lifecycleScope.launch(Dispatchers.IO) { + delay(200L) + try { + ProfileManager.update(profile) + } catch (e: Exception) { + withContext(Dispatchers.Main) { + errorDialogBuilder(e).show() + } + } + withContext(Dispatchers.Main) { + binding.progressView.isVisible = false + } + } + } + + @Suppress("UNUSED_PARAMETER") + private fun updateProfile(view: View) { + binding.progressView.isVisible = true + lifecycleScope.launch(Dispatchers.IO) { + var selectedProfileUpdated = false + try { + val content = HTTPClient().use { it.getString(profile.typed.remoteURL) } + Libbox.checkConfig(content) + val file = File(profile.typed.path) + if (file.readText() != content) { + File(profile.typed.path).writeText(content) + if (profile.id == Settings.selectedProfile) { + selectedProfileUpdated = true + } + } + profile.typed.lastUpdated = Date() + ProfileManager.update(profile) + } catch (e: Exception) { + withContext(Dispatchers.Main) { + errorDialogBuilder(e).show() + } + } + withContext(Dispatchers.Main) { + binding.lastUpdated.text = + DateFormat.getDateTimeInstance().format(profile.typed.lastUpdated) + binding.progressView.isVisible = false + } + if (selectedProfileUpdated) { + runCatching { + Libbox.newStandaloneCommandClient().serviceReload() + } + } + } + } + +} \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/profile/EditProfileContentActivity.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/profile/EditProfileContentActivity.kt new file mode 100644 index 0000000000..df762eb6e2 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/profile/EditProfileContentActivity.kt @@ -0,0 +1,139 @@ +package io.nekohasekai.sfa.ui.profile + +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import androidx.core.view.isInvisible +import androidx.core.view.isVisible +import androidx.core.widget.addTextChangedListener +import androidx.lifecycle.lifecycleScope +import com.blacksquircle.ui.language.json.JsonLanguage +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.database.Profile +import io.nekohasekai.sfa.database.ProfileManager +import io.nekohasekai.sfa.databinding.ActivityEditProfileContentBinding +import io.nekohasekai.sfa.ktx.errorDialogBuilder +import io.nekohasekai.sfa.ktx.unwrap +import io.nekohasekai.sfa.ui.shared.AbstractActivity +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File + +class EditProfileContentActivity : AbstractActivity() { + + private var profile: Profile? = null + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setTitle(R.string.title_edit_configuration) + binding.editor.language = JsonLanguage() + loadConfiguration() + } + + private fun loadConfiguration() { + lifecycleScope.launch(Dispatchers.IO) { + runCatching { + loadConfiguration0() + }.onFailure { + withContext(Dispatchers.Main) { + errorDialogBuilder(it) + .setPositiveButton(R.string.ok) { _, _ -> finish() } + .show() + } + } + } + } + + override fun onCreateOptionsMenu(menu: Menu?): Boolean { + menuInflater.inflate(R.menu.edit_configutation_menu, menu) + return true + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.action_undo -> { + if (binding.editor.canUndo()) binding.editor.undo() + return true + } + + R.id.action_redo -> { + if (binding.editor.canRedo()) binding.editor.redo() + return true + } + + R.id.action_check -> { + binding.progressView.isVisible = true + lifecycleScope.launch(Dispatchers.IO) { + runCatching { + Libbox.checkConfig(binding.editor.text.toString()) + }.onFailure { + withContext(Dispatchers.Main) { + errorDialogBuilder(it).show() + } + } + withContext(Dispatchers.Main) { + delay(200) + binding.progressView.isInvisible = true + } + } + return true + } + + R.id.action_format -> { + lifecycleScope.launch(Dispatchers.IO) { + runCatching { + val content = Libbox.formatConfig(binding.editor.text.toString()).unwrap + if (binding.editor.text.toString() != content) { + withContext(Dispatchers.Main) { + binding.editor.setTextContent(content) + } + } + }.onFailure { + withContext(Dispatchers.Main) { + errorDialogBuilder(it).show() + } + } + } + return true + } + } + return super.onOptionsItemSelected(item) + } + + private suspend fun loadConfiguration0() { + delay(200L) + + val profileId = intent.getLongExtra("profile_id", -1L) + if (profileId == -1L) error("invalid arguments") + val profile = ProfileManager.get(profileId) ?: error("invalid arguments") + this.profile = profile + val content = File(profile.typed.path).readText() + withContext(Dispatchers.Main) { + binding.editor.setTextContent(content) + binding.editor.addTextChangedListener { + binding.progressView.isVisible = true + val newContent = it.toString() + lifecycleScope.launch(Dispatchers.IO) { + runCatching { + File(profile.typed.path).writeText(newContent) + }.onFailure { + withContext(Dispatchers.Main) { + errorDialogBuilder(it) + .setPositiveButton(android.R.string.ok) { _, _ -> finish() } + .show() + } + } + withContext(Dispatchers.Main) { + delay(200L) + binding.progressView.isInvisible = true + } + } + } + binding.progressView.isInvisible = true + } + } + +} \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/profile/NewProfileActivity.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/profile/NewProfileActivity.kt new file mode 100644 index 0000000000..75672f3a69 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/profile/NewProfileActivity.kt @@ -0,0 +1,216 @@ +package io.nekohasekai.sfa.ui.profile + +import android.content.Context +import android.net.Uri +import android.os.Bundle +import android.view.View +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.StringRes +import androidx.core.view.isVisible +import androidx.lifecycle.lifecycleScope +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.constant.EnabledType +import io.nekohasekai.sfa.database.Profile +import io.nekohasekai.sfa.database.ProfileManager +import io.nekohasekai.sfa.database.TypedProfile +import io.nekohasekai.sfa.databinding.ActivityAddProfileBinding +import io.nekohasekai.sfa.ktx.addTextChangedListener +import io.nekohasekai.sfa.ktx.errorDialogBuilder +import io.nekohasekai.sfa.ktx.removeErrorIfNotEmpty +import io.nekohasekai.sfa.ktx.showErrorIfEmpty +import io.nekohasekai.sfa.ktx.startFilesForResult +import io.nekohasekai.sfa.ktx.text +import io.nekohasekai.sfa.ui.shared.AbstractActivity +import io.nekohasekai.sfa.utils.HTTPClient +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File +import java.io.InputStream +import java.util.Date + +class NewProfileActivity : AbstractActivity() { + enum class FileSource(@StringRes val formattedRes: Int) { + CreateNew(R.string.profile_source_create_new), + Import(R.string.profile_source_import); + + fun formatted(context: Context): String { + return context.getString(formattedRes) + } + } + + private val importFile = + registerForActivityResult(ActivityResultContracts.GetContent()) { fileURI -> + if (fileURI != null) { + binding.sourceURL.editText?.setText(fileURI.toString()) + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setTitle(R.string.title_new_profile) + + intent.getStringExtra("importName")?.also { importName -> + intent.getStringExtra("importURL")?.also { importURL -> + binding.name.editText?.setText(importName) + binding.type.text = TypedProfile.Type.Remote.getString(this) + binding.remoteURL.editText?.setText(importURL) + binding.localFields.isVisible = false + binding.remoteFields.isVisible = true + binding.autoUpdateInterval.text = "60" + } + } + + binding.name.removeErrorIfNotEmpty() + binding.type.addTextChangedListener { + when (it) { + TypedProfile.Type.Local.getString(this) -> { + binding.localFields.isVisible = true + binding.remoteFields.isVisible = false + } + + TypedProfile.Type.Remote.getString(this) -> { + binding.localFields.isVisible = false + binding.remoteFields.isVisible = true + if (binding.autoUpdateInterval.text.toIntOrNull() == null) { + binding.autoUpdateInterval.text = "60" + } + } + } + } + binding.fileSourceMenu.addTextChangedListener { + when (it) { + FileSource.CreateNew.formatted(this) -> { + binding.importFileButton.isVisible = false + binding.sourceURL.isVisible = false + } + + FileSource.Import.formatted(this) -> { + binding.importFileButton.isVisible = true + binding.sourceURL.isVisible = true + } + } + } + binding.importFileButton.setOnClickListener { + startFilesForResult(importFile, "application/json") + } + binding.createProfile.setOnClickListener(this::createProfile) + binding.autoUpdateInterval.addTextChangedListener(this::updateAutoUpdateInterval) + } + + private fun createProfile(@Suppress("UNUSED_PARAMETER") view: View) { + if (binding.name.showErrorIfEmpty()) { + return + } + when (binding.type.text) { + TypedProfile.Type.Local.getString(this) -> { + when (binding.fileSourceMenu.text) { + FileSource.Import.formatted(this) -> { + if (binding.sourceURL.showErrorIfEmpty()) { + return + } + } + } + } + + TypedProfile.Type.Remote.getString(this) -> { + if (binding.remoteURL.showErrorIfEmpty()) { + return + } + } + } + binding.progressView.isVisible = true + lifecycleScope.launch(Dispatchers.IO) { + runCatching { + createProfile0() + }.onFailure { e -> + withContext(Dispatchers.Main) { + binding.progressView.isVisible = false + errorDialogBuilder(e).show() + } + } + } + } + + private suspend fun createProfile0() { + val typedProfile = TypedProfile() + val profile = Profile(name = binding.name.text, typed = typedProfile) + profile.userOrder = ProfileManager.nextOrder() + val fileID = ProfileManager.nextFileID() + val configDirectory = File(filesDir, "configs").also { it.mkdirs() } + val configFile = File(configDirectory, "$fileID.json") + typedProfile.path = configFile.path + + when (binding.type.text) { + TypedProfile.Type.Local.getString(this) -> { + typedProfile.type = TypedProfile.Type.Local + + when (binding.fileSourceMenu.text) { + FileSource.CreateNew.formatted(this) -> { + configFile.writeText("{}") + } + + FileSource.Import.formatted(this) -> { + val sourceURL = binding.sourceURL.text + val content = if (sourceURL.startsWith("content://")) { + val inputStream = + contentResolver.openInputStream(Uri.parse(sourceURL)) as InputStream + inputStream.use { it.bufferedReader().readText() } + } else if (sourceURL.startsWith("file://")) { + File(sourceURL).readText() + } else if (sourceURL.startsWith("http://") || sourceURL.startsWith("https://")) { + HTTPClient().use { it.getString(sourceURL) } + } else { + error("unsupported source: $sourceURL") + } + Libbox.checkConfig(content) + configFile.writeText(content) + } + } + } + + TypedProfile.Type.Remote.getString(this) -> { + typedProfile.type = TypedProfile.Type.Remote + val remoteURL = binding.remoteURL.text + val content = HTTPClient().use { it.getString(remoteURL) } + Libbox.checkConfig(content) + configFile.writeText(content) + typedProfile.remoteURL = remoteURL + typedProfile.lastUpdated = Date() + typedProfile.autoUpdate = + EnabledType.valueOf(this, binding.autoUpdate.text).boolValue + binding.autoUpdateInterval.text.toIntOrNull()?.also { + typedProfile.autoUpdateInterval = it + } + } + } + ProfileManager.create(profile) + withContext(Dispatchers.Main) { + binding.progressView.isVisible = false + finish() + } + } + + private fun updateAutoUpdateInterval(newValue: String) { + if (newValue.isBlank()) { + binding.autoUpdateInterval.error = getString(R.string.profile_input_required) + return + } + val intValue = try { + newValue.toInt() + } catch (e: Exception) { + binding.autoUpdateInterval.error = e.localizedMessage + return + } + if (intValue < 15) { + binding.autoUpdateInterval.error = + getString(R.string.profile_auto_update_interval_minimum_hint) + return + } + binding.autoUpdateInterval.error = null + } + + +} \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/profile/QRScanActivity.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/profile/QRScanActivity.kt new file mode 100644 index 0000000000..ce3deddb5f --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/profile/QRScanActivity.kt @@ -0,0 +1,235 @@ +package io.nekohasekai.sfa.ui.profile + +import android.Manifest +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import androidx.activity.result.contract.ActivityResultContract +import androidx.activity.result.contract.ActivityResultContracts +import androidx.camera.core.Camera +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.Preview +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.PreviewView +import androidx.core.content.ContextCompat +import androidx.core.view.isVisible +import androidx.lifecycle.lifecycleScope +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.databinding.ActivityQrScanBinding +import io.nekohasekai.sfa.ktx.errorDialogBuilder +import io.nekohasekai.sfa.ui.shared.AbstractActivity +import io.nekohasekai.sfa.vendor.Vendor +import kotlinx.coroutines.launch +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors + +class QRScanActivity : AbstractActivity() { + + private lateinit var analysisExecutor: ExecutorService + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setTitle(R.string.profile_add_scan_qr_code) + + analysisExecutor = Executors.newSingleThreadExecutor() + binding.previewView.implementationMode = PreviewView.ImplementationMode.COMPATIBLE + binding.previewView.previewStreamState.observe(this) { + if (it === PreviewView.StreamState.STREAMING) { + binding.progress.isVisible = false + binding.previewView.implementationMode = PreviewView.ImplementationMode.PERFORMANCE + } + } + if (ContextCompat.checkSelfPermission( + this, Manifest.permission.CAMERA + ) == PackageManager.PERMISSION_GRANTED + ) { + startCamera() + } else { + requestPermissionLauncher.launch(Manifest.permission.CAMERA) + } + } + + private val requestPermissionLauncher = + registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted: Boolean -> + if (isGranted) { + startCamera() + } else { + setResult(RESULT_CANCELED) + finish() + } + } + + private lateinit var imageAnalysis: ImageAnalysis + private lateinit var imageAnalyzer: ImageAnalysis.Analyzer + private val onSuccess: (String) -> Unit = { rawValue: String -> + imageAnalysis.clearAnalyzer() + if (!onSuccess(rawValue)) { + imageAnalysis.setAnalyzer(analysisExecutor, imageAnalyzer) + } + } + private val onFailure: (Exception) -> Unit = { + lifecycleScope.launch { + resetAnalyzer() + errorDialogBuilder("MLKit error: ${it.localizedMessage}").show() + } + } + private val vendorAnalyzer = Vendor.createQRCodeAnalyzer(onSuccess, onFailure) + private var useVendorAnalyzer = vendorAnalyzer != null + private fun resetAnalyzer() { + if (useVendorAnalyzer) { + useVendorAnalyzer = false + imageAnalysis.clearAnalyzer() + imageAnalyzer = ZxingQRCodeAnalyzer(onSuccess, onFailure) + imageAnalysis.setAnalyzer(analysisExecutor, imageAnalyzer) + } + } + + private lateinit var cameraProvider: ProcessCameraProvider + private lateinit var cameraPreview: Preview + private lateinit var camera: Camera + + private fun startCamera() { + val cameraProviderFuture = try { + ProcessCameraProvider.getInstance(this) + } catch (e: Exception) { + fatalError(e) + return + } + cameraProviderFuture.addListener({ + cameraProvider = try { + cameraProviderFuture.get() + } catch (e: Exception) { + fatalError(e) + return@addListener + } + + cameraPreview = Preview.Builder().build() + .also { it.setSurfaceProvider(binding.previewView.surfaceProvider) } + imageAnalysis = ImageAnalysis.Builder().build() + imageAnalyzer = vendorAnalyzer ?: ZxingQRCodeAnalyzer(onSuccess, onFailure) + imageAnalysis.setAnalyzer(analysisExecutor, imageAnalyzer) + cameraProvider.unbindAll() + + try { + camera = cameraProvider.bindToLifecycle( + this, CameraSelector.DEFAULT_BACK_CAMERA, cameraPreview, imageAnalysis + ) + } catch (e: Exception) { + fatalError(e) + } + }, ContextCompat.getMainExecutor(this)) + } + + private fun fatalError(e: Exception) { + lifecycleScope.launch { + errorDialogBuilder(e).setOnDismissListener { + setResult(RESULT_CANCELED) + finish() + }.show() + } + } + + private fun onSuccess(value: String): Boolean { + try { + importRemoteProfileFromString(value) + return true + } catch (e: Exception) { + lifecycleScope.launch { + errorDialogBuilder(e).show() + } + } + return false + } + + private fun importRemoteProfileFromString(uriString: String) { + val uri = Uri.parse(uriString) + if (uri.scheme != "sing-box" || uri.host != "import-remote-profile") error("Not a valid sing-box remote profile URI") + Libbox.parseRemoteProfileImportLink(uri.toString()) + setResult(RESULT_OK, Intent().apply { + setData(uri) + }) + finish() + } + + override fun onPrepareOptionsMenu(menu: Menu?): Boolean { + if (!useVendorAnalyzer) { + menu!!.findItem(R.id.action_use_vendor_analyzer).also { + it.isEnabled = false + it.isChecked = false + } + } + return true + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.qr_scan_menu, menu) + if (useVendorAnalyzer) { + menu.findItem(R.id.action_use_vendor_analyzer).isChecked = true + } + return true + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.action_use_front_camera -> { + item.isChecked = !item.isChecked + cameraProvider.unbindAll() + try { + camera = cameraProvider.bindToLifecycle( + this, + if (!item.isChecked) CameraSelector.DEFAULT_BACK_CAMERA else CameraSelector.DEFAULT_FRONT_CAMERA, + cameraPreview, + imageAnalysis + ) + } catch (e: Exception) { + fatalError(e) + } + } + + R.id.action_enable_torch -> { + item.isChecked = !item.isChecked + camera.cameraControl.enableTorch(item.isChecked) + } + + R.id.action_use_vendor_analyzer -> { + item.isChecked = !item.isChecked + imageAnalysis.clearAnalyzer() + imageAnalyzer = if (item.isChecked) { + vendorAnalyzer!! + } else { + ZxingQRCodeAnalyzer(onSuccess, onFailure) + } + imageAnalysis.setAnalyzer(analysisExecutor, imageAnalyzer) + } + + else -> return super.onOptionsItemSelected(item) + } + return true + } + + + override fun onDestroy() { + super.onDestroy() + analysisExecutor.shutdown() + } + + class Contract : ActivityResultContract() { + + override fun createIntent(context: Context, input: Nothing?): Intent = + Intent(context, QRScanActivity::class.java) + + override fun parseResult(resultCode: Int, intent: Intent?): Intent? { + return when (resultCode) { + RESULT_OK -> intent + else -> null + } + } + } + +} \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/profile/ZxingQRCodeAnalyzer.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/profile/ZxingQRCodeAnalyzer.kt new file mode 100644 index 0000000000..5d35d2707a --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/profile/ZxingQRCodeAnalyzer.kt @@ -0,0 +1,49 @@ +package io.nekohasekai.sfa.ui.profile + +import android.util.Log +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.ImageProxy +import com.google.zxing.BinaryBitmap +import com.google.zxing.NotFoundException +import com.google.zxing.RGBLuminanceSource +import com.google.zxing.common.GlobalHistogramBinarizer +import com.google.zxing.qrcode.QRCodeReader + +class ZxingQRCodeAnalyzer( + private val onSuccess: ((String) -> Unit), + private val onFailure: ((Exception) -> Unit), +) : ImageAnalysis.Analyzer { + + private val qrCodeReader = QRCodeReader() + override fun analyze(image: ImageProxy) { + try { + val bitmap = image.toBitmap() + val intArray = IntArray(bitmap.getWidth() * bitmap.getHeight()) + bitmap.getPixels( + intArray, + 0, + bitmap.getWidth(), + 0, + 0, + bitmap.getWidth(), + bitmap.getHeight() + ) + val source = RGBLuminanceSource(bitmap.getWidth(), bitmap.getHeight(), intArray) + val result = try { + qrCodeReader.decode(BinaryBitmap(GlobalHistogramBinarizer(source))) + } catch (e: NotFoundException) { + try { + qrCodeReader.decode(BinaryBitmap(GlobalHistogramBinarizer(source.invert()))) + } catch (ignore: NotFoundException) { + return + } + } + Log.d("ZxingQRCodeAnalyzer", "barcode decode success: ${result.text}") + onSuccess(result.text) + } catch (e: Exception) { + onFailure(e) + } finally { + image.close() + } + } +} \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/profileoverride/PerAppProxyActivity.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/profileoverride/PerAppProxyActivity.kt new file mode 100644 index 0000000000..6165b3b776 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/profileoverride/PerAppProxyActivity.kt @@ -0,0 +1,782 @@ +package io.nekohasekai.sfa.ui.profileoverride + +import android.Manifest +import android.annotation.SuppressLint +import android.content.pm.ApplicationInfo +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.os.Build +import android.os.Bundle +import android.util.Log +import android.view.Gravity +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuItem +import android.view.ViewGroup +import android.widget.Toast +import androidx.appcompat.widget.PopupMenu +import androidx.appcompat.widget.SearchView +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.isVisible +import androidx.core.view.updatePadding +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.RecyclerView +import com.android.tools.smali.dexlib2.dexbacked.DexBackedDexFile +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import io.nekohasekai.sfa.Application +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.database.Settings +import io.nekohasekai.sfa.databinding.ActivityPerAppProxyBinding +import io.nekohasekai.sfa.databinding.DialogProgressbarBinding +import io.nekohasekai.sfa.databinding.ViewAppListItemBinding +import io.nekohasekai.sfa.ktx.clipboardText +import io.nekohasekai.sfa.ui.shared.AbstractActivity +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File +import java.util.concurrent.atomic.AtomicInteger +import java.util.zip.ZipFile + +class PerAppProxyActivity : AbstractActivity() { + enum class SortMode { + NAME, PACKAGE_NAME, UID, INSTALL_TIME, UPDATE_TIME, + } + + private var proxyMode = Settings.PER_APP_PROXY_INCLUDE + private var sortMode = SortMode.NAME + private var sortReverse = false + private var hideSystemApps = false + private var hideOfflineApps = true + private var hideDisabledApps = true + + inner class PackageCache( + private val packageInfo: PackageInfo, + private val appInfo: ApplicationInfo, + ) { + + val packageName: String get() = packageInfo.packageName + + val uid get() = packageInfo.applicationInfo!!.uid + + val installTime get() = packageInfo.firstInstallTime + val updateTime get() = packageInfo.lastUpdateTime + val isSystem get() = appInfo.flags and ApplicationInfo.FLAG_SYSTEM == 1 + val isOffline get() = packageInfo.requestedPermissions?.contains(Manifest.permission.INTERNET) != true + val isDisabled get() = appInfo.flags and ApplicationInfo.FLAG_INSTALLED == 0 + + val applicationIcon by lazy { + appInfo.loadIcon(packageManager) + } + + val applicationLabel by lazy { + appInfo.loadLabel(packageManager).toString() + } + } + + private lateinit var adapter: ApplicationAdapter + private var packages = listOf() + private var displayPackages = listOf() + private var currentPackages = listOf() + private var selectedUIDs = mutableSetOf() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setTitle(R.string.title_per_app_proxy) + + ViewCompat.setOnApplyWindowInsetsListener(binding.appList) { view, windowInsets -> + val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + view.updatePadding(bottom = insets.bottom) + WindowInsetsCompat.CONSUMED + } + + lifecycleScope.launch { + withContext(Dispatchers.IO) { + proxyMode = if (Settings.perAppProxyMode == Settings.PER_APP_PROXY_INCLUDE) { + Settings.PER_APP_PROXY_INCLUDE + } else { + Settings.PER_APP_PROXY_EXCLUDE + } + withContext(Dispatchers.Main) { + if (proxyMode == Settings.PER_APP_PROXY_INCLUDE) { + binding.perAppProxyMode.setText(R.string.per_app_proxy_mode_include_description) + } else { + binding.perAppProxyMode.setText(R.string.per_app_proxy_mode_exclude_description) + } + } + reloadApplicationList() + filterApplicationList() + withContext(Dispatchers.Main) { + adapter = ApplicationAdapter(displayPackages) + binding.appList.adapter = adapter + delay(500L) + binding.progress.isVisible = false + } + } + } + } + + private fun reloadApplicationList() { + val packageManagerFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + PackageManager.GET_PERMISSIONS or PackageManager.MATCH_UNINSTALLED_PACKAGES + } else { + @Suppress("DEPRECATION") + PackageManager.GET_PERMISSIONS or PackageManager.GET_UNINSTALLED_PACKAGES + } + val installedPackages = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + packageManager.getInstalledPackages( + PackageManager.PackageInfoFlags.of( + packageManagerFlags.toLong() + ) + ) + } else { + @Suppress("DEPRECATION") packageManager.getInstalledPackages(packageManagerFlags) + } + val packages = mutableListOf() + for (packageInfo in installedPackages) { + if (packageInfo.packageName == packageName) continue + val appInfo = packageInfo.applicationInfo ?: continue + packages.add(PackageCache(packageInfo, appInfo)) + } + val selectedPackageNames = Settings.perAppProxyList.toMutableSet() + val selectedUIDs = mutableSetOf() + for (packageCache in packages) { + if (selectedPackageNames.contains(packageCache.packageName)) { + selectedUIDs.add(packageCache.uid) + } + } + this.packages = packages + this.selectedUIDs = selectedUIDs + } + + private fun filterApplicationList(selectedUIDs: Set = this.selectedUIDs) { + val displayPackages = mutableListOf() + for (packageCache in packages) { + if (hideSystemApps && packageCache.isSystem) continue + if (hideOfflineApps && packageCache.isOffline) continue + if (hideDisabledApps && packageCache.isDisabled) continue + displayPackages.add(packageCache) + } + displayPackages.sortWith(compareBy { + !selectedUIDs.contains(it.uid) + }.let { + if (!sortReverse) it.thenBy { + when (sortMode) { + SortMode.NAME -> it.applicationLabel + SortMode.PACKAGE_NAME -> it.packageName + SortMode.UID -> it.uid + SortMode.INSTALL_TIME -> it.installTime + SortMode.UPDATE_TIME -> it.updateTime + } + } else it.thenByDescending { + when (sortMode) { + SortMode.NAME -> it.applicationLabel + SortMode.PACKAGE_NAME -> it.packageName + SortMode.UID -> it.uid + SortMode.INSTALL_TIME -> it.installTime + SortMode.UPDATE_TIME -> it.updateTime + } + } + }) + + this.displayPackages = displayPackages + this.currentPackages = displayPackages + } + + private fun updateApplicationSelection(packageCache: PackageCache, selected: Boolean) { + val performed = if (selected) { + selectedUIDs.add(packageCache.uid) + } else { + selectedUIDs.remove(packageCache.uid) + } + if (!performed) return + currentPackages.forEachIndexed { index, it -> + if (it.uid == packageCache.uid) { + adapter.notifyItemChanged(index, PayloadUpdateSelection(selected)) + } + } + saveSelectedApplications() + } + + data class PayloadUpdateSelection(val selected: Boolean) + + inner class ApplicationAdapter(private var applicationList: List) : + RecyclerView.Adapter() { + + @SuppressLint("NotifyDataSetChanged") + fun setApplicationList(applicationList: List) { + this.applicationList = applicationList + notifyDataSetChanged() + } + + override fun onCreateViewHolder( + parent: ViewGroup, viewType: Int + ): ApplicationViewHolder { + return ApplicationViewHolder( + ViewAppListItemBinding.inflate( + LayoutInflater.from(parent.context), parent, false + ) + ) + } + + override fun getItemCount(): Int { + return applicationList.size + } + + override fun onBindViewHolder( + holder: ApplicationViewHolder, position: Int + ) { + holder.bind(applicationList[position]) + } + + override fun onBindViewHolder( + holder: ApplicationViewHolder, position: Int, payloads: MutableList + ) { + if (payloads.isEmpty()) { + onBindViewHolder(holder, position) + return + } + payloads.forEach { + when (it) { + is PayloadUpdateSelection -> holder.updateSelection(it.selected) + } + } + } + } + + inner class ApplicationViewHolder( + private val binding: ViewAppListItemBinding + ) : RecyclerView.ViewHolder(binding.root) { + + @SuppressLint("SetTextI18n") + fun bind(packageCache: PackageCache) { + binding.appIcon.setImageDrawable(packageCache.applicationIcon) + binding.applicationLabel.text = packageCache.applicationLabel + binding.packageName.text = "${packageCache.packageName} (${packageCache.uid})" + binding.selected.isChecked = selectedUIDs.contains(packageCache.uid) + binding.root.setOnClickListener { + updateApplicationSelection(packageCache, !binding.selected.isChecked) + } + binding.root.setOnLongClickListener { + val popup = PopupMenu(it.context, it) + popup.setForceShowIcon(true) + popup.gravity = Gravity.END + popup.menuInflater.inflate(R.menu.app_menu, popup.menu) + popup.setOnMenuItemClickListener { + when (it.itemId) { + R.id.action_copy_application_label -> { + clipboardText = packageCache.applicationLabel + true + } + + R.id.action_copy_package_name -> { + clipboardText = packageCache.packageName + true + } + + R.id.action_copy_uid -> { + clipboardText = packageCache.uid.toString() + true + } + + else -> false + } + } + popup.show() + true + } + } + + fun updateSelection(selected: Boolean) { + binding.selected.isChecked = selected + } + } + + private fun searchApplications(searchText: String) { + currentPackages = if (searchText.isEmpty()) { + displayPackages + } else { + displayPackages.filter { + it.applicationLabel.contains( + searchText, ignoreCase = true + ) || it.packageName.contains( + searchText, ignoreCase = true + ) || it.uid.toString().contains(searchText) + } + } + adapter.setApplicationList(currentPackages) + } + + override fun onCreateOptionsMenu(menu: Menu?): Boolean { + menuInflater.inflate(R.menu.per_app_menu, menu) + + if (menu != null) { + val searchView = menu.findItem(R.id.action_search).actionView as SearchView + searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + override fun onQueryTextSubmit(query: String): Boolean { + return true + } + + override fun onQueryTextChange(newText: String): Boolean { + searchApplications(newText) + return true + } + }) + searchView.setOnCloseListener { + searchApplications("") + true + } + when (proxyMode) { + Settings.PER_APP_PROXY_INCLUDE -> { + menu.findItem(R.id.action_mode_include).isChecked = true + } + + Settings.PER_APP_PROXY_EXCLUDE -> { + menu.findItem(R.id.action_mode_exclude).isChecked = true + } + } + when (sortMode) { + SortMode.NAME -> { + menu.findItem(R.id.action_sort_by_name).isChecked = true + } + + SortMode.PACKAGE_NAME -> { + menu.findItem(R.id.action_sort_by_package_name).isChecked = true + } + + SortMode.UID -> { + menu.findItem(R.id.action_sort_by_uid).isChecked = true + } + + SortMode.INSTALL_TIME -> { + menu.findItem(R.id.action_sort_by_install_time).isChecked = true + } + + SortMode.UPDATE_TIME -> { + menu.findItem(R.id.action_sort_by_update_time).isChecked = true + } + } + menu.findItem(R.id.action_sort_reverse).isChecked = sortReverse + menu.findItem(R.id.action_hide_system_apps).isChecked = hideSystemApps + menu.findItem(R.id.action_hide_offline_apps).isChecked = hideOfflineApps + menu.findItem(R.id.action_hide_disabled_apps).isChecked = hideDisabledApps + } + + return super.onCreateOptionsMenu(menu) + } + + @SuppressLint("NotifyDataSetChanged") + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.action_mode_include -> { + item.isChecked = true + proxyMode = Settings.PER_APP_PROXY_INCLUDE + binding.perAppProxyMode.setText(R.string.per_app_proxy_mode_include_description) + lifecycleScope.launch { + Settings.perAppProxyMode = Settings.PER_APP_PROXY_INCLUDE + } + } + + R.id.action_mode_exclude -> { + item.isChecked = true + proxyMode = Settings.PER_APP_PROXY_EXCLUDE + binding.perAppProxyMode.setText(R.string.per_app_proxy_mode_exclude_description) + lifecycleScope.launch { + Settings.perAppProxyMode = Settings.PER_APP_PROXY_EXCLUDE + } + } + + R.id.action_sort_by_name -> { + item.isChecked = true + sortMode = SortMode.NAME + filterApplicationList() + adapter.setApplicationList(currentPackages) + } + + R.id.action_sort_by_package_name -> { + item.isChecked = true + sortMode = SortMode.PACKAGE_NAME + filterApplicationList() + adapter.setApplicationList(currentPackages) + } + + R.id.action_sort_by_uid -> { + item.isChecked = true + sortMode = SortMode.UID + filterApplicationList() + adapter.setApplicationList(currentPackages) + } + + R.id.action_sort_by_install_time -> { + item.isChecked = true + sortMode = SortMode.INSTALL_TIME + filterApplicationList() + adapter.setApplicationList(currentPackages) + } + + R.id.action_sort_by_update_time -> { + item.isChecked = true + sortMode = SortMode.UPDATE_TIME + filterApplicationList() + adapter.setApplicationList(currentPackages) + } + + R.id.action_sort_reverse -> { + item.isChecked = !item.isChecked + sortReverse = item.isChecked + filterApplicationList() + adapter.setApplicationList(currentPackages) + } + + R.id.action_hide_system_apps -> { + item.isChecked = !item.isChecked + hideSystemApps = item.isChecked + filterApplicationList() + adapter.setApplicationList(currentPackages) + } + + R.id.action_hide_offline_apps -> { + item.isChecked = !item.isChecked + hideOfflineApps = item.isChecked + filterApplicationList() + adapter.setApplicationList(currentPackages) + } + + R.id.action_hide_disabled_apps -> { + item.isChecked = !item.isChecked + hideDisabledApps = item.isChecked + filterApplicationList() + adapter.setApplicationList(currentPackages) + } + + R.id.action_select_all -> { + val selectedUIDs = mutableSetOf() + currentPackages.forEach { + selectedUIDs.add(it.uid) + } + lifecycleScope.launch { + postSaveSelectedApplications(selectedUIDs) + } + } + + R.id.action_deselect_all -> { + lifecycleScope.launch { + postSaveSelectedApplications(mutableSetOf()) + } + } + + R.id.action_export -> { + lifecycleScope.launch { + val packageList = mutableListOf() + for (packageCache in packages) { + if (selectedUIDs.contains(packageCache.uid)) { + packageList.add(packageCache.packageName) + } + } + clipboardText = packageList.joinToString("\n") + withContext(Dispatchers.Main) { + Toast.makeText( + this@PerAppProxyActivity, + R.string.toast_copied_to_clipboard, + Toast.LENGTH_SHORT + ).show() + } + } + } + + R.id.action_import -> { + val packageNames = clipboardText?.split("\n")?.distinct() + ?.takeIf { it.isNotEmpty() && it[0].isNotEmpty() } + if (packageNames.isNullOrEmpty()) { + Toast.makeText( + this@PerAppProxyActivity, + R.string.toast_clipboard_empty, + Toast.LENGTH_SHORT + ).show() + return true + } + val selectedUIDs = mutableSetOf() + for (packageCache in packages) { + if (packageNames.contains(packageCache.packageName)) { + selectedUIDs.add(packageCache.uid) + } + } + lifecycleScope.launch { + postSaveSelectedApplications(selectedUIDs) + withContext(Dispatchers.Main) { + Toast.makeText( + this@PerAppProxyActivity, + R.string.toast_imported_from_clipboard, + Toast.LENGTH_SHORT + ).show() + } + } + + } + + R.id.action_scan_china_apps -> { + scanChinaApps() + } + + else -> return super.onOptionsItemSelected(item) + } + return true + } + + @SuppressLint("NotifyDataSetChanged") + private fun scanChinaApps() { + val binding = DialogProgressbarBinding.inflate(layoutInflater) + binding.progress.max = currentPackages.size + binding.message.setText(R.string.message_scanning) + val dialogTheme = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && resources.configuration.isNightModeActive) { + com.google.android.material.R.style.Theme_MaterialComponents_Dialog + } else { + com.google.android.material.R.style.Theme_MaterialComponents_Light_Dialog + } + val progress = MaterialAlertDialogBuilder( + this, dialogTheme + ).setView(binding.root).setCancelable(false).create() + progress.show() + lifecycleScope.launch { + val startTime = System.currentTimeMillis() + val foundApps = withContext(Dispatchers.Default) { + mutableMapOf().also { foundApps -> + val progressInt = AtomicInteger() + currentPackages.map { it -> + async { + if (scanChinaPackage(it.packageName)) { + foundApps[it.packageName] = it + } + runOnUiThread { + binding.progress.progress = progressInt.addAndGet(1) + } + } + }.awaitAll() + } + } + Log.d( + "PerAppProxyActivity", + "Scan China apps took ${(System.currentTimeMillis() - startTime).toDouble() / 1000}s" + ) + withContext(Dispatchers.Main) { + progress.dismiss() + if (foundApps.isEmpty()) { + MaterialAlertDialogBuilder(this@PerAppProxyActivity).setTitle(R.string.title_scan_result) + .setMessage(R.string.message_scan_app_no_apps_found) + .setPositiveButton(R.string.ok, null).show() + return@withContext + } + val dialogContent = + getString(R.string.message_scan_app_found) + "\n\n" + foundApps.entries.joinToString( + "\n" + ) { + "${it.value.applicationLabel} (${it.key})" + } + MaterialAlertDialogBuilder(this@PerAppProxyActivity).setTitle(R.string.title_scan_result) + .setMessage(dialogContent) + .setPositiveButton(R.string.action_select) { dialog, _ -> + dialog.dismiss() + lifecycleScope.launch { + val selectedUIDs = selectedUIDs.toMutableSet() + foundApps.values.forEach { + selectedUIDs.add(it.uid) + } + postSaveSelectedApplications(selectedUIDs) + } + }.setNegativeButton(R.string.action_deselect) { dialog, _ -> + dialog.dismiss() + lifecycleScope.launch { + val selectedUIDs = selectedUIDs.toMutableSet() + foundApps.values.forEach { + selectedUIDs.remove(it.uid) + } + postSaveSelectedApplications(selectedUIDs) + } + }.setNeutralButton(android.R.string.cancel, null).show() + } + } + + } + + @SuppressLint("NotifyDataSetChanged") + private suspend fun postSaveSelectedApplications(newUIDs: MutableSet) { + filterApplicationList(newUIDs) + withContext(Dispatchers.Main) { + selectedUIDs = newUIDs + adapter.notifyDataSetChanged() + } + val packageList = selectedUIDs.mapNotNull { uid -> + packages.find { it.uid == uid }?.packageName + } + Settings.perAppProxyList = packageList.toSet() + } + + private fun saveSelectedApplications() { + lifecycleScope.launch { + val packageList = selectedUIDs.mapNotNull { uid -> + packages.find { it.uid == uid }?.packageName + } + Settings.perAppProxyList = packageList.toSet() + } + } + + companion object { + + private val skipPrefixList = listOf( + "com.google", + "com.android.chrome", + "com.android.vending", + "com.microsoft", + "com.apple", + "com.zhiliaoapp.musically", // Banned by China + "com.android.providers.downloads", + ) + + private val chinaAppPrefixList = listOf( + "com.tencent", + "com.alibaba", + "com.umeng", + "com.qihoo", + "com.ali", + "com.alipay", + "com.amap", + "com.sina", + "com.weibo", + "com.vivo", + "com.xiaomi", + "com.huawei", + "com.taobao", + "com.secneo", + "s.h.e.l.l", + "com.stub", + "com.kiwisec", + "com.secshell", + "com.wrapper", + "cn.securitystack", + "com.mogosec", + "com.secoen", + "com.netease", + "com.mx", + "com.qq.e", + "com.baidu", + "com.bytedance", + "com.bugly", + "com.miui", + "com.oppo", + "com.coloros", + "com.iqoo", + "com.meizu", + "com.gionee", + "cn.nubia", + "com.oplus", + "andes.oplus", + "com.unionpay", + "cn.wps" + ) + + + private val chinaAppRegex by lazy { + ("(" + chinaAppPrefixList.joinToString("|").replace(".", "\\.") + ").*").toRegex() + } + + fun scanChinaPackage(packageName: String): Boolean { + skipPrefixList.forEach { + if (packageName == it || packageName.startsWith("$it.")) return false + } + + val packageManagerFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + PackageManager.MATCH_UNINSTALLED_PACKAGES or PackageManager.GET_ACTIVITIES or PackageManager.GET_SERVICES or PackageManager.GET_RECEIVERS or PackageManager.GET_PROVIDERS + } else { + @Suppress("DEPRECATION") + PackageManager.GET_UNINSTALLED_PACKAGES or PackageManager.GET_ACTIVITIES or PackageManager.GET_SERVICES or PackageManager.GET_RECEIVERS or PackageManager.GET_PROVIDERS + } + if (packageName.matches(chinaAppRegex)) { + Log.d("PerAppProxyActivity", "Match package name: $packageName") + return true + } + try { + val packageInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + Application.packageManager.getPackageInfo( + packageName, + PackageManager.PackageInfoFlags.of(packageManagerFlags.toLong()) + ) + } else { + @Suppress("DEPRECATION") Application.packageManager.getPackageInfo( + packageName, packageManagerFlags + ) + } + val appInfo = packageInfo.applicationInfo ?: return false + packageInfo.services?.forEach { + if (it.name.matches(chinaAppRegex)) { + Log.d("PerAppProxyActivity", "Match service ${it.name} in $packageName") + return true + } + } + packageInfo.activities?.forEach { + if (it.name.matches(chinaAppRegex)) { + Log.d("PerAppProxyActivity", "Match activity ${it.name} in $packageName") + return true + } + } + packageInfo.receivers?.forEach { + if (it.name.matches(chinaAppRegex)) { + Log.d("PerAppProxyActivity", "Match receiver ${it.name} in $packageName") + return true + } + } + packageInfo.providers?.forEach { + if (it.name.matches(chinaAppRegex)) { + Log.d("PerAppProxyActivity", "Match provider ${it.name} in $packageName") + return true + } + } + ZipFile(File(appInfo.publicSourceDir)).use { + for (packageEntry in it.entries()) { + if (packageEntry.name.startsWith("firebase-")) return false + } + for (packageEntry in it.entries()) { + if (!(packageEntry.name.startsWith("classes") && packageEntry.name.endsWith( + ".dex" + )) + ) { + continue + } + if (packageEntry.size > 15000000) { + Log.d( + "PerAppProxyActivity", + "Confirm $packageName due to large dex file" + ) + return true + } + val input = it.getInputStream(packageEntry).buffered() + val dexFile = try { + DexBackedDexFile.fromInputStream(null, input) + } catch (e: Exception) { + Log.e("PerAppProxyActivity", "Error reading dex file", e) + return false + } + for (clazz in dexFile.classes) { + val clazzName = + clazz.type.substring(1, clazz.type.length - 1).replace("/", ".") + .replace("$", ".") + if (clazzName.matches(chinaAppRegex)) { + Log.d("PerAppProxyActivity", "Match $clazzName in $packageName") + return true + } + } + } + } + } catch (e: Exception) { + Log.e("PerAppProxyActivity", "Error scanning package $packageName", e) + } + return false + } + } + +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/profileoverride/ProfileOverrideActivity.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/profileoverride/ProfileOverrideActivity.kt new file mode 100644 index 0000000000..4be88f29a3 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/profileoverride/ProfileOverrideActivity.kt @@ -0,0 +1,58 @@ +package io.nekohasekai.sfa.ui.profileoverride + +import android.content.Intent +import android.os.Bundle +import androidx.lifecycle.lifecycleScope +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.constant.PerAppProxyUpdateType +import io.nekohasekai.sfa.database.Settings +import io.nekohasekai.sfa.databinding.ActivityConfigOverrideBinding +import io.nekohasekai.sfa.ktx.addTextChangedListener +import io.nekohasekai.sfa.ktx.setSimpleItems +import io.nekohasekai.sfa.ktx.text +import io.nekohasekai.sfa.ui.shared.AbstractActivity +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class ProfileOverrideActivity : + AbstractActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setTitle(R.string.title_profile_override) + binding.switchPerAppProxy.isChecked = Settings.perAppProxyEnabled + binding.switchPerAppProxy.setOnCheckedChangeListener { _, isChecked -> + Settings.perAppProxyEnabled = isChecked + binding.perAppProxyUpdateOnChange.isEnabled = binding.switchPerAppProxy.isChecked + binding.configureAppListButton.isEnabled = isChecked + } + binding.perAppProxyUpdateOnChange.isEnabled = binding.switchPerAppProxy.isChecked + binding.configureAppListButton.isEnabled = binding.switchPerAppProxy.isChecked + + binding.perAppProxyUpdateOnChange.addTextChangedListener { + lifecycleScope.launch(Dispatchers.IO) { + Settings.perAppProxyUpdateOnChange = + PerAppProxyUpdateType.valueOf(this@ProfileOverrideActivity, it).value() + } + } + + binding.configureAppListButton.setOnClickListener { + startActivity(Intent(this, PerAppProxyActivity::class.java)) + } + lifecycleScope.launch(Dispatchers.IO) { + reloadSettings() + } + } + + private suspend fun reloadSettings() { + val perAppUpdateOnChange = Settings.perAppProxyUpdateOnChange + withContext(Dispatchers.Main) { + binding.perAppProxyUpdateOnChange.text = + PerAppProxyUpdateType.valueOf(perAppUpdateOnChange) + .getString(this@ProfileOverrideActivity) + binding.perAppProxyUpdateOnChange.setSimpleItems(R.array.per_app_proxy_update_on_change_value) + } + } +} \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/shared/AbstractActivity.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/shared/AbstractActivity.kt new file mode 100644 index 0000000000..92005c1c93 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/shared/AbstractActivity.kt @@ -0,0 +1,87 @@ +package io.nekohasekai.sfa.ui.shared + +import android.content.res.Configuration +import android.os.Build +import android.os.Bundle +import android.view.LayoutInflater +import android.view.MenuItem +import android.view.WindowManager +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.content.res.AppCompatResources +import androidx.core.view.WindowCompat +import androidx.viewbinding.ViewBinding +import com.google.android.material.appbar.MaterialToolbar +import com.google.android.material.color.DynamicColors +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.ktx.getAttrColor +import io.nekohasekai.sfa.ui.MainActivity +import io.nekohasekai.sfa.utils.MIUIUtils +import java.lang.reflect.ParameterizedType + +abstract class AbstractActivity : AppCompatActivity() { + + private var _binding: Binding? = null + internal val binding get() = _binding!! + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + DynamicColors.applyToActivityIfAvailable(this) + + // Set light navigation bar for Android 8.0 + if (Build.VERSION.SDK_INT == Build.VERSION_CODES.O) { + val nightFlag = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK + if (nightFlag != Configuration.UI_MODE_NIGHT_YES) { + val insetsController = WindowCompat.getInsetsController( + window, + window.decorView + ) + insetsController.isAppearanceLightNavigationBars = true + } + } + + _binding = createBindingInstance(layoutInflater).also { + setContentView(it.root) + } + + findViewById(R.id.toolbar)?.also { + setSupportActionBar(it) + } + + // MIUI overrides colorSurfaceContainer to colorSurface without below flags + @Suppress("DEPRECATION") if (MIUIUtils.isMIUI) { + window.addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS) + window.addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION) + } + + if (this !is MainActivity) { + supportActionBar?.setHomeAsUpIndicator(AppCompatResources.getDrawable( + this@AbstractActivity, R.drawable.ic_arrow_back_24 + )!!.apply { + setTint(getAttrColor(com.google.android.material.R.attr.colorOnSurface)) + }) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + } + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + android.R.id.home -> { + onBackPressedDispatcher.onBackPressed() + return true + } + } + return super.onOptionsItemSelected(item) + } + + @Suppress("UNCHECKED_CAST") + private fun createBindingInstance( + inflater: LayoutInflater, + ): Binding { + val vbType = (javaClass.genericSuperclass as ParameterizedType).actualTypeArguments[0] + val vbClass = vbType as Class + val method = vbClass.getMethod("inflate", LayoutInflater::class.java) + return method.invoke(null, inflater) as Binding + } + +} \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/shared/QRCodeDialog.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/shared/QRCodeDialog.kt new file mode 100644 index 0000000000..60b8a2c4c8 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/shared/QRCodeDialog.kt @@ -0,0 +1,26 @@ +package io.nekohasekai.sfa.ui.shared + +import android.graphics.Bitmap +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import io.nekohasekai.sfa.databinding.FragmentQrcodeDialogBinding + +class QRCodeDialog(private val bitmap: Bitmap) : + BottomSheetDialogFragment() { + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val binding = FragmentQrcodeDialogBinding.inflate(inflater, container, false) + val behavior = BottomSheetBehavior.from(binding.qrcodeLayout) + behavior.state = BottomSheetBehavior.STATE_EXPANDED + binding.qrCode.setImageBitmap(bitmap) + return binding.root + } + +} \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/update/UpdateCheckException.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/update/UpdateCheckException.kt deleted file mode 100644 index 63d2b61282..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/update/UpdateCheckException.kt +++ /dev/null @@ -1,5 +0,0 @@ -package io.nekohasekai.sfa.update - -sealed class UpdateCheckException : Exception() { - class TrackNotSupported : UpdateCheckException() -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/update/UpdateInfo.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/update/UpdateInfo.kt deleted file mode 100644 index 6e3fc4972e..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/update/UpdateInfo.kt +++ /dev/null @@ -1,24 +0,0 @@ -package io.nekohasekai.sfa.update - -import kotlinx.serialization.Serializable -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json - -@Serializable -data class UpdateInfo( - val versionCode: Int, - val versionName: String, - val downloadUrl: String, - val releaseUrl: String, - val releaseNotes: String?, - val isPrerelease: Boolean, - val fileSize: Long = 0, -) { - fun toJson(): String = Json.encodeToString(this) - - companion object { - fun fromJson(json: String): UpdateInfo? = runCatching { - Json.decodeFromString(json) - }.getOrNull() - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/update/UpdateState.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/update/UpdateState.kt deleted file mode 100644 index 17efa3d654..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/update/UpdateState.kt +++ /dev/null @@ -1,90 +0,0 @@ -package io.nekohasekai.sfa.update - -import androidx.compose.runtime.mutableStateOf -import io.nekohasekai.sfa.BuildConfig -import io.nekohasekai.sfa.database.Settings -import java.io.File - -object UpdateState { - val hasUpdate = mutableStateOf(false) - val updateInfo = mutableStateOf(null) - val isChecking = mutableStateOf(false) - - val isDownloading = mutableStateOf(false) - val downloadError = mutableStateOf(null) - - val cachedApkFile = mutableStateOf(null) - - sealed class InstallStatus { - data object Idle : InstallStatus() - data object Installing : InstallStatus() - data object Success : InstallStatus() - data class Failed(val error: String) : InstallStatus() - } - - val installStatus = mutableStateOf(InstallStatus.Idle) - - fun setUpdate(info: UpdateInfo?) { - updateInfo.value = info - hasUpdate.value = info != null - saveToCache(info) - } - - fun setInstallStatus(status: InstallStatus) { - installStatus.value = status - } - - fun clear() { - hasUpdate.value = false - updateInfo.value = null - isDownloading.value = false - downloadError.value = null - installStatus.value = InstallStatus.Idle - cachedApkFile.value = null - clearCache() - } - - fun resetDownload() { - isDownloading.value = false - downloadError.value = null - } - - fun loadFromCache() { - val json = Settings.cachedUpdateInfo - if (json.isBlank()) return - - val info = UpdateInfo.fromJson(json) ?: return - if (info.versionCode <= BuildConfig.VERSION_CODE) { - clearCache() - return - } - - updateInfo.value = info - hasUpdate.value = true - - val apkPath = Settings.cachedApkPath - if (apkPath.isNotBlank()) { - val apkFile = File(apkPath) - if (apkFile.exists() && apkFile.length() > 0) { - cachedApkFile.value = apkFile - } else { - Settings.cachedApkPath = "" - } - } - } - - private fun saveToCache(info: UpdateInfo?) { - Settings.cachedUpdateInfo = info?.toJson() ?: "" - } - - fun saveApkPath(file: File) { - Settings.cachedApkPath = file.absolutePath - cachedApkFile.value = file - } - - private fun clearCache() { - Settings.cachedUpdateInfo = "" - Settings.cachedApkPath = "" - Settings.lastShownUpdateVersion = 0 - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/update/UpdateTrack.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/update/UpdateTrack.kt deleted file mode 100644 index d3e1c51767..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/update/UpdateTrack.kt +++ /dev/null @@ -1,14 +0,0 @@ -package io.nekohasekai.sfa.update - -enum class UpdateTrack { - STABLE, - BETA, - ; - - companion object { - fun fromString(value: String): UpdateTrack = when (value.lowercase()) { - "beta" -> BETA - else -> STABLE - } - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/utils/AppLifecycleObserver.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/utils/AppLifecycleObserver.kt deleted file mode 100644 index 9a2969fb42..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/utils/AppLifecycleObserver.kt +++ /dev/null @@ -1,54 +0,0 @@ -package io.nekohasekai.sfa.utils - -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.os.PowerManager -import androidx.core.content.getSystemService -import androidx.lifecycle.DefaultLifecycleObserver -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.ProcessLifecycleOwner -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow - -object AppLifecycleObserver : DefaultLifecycleObserver { - private val _isForeground = MutableStateFlow(true) - val isForeground: StateFlow = _isForeground.asStateFlow() - - private val _isScreenOn = MutableStateFlow(true) - val isScreenOn: StateFlow = _isScreenOn.asStateFlow() - - private val screenReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - when (intent.action) { - Intent.ACTION_SCREEN_ON -> _isScreenOn.value = true - Intent.ACTION_SCREEN_OFF -> _isScreenOn.value = false - } - } - } - - fun register(context: Context) { - ProcessLifecycleOwner.get().lifecycle.addObserver(this) - - val powerManager = context.getSystemService()!! - _isScreenOn.value = powerManager.isInteractive - - context.registerReceiver( - screenReceiver, - IntentFilter().apply { - addAction(Intent.ACTION_SCREEN_ON) - addAction(Intent.ACTION_SCREEN_OFF) - }, - ) - } - - override fun onStart(owner: LifecycleOwner) { - _isForeground.value = true - } - - override fun onStop(owner: LifecycleOwner) { - _isForeground.value = false - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/utils/ColorUtils.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/utils/ColorUtils.kt index a9808d108f..8d03180820 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/utils/ColorUtils.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/utils/ColorUtils.kt @@ -14,6 +14,7 @@ import io.nekohasekai.sfa.R import java.util.Stack object ColorUtils { + private val ansiRegex by lazy { Regex("\u001B\\[[;\\d]*m") } fun ansiEscapeToSpannable(context: Context, text: String): Spannable { @@ -32,12 +33,11 @@ object ColorUtils { if (ansiInstruction.decorationCode == "0" && stack.isNotEmpty()) { spans.add(stack.pop().copy(end = end - offset)) } else { - val span = - AnsiSpan( - AnsiInstruction(context, stringCode), - start - if (offset > start) start else offset - 1, - 0, - ) + val span = AnsiSpan( + AnsiInstruction(context, stringCode), + start - if (offset > start) start else offset - 1, + 0 + ) stack.push(span) } } @@ -48,7 +48,7 @@ object ColorUtils { it, ansiSpan.start, ansiSpan.end, - Spannable.SPAN_EXCLUSIVE_INCLUSIVE, + Spannable.SPAN_EXCLUSIVE_INCLUSIVE ) } } @@ -56,13 +56,15 @@ object ColorUtils { return spannable } - private data class AnsiSpan(val instruction: AnsiInstruction, val start: Int, val end: Int) + private data class AnsiSpan( + val instruction: AnsiInstruction, val start: Int, val end: Int + ) private class AnsiInstruction(context: Context, code: String) { + val spans: List by lazy { listOfNotNull( - getSpan(colorCode, context), - getSpan(decorationCode, context), + getSpan(colorCode, context), getSpan(decorationCode, context) ) } diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/utils/CommandClient.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/utils/CommandClient.kt index c5b7681f00..4b6c3bb2aa 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/utils/CommandClient.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/utils/CommandClient.kt @@ -1,107 +1,83 @@ package io.nekohasekai.sfa.utils -import android.util.Log import go.Seq import io.nekohasekai.libbox.CommandClient import io.nekohasekai.libbox.CommandClientHandler import io.nekohasekai.libbox.CommandClientOptions -import io.nekohasekai.libbox.ConnectionEvents +import io.nekohasekai.libbox.Connections import io.nekohasekai.libbox.Libbox -import io.nekohasekai.libbox.LogEntry -import io.nekohasekai.libbox.LogIterator import io.nekohasekai.libbox.OutboundGroup import io.nekohasekai.libbox.OutboundGroupIterator import io.nekohasekai.libbox.StatusMessage import io.nekohasekai.libbox.StringIterator import io.nekohasekai.sfa.ktx.toList import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch open class CommandClient( private val scope: CoroutineScope, - private val connectionTypes: List, + private val connectionType: ConnectionType, private val handler: Handler, ) { - constructor( - scope: CoroutineScope, - connectionType: ConnectionType, - handler: Handler, - ) : this(scope, listOf(connectionType), handler) - - private val additionalHandlers = mutableListOf() - private var cachedGroups: MutableList? = null - - fun addHandler(handler: Handler) { - synchronized(additionalHandlers) { - if (!additionalHandlers.contains(handler)) { - additionalHandlers.add(handler) - cachedGroups?.let { groups -> - handler.updateGroups(groups) - } - } - } - } - - fun removeHandler(handler: Handler) { - synchronized(additionalHandlers) { - additionalHandlers.remove(handler) - } - } - - private fun getAllHandlers(): List = synchronized(additionalHandlers) { - listOf(handler) + additionalHandlers - } enum class ConnectionType { - Status, - Groups, - Log, - ClashMode, - Connections, + Status, Groups, Log, ClashMode } interface Handler { - fun onConnected() {} + fun onConnected() {} fun onDisconnected() {} fun updateStatus(status: StatusMessage) {} - fun setDefaultLogLevel(level: Int) {} - fun clearLogs() {} - - fun appendLogs(message: List) {} + fun appendLogs(message: List) {} fun updateGroups(newGroups: MutableList) {} fun initializeClashMode(modeList: List, currentMode: String) {} - fun updateClashMode(newMode: String) {} - fun writeConnectionEvents(events: ConnectionEvents) {} } private var commandClient: CommandClient? = null private val clientHandler = ClientHandler() - fun connect() { disconnect() val options = CommandClientOptions() - connectionTypes.forEach { connectionType -> - val command = - when (connectionType) { - ConnectionType.Status -> Libbox.CommandStatus - ConnectionType.Groups -> Libbox.CommandGroup - ConnectionType.Log -> Libbox.CommandLog - ConnectionType.ClashMode -> Libbox.CommandClashMode - ConnectionType.Connections -> Libbox.CommandConnections - } - options.addCommand(command) + options.command = when (connectionType) { + ConnectionType.Status -> Libbox.CommandStatus + ConnectionType.Groups -> Libbox.CommandGroup + ConnectionType.Log -> Libbox.CommandLog + ConnectionType.ClashMode -> Libbox.CommandClashMode } options.statusInterval = 1 * 1000 * 1000 * 1000 val commandClient = CommandClient(clientHandler, options) - commandClient.connect() - this.commandClient = commandClient + scope.launch(Dispatchers.IO) { + for (i in 1..10) { + delay(100 + i.toLong() * 50) + try { + commandClient.connect() + } catch (ignored: Exception) { + continue + } + if (!isActive) { + runCatching { + commandClient.disconnect() + } + return@launch + } + this@CommandClient.commandClient = commandClient + return@launch + } + runCatching { + commandClient.disconnect() + } + } } fun disconnect() { @@ -109,20 +85,19 @@ open class CommandClient( runCatching { disconnect() } -// Seq.destroyRef(refnum) + Seq.destroyRef(refnum) } commandClient = null } private inner class ClientHandler : CommandClientHandler { + override fun connected() { - getAllHandlers().forEach { it.onConnected() } - Log.d("CommandClient", "connected") + handler.onConnected() } override fun disconnected(message: String?) { - getAllHandlers().forEach { it.onDisconnected() } - Log.d("CommandClient", "disconnected: $message") + handler.onDisconnected() } override fun writeGroups(message: OutboundGroupIterator?) { @@ -133,42 +108,34 @@ open class CommandClient( while (message.hasNext()) { groups.add(message.next()) } - cachedGroups = groups - getAllHandlers().forEach { it.updateGroups(groups) } - } - - override fun setDefaultLogLevel(level: Int) { - getAllHandlers().forEach { it.setDefaultLogLevel(level) } + handler.updateGroups(groups) } override fun clearLogs() { - getAllHandlers().forEach { it.clearLogs() } + handler.clearLogs() } - override fun writeLogs(messageList: LogIterator?) { + override fun writeLogs(messageList: StringIterator?) { if (messageList == null) { return } - val logs = messageList.toList() - getAllHandlers().forEach { it.appendLogs(logs) } + handler.appendLogs(messageList.toList()) } override fun writeStatus(message: StatusMessage) { - getAllHandlers().forEach { it.updateStatus(message) } + handler.updateStatus(message) } override fun initializeClashMode(modeList: StringIterator, currentMode: String) { - val modes = modeList.toList() - getAllHandlers().forEach { it.initializeClashMode(modes, currentMode) } + handler.initializeClashMode(modeList.toList(), currentMode) } override fun updateClashMode(newMode: String) { - getAllHandlers().forEach { it.updateClashMode(newMode) } + handler.updateClashMode(newMode) } - override fun writeConnectionEvents(events: ConnectionEvents?) { - if (events == null) return - getAllHandlers().forEach { it.writeConnectionEvents(events) } + override fun writeConnections(message: Connections?) { } } -} + +} \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/utils/ConnectivityBinderUtils.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/utils/ConnectivityBinderUtils.kt deleted file mode 100644 index 38396f330d..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/utils/ConnectivityBinderUtils.kt +++ /dev/null @@ -1,45 +0,0 @@ -package io.nekohasekai.sfa.utils - -import android.content.Context -import android.net.ConnectivityManager -import android.os.IBinder -import android.os.Parcel -import android.util.Log - -object ConnectivityBinderUtils { - private const val TAG = "ConnectivityBinderUtils" - - fun getBinder(context: Context): IBinder? { - val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager - ?: return null - try { - val field = cm.javaClass.getDeclaredField("mService") - field.isAccessible = true - val service = field.get(cm) as? android.os.IInterface - if (service != null) { - return service.asBinder() - } - } catch (e: Throwable) { - Log.w(TAG, "Failed to get ConnectivityManager service binder", e) - } - return try { - val serviceManager = Class.forName("android.os.ServiceManager") - val getService = serviceManager.getMethod("getService", String::class.java) - getService.invoke(null, Context.CONNECTIVITY_SERVICE) as? IBinder - } catch (e: Throwable) { - Log.w(TAG, "Failed to get binder from ServiceManager", e) - null - } - } - - inline fun withParcel(block: (data: Parcel, reply: Parcel) -> T): T { - val data = Parcel.obtain() - val reply = Parcel.obtain() - return try { - block(data, reply) - } finally { - reply.recycle() - data.recycle() - } - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/utils/HTTPClient.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/utils/HTTPClient.kt index 5bdaef4817..64f785b268 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/utils/HTTPClient.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/utils/HTTPClient.kt @@ -7,6 +7,7 @@ import java.io.Closeable import java.util.Locale class HTTPClient : Closeable { + companion object { val userAgent by lazy { var userAgent = "SFA/" @@ -39,4 +40,6 @@ class HTTPClient : Closeable { override fun close() { client.close() } -} + + +} \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/utils/HookErrorClient.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/utils/HookErrorClient.kt deleted file mode 100644 index 0d1176ec44..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/utils/HookErrorClient.kt +++ /dev/null @@ -1,48 +0,0 @@ -package io.nekohasekai.sfa.utils - -import android.content.Context -import android.os.RemoteException -import io.nekohasekai.sfa.bg.LogEntry -import io.nekohasekai.sfa.bg.ParceledListSlice -import io.nekohasekai.sfa.xposed.HookStatusKeys - -object HookErrorClient { - enum class Failure { - SERVICE_UNAVAILABLE, - TRANSACTION_FAILED, - REMOTE_ERROR, - PROTOCOL_ERROR, - } - - data class Result(val logs: List, val hasWarnings: Boolean, val failure: Failure? = null, val detail: String? = null) - - private fun failureResult(failure: Failure, detail: String? = null) = Result( - logs = emptyList(), - hasWarnings = false, - failure = failure, - detail = detail, - ) - - fun query(context: Context): Result { - val binder = ConnectivityBinderUtils.getBinder(context) - ?: return failureResult(Failure.SERVICE_UNAVAILABLE) - return ConnectivityBinderUtils.withParcel { data, reply -> - data.writeInterfaceToken(HookStatusKeys.DESCRIPTOR) - if (!binder.transact(HookStatusKeys.TRANSACTION_GET_ERRORS, data, reply, 0)) { - return@withParcel failureResult(Failure.TRANSACTION_FAILED) - } - try { - reply.readException() - } catch (e: RemoteException) { - return@withParcel failureResult(Failure.REMOTE_ERROR, e.message) - } - if (reply.dataAvail() < 4) { - return@withParcel failureResult(Failure.PROTOCOL_ERROR, "reply too short: ${reply.dataAvail()}") - } - val hasWarnings = reply.readInt() != 0 - val slice = ParceledListSlice.CREATOR.createFromParcel(reply, LogEntry::class.java.classLoader) - @Suppress("UNCHECKED_CAST") - Result(logs = slice.list as List, hasWarnings = hasWarnings) - } - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/utils/HookModuleUpdateNotifier.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/utils/HookModuleUpdateNotifier.kt deleted file mode 100644 index 45e88be46a..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/utils/HookModuleUpdateNotifier.kt +++ /dev/null @@ -1,77 +0,0 @@ -package io.nekohasekai.sfa.utils - -import android.app.NotificationChannel -import android.app.NotificationManager -import android.app.PendingIntent -import android.content.Context -import android.content.Intent -import android.os.Build -import androidx.core.app.NotificationCompat -import androidx.core.app.NotificationManagerCompat -import io.nekohasekai.sfa.R -import io.nekohasekai.sfa.bg.ServiceNotification -import io.nekohasekai.sfa.compose.MainActivity -import io.nekohasekai.sfa.xposed.HookModuleVersion - -object HookModuleUpdateNotifier { - private const val CHANNEL_ID = "lsposed_module_update" - private const val NOTIFICATION_ID = 0x5F10 - - fun needsRestart(status: HookStatusClient.Status?): Boolean = isDowngrade(status) || isUpgrade(status) - - fun isDowngrade(status: HookStatusClient.Status?): Boolean = status != null && status.version > HookModuleVersion.CURRENT - - fun isUpgrade(status: HookStatusClient.Status?): Boolean = status != null && status.version < HookModuleVersion.CURRENT - - fun sync(context: Context) { - HookStatusClient.refresh() - maybeNotify(context, HookStatusClient.status.value) - } - - fun maybeNotify(context: Context, status: HookStatusClient.Status?) { - if (!needsRestart(status)) { - cancel(context) - return - } - ensureChannel(context) - val intent = - Intent(context, MainActivity::class.java).apply { - flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP - addCategory("de.robv.android.xposed.category.MODULE_SETTINGS") - } - val pendingIntent = - PendingIntent.getActivity( - context, - 0, - intent, - PendingIntent.FLAG_UPDATE_CURRENT or ServiceNotification.flags, - ) - val builder = - NotificationCompat.Builder(context, CHANNEL_ID) - .setSmallIcon(R.drawable.ic_menu) - .setContentTitle(context.getString(R.string.privilege_module_restart_notification_title)) - .setContentText(context.getString(R.string.privilege_module_restart_notification_message)) - .setContentIntent(pendingIntent) - .setAutoCancel(true) - .setCategory(NotificationCompat.CATEGORY_STATUS) - .setPriority(NotificationCompat.PRIORITY_HIGH) - NotificationManagerCompat.from(context).notify(NOTIFICATION_ID, builder.build()) - } - - private fun cancel(context: Context) { - NotificationManagerCompat.from(context).cancel(NOTIFICATION_ID) - } - - private fun ensureChannel(context: Context) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - manager.createNotificationChannel( - NotificationChannel( - CHANNEL_ID, - context.getString(R.string.privilege_module_restart_channel), - NotificationManager.IMPORTANCE_HIGH, - ), - ) - } - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/utils/HookStatusClient.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/utils/HookStatusClient.kt deleted file mode 100644 index e0bd730eb6..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/utils/HookStatusClient.kt +++ /dev/null @@ -1,61 +0,0 @@ -package io.nekohasekai.sfa.utils - -import android.content.Context -import android.content.pm.PackageInfo -import io.nekohasekai.sfa.bg.ParceledListSlice -import io.nekohasekai.sfa.xposed.HookStatusKeys -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow - -object HookStatusClient { - data class Status(val active: Boolean, val lastPatchedAt: Long, val version: Int, val systemPid: Int) - - private val statusFlow = MutableStateFlow(null) - val status: StateFlow = statusFlow - - @Volatile - private var appContext: Context? = null - - fun register(context: Context) { - appContext = context.applicationContext - refresh() - } - - fun refresh() { - val context = appContext ?: return - val binder = ConnectivityBinderUtils.getBinder(context) ?: run { - statusFlow.value = null - return - } - ConnectivityBinderUtils.withParcel { data, reply -> - data.writeInterfaceToken(HookStatusKeys.DESCRIPTOR) - val ok = binder.transact(HookStatusKeys.TRANSACTION_STATUS, data, reply, 0) - if (!ok) { - statusFlow.value = null - return - } - reply.readException() - statusFlow.value = Status( - active = reply.readInt() != 0, - lastPatchedAt = reply.readLong(), - version = reply.readInt(), - systemPid = reply.readInt(), - ) - } - } - - fun getInstalledPackages(context: Context, flags: Long, userId: Int): List? { - val binder = ConnectivityBinderUtils.getBinder(context) ?: return null - return ConnectivityBinderUtils.withParcel { data, reply -> - data.writeInterfaceToken(HookStatusKeys.DESCRIPTOR) - data.writeLong(flags) - data.writeInt(userId) - val ok = binder.transact(HookStatusKeys.TRANSACTION_GET_INSTALLED_PACKAGES, data, reply, 0) - if (!ok) return@withParcel null - reply.readException() - val slice = ParceledListSlice.CREATOR.createFromParcel(reply, PackageInfo::class.java.classLoader) - @Suppress("UNCHECKED_CAST") - (slice as ParceledListSlice).list - } - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/utils/MIUIUtils.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/utils/MIUIUtils.kt index ebc678eafe..37eae60780 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/utils/MIUIUtils.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/utils/MIUIUtils.kt @@ -6,6 +6,7 @@ import android.content.Intent import android.os.Process object MIUIUtils { + val isMIUI by lazy { !getSystemProperty("ro.miui.ui.version.name").isNullOrBlank() } @@ -26,4 +27,5 @@ object MIUIUtils { intent.putExtra("extra_pkgname", context.packageName) context.startActivity(intent) } -} + +} \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/utils/PrivilegeSettingsClient.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/utils/PrivilegeSettingsClient.kt deleted file mode 100644 index 283e714ebe..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/utils/PrivilegeSettingsClient.kt +++ /dev/null @@ -1,68 +0,0 @@ -package io.nekohasekai.sfa.utils - -import android.content.Context -import android.os.RemoteException -import android.util.Log -import io.nekohasekai.sfa.bg.PackageEntry -import io.nekohasekai.sfa.bg.ParceledListSlice -import io.nekohasekai.sfa.bg.RootClient -import io.nekohasekai.sfa.database.Settings -import io.nekohasekai.sfa.xposed.HookModuleVersion -import io.nekohasekai.sfa.xposed.HookStatusKeys - -object PrivilegeSettingsClient { - private const val TAG = "PrivilegeSettingsClient" - - @Volatile - private var appContext: Context? = null - - data class ExportResult(val outputPath: String?, val error: String?) - - fun register(context: Context) { - appContext = context.applicationContext - sync() - } - - fun sync(): Throwable? { - val context = appContext ?: return null - if (isVersionMismatch()) return null - val binder = ConnectivityBinderUtils.getBinder(context) ?: return null - return ConnectivityBinderUtils.withParcel { data, reply -> - data.writeInterfaceToken(HookStatusKeys.DESCRIPTOR) - data.writeInt(if (Settings.privilegeSettingsEnabled) 1 else 0) - ParceledListSlice(Settings.privilegeSettingsList.map { PackageEntry(it) }).writeToParcel(data, 0) - data.writeInt(if (Settings.privilegeSettingsInterfaceRenameEnabled) 1 else 0) - data.writeString(Settings.privilegeSettingsInterfacePrefix) - try { - val ok = binder.transact(HookStatusKeys.TRANSACTION_UPDATE_PRIVILEGE_SETTINGS, data, reply, 0) - reply.readException() - if (!ok) { - val error = RemoteException() - Log.w(TAG, "Privilege settings sync failed: transaction not handled", error) - return@withParcel error - } - return@withParcel null - } catch (e: RemoteException) { - Log.w(TAG, "Privilege settings sync failed: remote exception", e) - return@withParcel e - } catch (e: RuntimeException) { - Log.w(TAG, "Privilege settings sync failed: bad reply", e) - return@withParcel e - } - } - } - - suspend fun exportDebugInfo(outputPath: String): ExportResult = try { - val service = RootClient.bindService() - val path = service.exportDebugInfo(outputPath) - ExportResult(path, null) - } catch (e: Throwable) { - Log.e(TAG, "Export debug info failed", e) - ExportResult(null, e.message ?: "export failed") - } - - private fun isVersionMismatch(): Boolean { - val status = HookStatusClient.status.value ?: return false - return status.version != HookModuleVersion.CURRENT - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/utils/VpnDetectionTest.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/utils/VpnDetectionTest.kt deleted file mode 100644 index f210ed10cc..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/utils/VpnDetectionTest.kt +++ /dev/null @@ -1,162 +0,0 @@ -package io.nekohasekai.sfa.utils - -import android.content.Context -import android.net.ConnectivityManager -import android.net.Network -import android.net.NetworkCapabilities -import java.net.NetworkInterface -import java.util.Collections - -data class DetectionResult( - val frameworkDetected: List, - val nativeDetected: Boolean, - val frameworkInterfaces: List, - val nativeInterfaces: List, - val httpProxy: String?, -) - -object VpnDetectionTest { - - fun runDetection(context: Context): DetectionResult { - val frameworkDetected = LinkedHashSet() - val frameworkInterfaces = LinkedHashSet() - - val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager - ?: return DetectionResult(emptyList(), false, emptyList(), emptyList(), null) - - // Check activeNetworkInfo - val activeInfo = cm.activeNetworkInfo - if (activeInfo?.type == ConnectivityManager.TYPE_VPN) { - frameworkDetected += "ActiveNetworkInfo" - } - - // Check networkInfo(TYPE_VPN) - val vpnInfo = cm.getNetworkInfo(ConnectivityManager.TYPE_VPN) - if (vpnInfo != null && vpnInfo.isConnected) { - frameworkDetected += "NetworkInfo" - } - - // Check networkForType(VPN) - val vpnNetwork = runCatching { - val method = cm.javaClass.getMethod( - "getNetworkForType", - Int::class.javaPrimitiveType, - ) - method.invoke(cm, ConnectivityManager.TYPE_VPN) as? Network - }.getOrNull() - if (vpnNetwork != null) { - frameworkDetected += "NetworkForType" - } - - // Check all networks for VPN transport or missing NOT_VPN capability - val networks = cm.allNetworks ?: emptyArray() - for (network in networks) { - val caps = cm.getNetworkCapabilities(network) ?: continue - if (caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) { - frameworkDetected += "NetworkCapabilities" - } - if (!caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)) { - frameworkDetected += "NetworkCapabilities" - } - // Check interface name in LinkProperties - val lp = cm.getLinkProperties(network) - if (isVpnInterface(lp?.interfaceName)) { - lp?.interfaceName?.let(frameworkInterfaces::add) - frameworkDetected += "LinkProperties" - } - } - - // Check activeLinkProperties interface - val activeLinkProperties = runCatching { cm.getLinkProperties(cm.activeNetwork) }.getOrNull() - if (isVpnInterface(activeLinkProperties?.interfaceName)) { - activeLinkProperties?.interfaceName?.let(frameworkInterfaces::add) - frameworkDetected += "LinkProperties" - } - - // Native: Check network interfaces (getifaddrs) - val nativeInterfaces = checkNetworkInterfaces() - - val httpProxy = readHttpProxy(cm) - return DetectionResult( - frameworkDetected.toList(), - nativeInterfaces.isNotEmpty(), - frameworkInterfaces.toList(), - nativeInterfaces, - httpProxy, - ) - } - - private fun checkNetworkInterfaces(): List { - val list = try { - Collections.list(NetworkInterface.getNetworkInterfaces()) - } catch (_: Throwable) { - return emptyList() - } - val matches = ArrayList() - for (iface in list) { - val name = iface.name ?: continue - val isUp = runCatching { iface.isUp }.getOrElse { false } - if (!isUp) continue - if (isVpnInterface(name)) { - matches.add(name) - } - } - return matches - } - - private fun isVpnInterface(name: String?): Boolean { - if (name.isNullOrEmpty()) return false - val lower = name.lowercase() - return lower.startsWith("tun") || lower.startsWith("ppp") || lower.startsWith("tap") - } - - private fun readHttpProxy(cm: ConnectivityManager): String? { - val defaultProxy = try { - val method = cm.javaClass.getMethod("getDefaultProxy") - method.invoke(cm) as? android.net.ProxyInfo - } catch (_: Throwable) { - null - } - val activeLinkProperties = runCatching { cm.getLinkProperties(cm.activeNetwork) }.getOrNull() - val networks = cm.allNetworks ?: emptyArray() - val proxies = buildList { - add(formatProxyInfo(defaultProxy)) - add(formatProxyInfo(readProxyFromLinkProperties(activeLinkProperties))) - for (network in networks) { - add(formatProxyInfo(readProxyFromLinkProperties(cm.getLinkProperties(network)))) - } - } - return proxies.firstOrNull { !it.isNullOrEmpty() } - } - - private fun readProxyFromLinkProperties(lp: android.net.LinkProperties?): android.net.ProxyInfo? { - if (lp == null) return null - return try { - val method = lp.javaClass.getMethod("getHttpProxy") - method.invoke(lp) as? android.net.ProxyInfo - } catch (_: Throwable) { - try { - val field = lp.javaClass.getDeclaredField("mHttpProxy") - field.isAccessible = true - field.get(lp) as? android.net.ProxyInfo - } catch (_: Throwable) { - null - } - } - } - - private fun formatProxyInfo(proxyInfo: android.net.ProxyInfo?): String? { - if (proxyInfo == null) return null - return try { - val host = proxyInfo.host - val port = proxyInfo.port - if (!host.isNullOrEmpty() && port > 0) { - return "$host:$port" - } - val pac = proxyInfo.pacFileUrl?.toString() - if (!pac.isNullOrEmpty()) pac else null - } catch (_: Throwable) { - null - } - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/vendor/PackageQueryStrategy.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/vendor/PackageQueryStrategy.kt deleted file mode 100644 index 01e7ba2439..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/vendor/PackageQueryStrategy.kt +++ /dev/null @@ -1,7 +0,0 @@ -package io.nekohasekai.sfa.vendor - -sealed class PackageQueryStrategy { - data object ForcedRoot : PackageQueryStrategy() - data class UserSelected(val mode: String) : PackageQueryStrategy() - data object Direct : PackageQueryStrategy() -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/vendor/PrivilegedAccessRequiredException.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/vendor/PrivilegedAccessRequiredException.kt deleted file mode 100644 index f582c4d169..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/vendor/PrivilegedAccessRequiredException.kt +++ /dev/null @@ -1,3 +0,0 @@ -package io.nekohasekai.sfa.vendor - -class PrivilegedAccessRequiredException(message: String) : Exception(message) diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/vendor/PrivilegedServiceUtils.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/vendor/PrivilegedServiceUtils.kt deleted file mode 100644 index 79a051629b..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/vendor/PrivilegedServiceUtils.kt +++ /dev/null @@ -1,183 +0,0 @@ -package io.nekohasekai.sfa.vendor - -import android.content.IIntentSender -import android.content.Intent -import android.content.IntentSender -import android.content.pm.IPackageInstaller -import android.content.pm.IPackageInstallerSession -import android.content.pm.PackageInfo -import android.content.pm.PackageInstaller -import android.os.Build -import android.os.Bundle -import android.os.IBinder -import android.os.ParcelFileDescriptor -import android.system.Os -import io.nekohasekai.sfa.BuildConfig -import java.io.IOException -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit - -object PrivilegedServiceUtils { - - private val iPackageManagerStubClass by lazy { Class.forName("android.content.pm.IPackageManager\$Stub") } - private val asInterfaceMethod by lazy { iPackageManagerStubClass.getMethod("asInterface", IBinder::class.java) } - private val iPackageManagerClass by lazy { Class.forName("android.content.pm.IPackageManager") } - - private val getInstalledPackagesMethodLong by lazy { - iPackageManagerClass.getMethod( - "getInstalledPackages", - Long::class.javaPrimitiveType, - Int::class.javaPrimitiveType, - ) - } - private val getInstalledPackagesMethodInt by lazy { - iPackageManagerClass.getMethod( - "getInstalledPackages", - Int::class.javaPrimitiveType, - Int::class.javaPrimitiveType, - ) - } - private val getPackageInstallerMethod by lazy { iPackageManagerClass.getMethod("getPackageInstaller") } - - private val packageInstallerCtorS by lazy { - PackageInstaller::class.java.getConstructor( - IPackageInstaller::class.java, - String::class.java, - String::class.java, - Int::class.javaPrimitiveType, - ) - } - private val packageInstallerCtorPre by lazy { - PackageInstaller::class.java.getConstructor( - IPackageInstaller::class.java, - String::class.java, - Int::class.javaPrimitiveType, - ) - } - private val sessionCtor by lazy { - PackageInstaller.Session::class.java.getConstructor(IPackageInstallerSession::class.java) - } - private val intentSenderCtor by lazy { - IntentSender::class.java.getConstructor(IIntentSender::class.java) - } - private val installFlagsField by lazy { - PackageInstaller.SessionParams::class.java.getDeclaredField("installFlags").apply { isAccessible = true } - } - private val getListMethod by lazy { - Class.forName("android.content.pm.ParceledListSlice").getMethod("getList") - } - - private fun getPackageManager(): Any { - val binder = SystemServiceHelperCompat.getSystemService("package") - ?: throw IllegalStateException("package service not available") - return asInterfaceMethod.invoke(null, binder) - ?: throw IllegalStateException("IPackageManager is null") - } - - fun getInstalledPackages(flags: Int, userId: Int): List { - val iPackageManager = getPackageManager() - val result = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - getInstalledPackagesMethodLong.invoke(iPackageManager, flags.toLong(), userId) - } else { - getInstalledPackagesMethodInt.invoke(iPackageManager, flags, userId) - } - return extractPackageList(result) - } - - fun installPackage(apkFd: ParcelFileDescriptor, size: Long, userId: Int) { - val iPackageInstaller = getPackageInstaller() - val isRoot = Os.getuid() == 0 - val installerPackageName = if (isRoot) BuildConfig.APPLICATION_ID else "com.android.shell" - val targetUserId = if (isRoot) userId else 0 - - val packageInstaller = createPackageInstaller( - iPackageInstaller, - installerPackageName, - null, - targetUserId, - ) - - val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL) - params.setAppPackageName(BuildConfig.APPLICATION_ID) - installFlagsField.setInt(params, installFlagsField.getInt(params) or 2) - val sessionId = packageInstaller.createSession(params) - - val iSession = IPackageInstallerSession.Stub.asInterface( - iPackageInstaller.openSession(sessionId).asBinder(), - ) - val session = createSession(iSession) - - try { - ParcelFileDescriptor.AutoCloseInputStream(apkFd).use { inputStream -> - session.openWrite("base.apk", 0, size).use { outputStream -> - inputStream.copyTo(outputStream) - session.fsync(outputStream) - } - } - - val resultIntent = arrayOfNulls(1) - val latch = CountDownLatch(1) - - val intentSender = createIntentSender { intent -> - resultIntent[0] = intent - latch.countDown() - } - - session.commit(intentSender) - latch.await(60, TimeUnit.SECONDS) - - val intent = resultIntent[0] ?: throw IOException("Installation timed out") - - val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE) - if (status != PackageInstaller.STATUS_SUCCESS) { - val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE) - throw IOException("Installation failed ($status): $message") - } - } finally { - session.close() - } - } - - private fun getPackageInstaller(): IPackageInstaller { - val iPackageManager = getPackageManager() - val installer = getPackageInstallerMethod.invoke(iPackageManager) as IPackageInstaller - return IPackageInstaller.Stub.asInterface(installer.asBinder()) - } - - private fun createPackageInstaller( - installer: IPackageInstaller, - installerPackageName: String, - installerAttributionTag: String?, - userId: Int, - ): PackageInstaller = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - packageInstallerCtorS.newInstance(installer, installerPackageName, installerAttributionTag, userId) - } else { - packageInstallerCtorPre.newInstance(installer, installerPackageName, userId) - } - - private fun createSession(session: IPackageInstallerSession): PackageInstaller.Session = sessionCtor.newInstance(session) - - private fun createIntentSender(onResult: (Intent) -> Unit): IntentSender { - val sender = object : IIntentSender.Stub() { - override fun send( - code: Int, - intent: Intent, - resolvedType: String?, - whitelistToken: android.os.IBinder?, - finishedReceiver: android.content.IIntentReceiver?, - requiredPermission: String?, - options: Bundle?, - ) { - onResult(intent) - } - } - return intentSenderCtor.newInstance(sender) - } - - @Suppress("UNCHECKED_CAST") - private fun extractPackageList(parceledListSlice: Any?): List { - if (parceledListSlice == null) return emptyList() - val list = getListMethod.invoke(parceledListSlice) as? List<*> - return list?.filterIsInstance() ?: emptyList() - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/vendor/SystemServiceHelperCompat.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/vendor/SystemServiceHelperCompat.kt deleted file mode 100644 index f6a9db99bb..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/vendor/SystemServiceHelperCompat.kt +++ /dev/null @@ -1,33 +0,0 @@ -package io.nekohasekai.sfa.vendor - -import android.annotation.SuppressLint -import android.os.IBinder -import android.util.Log -import java.lang.reflect.Method - -@SuppressLint("PrivateApi") -object SystemServiceHelperCompat { - - private val serviceCache = HashMap() - private val getService: Method? = try { - val cls = Class.forName("android.os.ServiceManager") - cls.getMethod("getService", String::class.java) - } catch (e: Exception) { - Log.w("SystemServiceHelper", Log.getStackTraceString(e)) - null - } - - fun getSystemService(name: String): IBinder? { - if (serviceCache.containsKey(name)) { - return serviceCache[name] - } - val binder = try { - getService?.invoke(null, name) as? IBinder - } catch (e: Exception) { - Log.w("SystemServiceHelper", Log.getStackTraceString(e)) - null - } - serviceCache[name] = binder - return binder - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/vendor/VendorInterface.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/vendor/VendorInterface.kt index e72e00ceec..718896994f 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/vendor/VendorInterface.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/vendor/VendorInterface.kt @@ -2,65 +2,12 @@ package io.nekohasekai.sfa.vendor import android.app.Activity import androidx.camera.core.ImageAnalysis -import io.nekohasekai.sfa.compose.screen.qrscan.QRCodeCropArea -import io.nekohasekai.sfa.update.UpdateInfo interface VendorInterface { + fun checkUpdateAvailable(): Boolean fun checkUpdate(activity: Activity, byUser: Boolean) - fun createQRCodeAnalyzer( onSuccess: (String) -> Unit, - onFailure: (Exception) -> Unit, - onCropArea: ((QRCodeCropArea?) -> Unit)? = null, + onFailure: (Exception) -> Unit ): ImageAnalysis.Analyzer? - - /** - * Check if Per-app Proxy feature is available - * @return true if available, false if disabled (e.g., for Play Store builds) - */ - fun isPerAppProxyAvailable(): Boolean = true - - /** - * Check if track selection is available (e.g., stable/beta) - * @return true if track selection is supported - */ - fun supportsTrackSelection(): Boolean = false - - /** - * Check for updates asynchronously - * @return UpdateInfo if update is available, null otherwise - */ - fun checkUpdateAsync(): UpdateInfo? = null - - /** - * Check if silent install feature is available - * @return true if silent install is supported (Other flavor only) - */ - fun supportsSilentInstall(): Boolean = false - - /** - * Check if auto update feature is available - * @return true if auto update is supported (Other flavor only) - */ - fun supportsAutoUpdate(): Boolean = false - - /** - * Schedule auto update worker - */ - fun scheduleAutoUpdate() {} - - /** - * Verify if the specified silent install method is available - * @param method The install method (SHIZUKU or ROOT) - * @return true if the method is available and working - */ - suspend fun verifySilentInstallMethod(method: String): Boolean = false - - /** - * Download and install an APK update - * @param context The context - * @param downloadUrl The URL to download the APK from - * @throws Exception if download or install fails - */ - suspend fun downloadAndInstall(context: android.content.Context, downloadUrl: String): Unit = throw UnsupportedOperationException("Not supported in this flavor") -} +} \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/HookErrorStore.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/HookErrorStore.kt deleted file mode 100644 index 2603ba9f6a..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/HookErrorStore.kt +++ /dev/null @@ -1,91 +0,0 @@ -package io.nekohasekai.sfa.xposed - -import android.util.Log -import io.nekohasekai.sfa.BuildConfig -import io.nekohasekai.sfa.bg.LogEntry -import java.util.ArrayDeque - -object HookErrorStore { - private const val MAX_ENTRIES = 100 - - private val lock = Any() - private val entries = ArrayDeque() - - fun i(source: String, message: String, throwable: Throwable? = null) { - log(LogEntry.LEVEL_INFO, source, message, throwable, store = true) - } - - fun w(source: String, message: String, throwable: Throwable? = null) { - log(LogEntry.LEVEL_WARN, source, message, throwable, store = true) - } - - fun e(source: String, message: String, throwable: Throwable? = null) { - log(LogEntry.LEVEL_ERROR, source, message, throwable, store = true) - } - - fun d(source: String, message: String, throwable: Throwable? = null) { - log(LogEntry.LEVEL_DEBUG, source, message, throwable, store = false) - } - - private fun log(level: Int, source: String, message: String, throwable: Throwable?, store: Boolean) { - if (BuildConfig.DEBUG) { - when (level) { - LogEntry.LEVEL_DEBUG -> { - if (throwable != null) { - Log.d(XposedInit.TAG, "[$source] $message", throwable) - } else { - Log.d(XposedInit.TAG, "[$source] $message") - } - } - LogEntry.LEVEL_INFO -> { - if (throwable != null) { - Log.i(XposedInit.TAG, "[$source] $message", throwable) - } else { - Log.i(XposedInit.TAG, "[$source] $message") - } - } - LogEntry.LEVEL_WARN -> { - if (throwable != null) { - Log.w(XposedInit.TAG, "[$source] $message", throwable) - } else { - Log.w(XposedInit.TAG, "[$source] $message") - } - } - LogEntry.LEVEL_ERROR -> { - if (throwable != null) { - Log.e(XposedInit.TAG, "[$source] $message", throwable) - } else { - Log.e(XposedInit.TAG, "[$source] $message") - } - } - } - } - if (!store || level == LogEntry.LEVEL_DEBUG) return - val stackTrace = throwable?.let { Log.getStackTraceString(it) } - val entry = LogEntry(level, System.currentTimeMillis(), source, message, stackTrace) - synchronized(lock) { - entries.addLast(entry) - while (entries.size > MAX_ENTRIES) { - entries.removeFirst() - } - } - } - - fun snapshot(): List { - synchronized(lock) { - return entries.toList() - } - } - - fun hasWarnings(): Boolean { - synchronized(lock) { - return entries.any { it.level >= LogEntry.LEVEL_WARN } - } - } - - fun clear() { - synchronized(lock) { - entries.clear() - } - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/HookModuleVersion.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/HookModuleVersion.kt deleted file mode 100644 index fa29f11808..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/HookModuleVersion.kt +++ /dev/null @@ -1,5 +0,0 @@ -package io.nekohasekai.sfa.xposed - -object HookModuleVersion { - const val CURRENT = 3 -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/HookStatusKeys.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/HookStatusKeys.kt deleted file mode 100644 index f8ee3cd5a5..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/HookStatusKeys.kt +++ /dev/null @@ -1,10 +0,0 @@ -package io.nekohasekai.sfa.xposed - -object HookStatusKeys { - const val DESCRIPTOR = "android.net.IConnectivityManager" - const val TRANSACTION_STATUS = 0x5F00 - const val TRANSACTION_UPDATE_PRIVILEGE_SETTINGS = 0x5F01 - const val TRANSACTION_GET_ERRORS = 0x5F02 - const val TRANSACTION_EXPORT_DEBUG_INFO = 0x5F03 - const val TRANSACTION_GET_INSTALLED_PACKAGES = 0x5F04 -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/HookStatusStore.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/HookStatusStore.kt deleted file mode 100644 index 17d9d60e52..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/HookStatusStore.kt +++ /dev/null @@ -1,23 +0,0 @@ -package io.nekohasekai.sfa.xposed - -import android.os.Process - -object HookStatusStore { - @Volatile - private var active = false - - @Volatile - private var lastPatchedAt = 0L - - fun markHookActive() { - active = true - } - - fun markPatched() { - lastPatchedAt = System.currentTimeMillis() - } - - fun snapshot(): Status = Status(active, lastPatchedAt, HookModuleVersion.CURRENT, Process.myPid()) - - data class Status(val active: Boolean, val lastPatchedAt: Long, val version: Int, val systemPid: Int) -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/PrivilegeChecker.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/PrivilegeChecker.kt deleted file mode 100644 index cbd7514aac..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/PrivilegeChecker.kt +++ /dev/null @@ -1,146 +0,0 @@ -package io.nekohasekai.sfa.xposed - -import android.content.pm.ApplicationInfo -import android.content.pm.PackageManager -import android.os.Process -import java.lang.reflect.Method -import java.util.concurrent.ConcurrentHashMap - -object PrivilegeChecker { - private const val PER_USER_RANGE = 100000 - private val privilegedPermissions = arrayOf( - "android.permission.NETWORK_STACK", - "android.permission.MAINLINE_NETWORK_STACK", - "android.permission.NETWORK_SETTINGS", - "android.permission.CONNECTIVITY_INTERNAL", - "android.permission.CONTROL_VPN", - "android.permission.CONTROL_ALWAYS_ON_VPN", - ) - private val exemptPackages = emptySet() - private val exemptCache = ConcurrentHashMap() - private val privilegedCache = ConcurrentHashMap() - - private val appGlobalsClass by lazy { Class.forName("android.app.AppGlobals") } - private val getPackageManagerMethod by lazy { appGlobalsClass.getMethod("getPackageManager") } - private var getPackagesForUidMethod: Method? = null - private var checkUidPermissionMethod: Method? = null - private var getApplicationInfoMethodLong: Method? = null - private var getApplicationInfoMethodInt: Method? = null - - fun isPrivilegedUid(uid: Int): Boolean { - if (uid < Process.FIRST_APPLICATION_UID) { - return true - } - val cached = privilegedCache[uid] - if (cached != null) { - return cached - } - if (isExemptUid(uid)) { - privilegedCache[uid] = true - return true - } - val packages = getPackagesForUid(uid) - val pm = getPackageManager() - if (pm != null && packages.isNotEmpty()) { - val userId = uid / PER_USER_RANGE - for (pkg in packages) { - val appInfo = getApplicationInfo(pm, pkg, userId) - if (appInfo != null && isSystemApp(appInfo)) { - privilegedCache[uid] = true - return true - } - } - val checkMethod = checkUidPermissionMethod ?: run { - pm.javaClass.getMethod( - "checkUidPermission", - String::class.java, - Int::class.javaPrimitiveType, - ).also { checkUidPermissionMethod = it } - } - for (permission in privilegedPermissions) { - val result = try { - checkMethod.invoke(pm, permission, uid) as? Int - } catch (_: Throwable) { - null - } - if (result == PackageManager.PERMISSION_GRANTED) { - privilegedCache[uid] = true - return true - } - } - } - privilegedCache[uid] = false - return false - } - - private fun isSystemApp(appInfo: ApplicationInfo): Boolean { - val flags = appInfo.flags - return flags and ApplicationInfo.FLAG_SYSTEM != 0 || - flags and ApplicationInfo.FLAG_UPDATED_SYSTEM_APP != 0 - } - - private fun isExemptUid(uid: Int): Boolean { - if (exemptPackages.isEmpty()) { - return false - } - val cached = exemptCache[uid] - if (cached != null) { - return cached - } - val packages = getPackagesForUid(uid) - val isExempt = packages.any { it in exemptPackages } - exemptCache[uid] = isExempt - return isExempt - } - - private fun getPackagesForUid(uid: Int): List { - val pm = getPackageManager() ?: return emptyList() - return try { - val method = getPackagesForUidMethod ?: run { - pm.javaClass.getMethod("getPackagesForUid", Int::class.javaPrimitiveType).also { - getPackagesForUidMethod = it - } - } - val result = method.invoke(pm, uid) - when (result) { - is Array<*> -> result.filterIsInstance() - is List<*> -> result.filterIsInstance() - else -> emptyList() - } - } catch (_: Throwable) { - emptyList() - } - } - - private fun getPackageManager(): Any? = try { - getPackageManagerMethod.invoke(null) - } catch (_: Throwable) { - null - } - - private fun getApplicationInfo(pm: Any, pkg: String, userId: Int): ApplicationInfo? = try { - val method = getApplicationInfoMethodInt ?: run { - pm.javaClass.getMethod( - "getApplicationInfo", - String::class.java, - Int::class.javaPrimitiveType, - Int::class.javaPrimitiveType, - ).also { getApplicationInfoMethodInt = it } - } - method.invoke(pm, pkg, 0, userId) as? ApplicationInfo - } catch (_: Throwable) { - try { - val method = getApplicationInfoMethodLong ?: run { - pm.javaClass.getMethod( - "getApplicationInfo", - String::class.java, - Long::class.javaPrimitiveType, - Int::class.javaPrimitiveType, - ).also { getApplicationInfoMethodLong = it } - } - method.invoke(pm, pkg, 0L, userId) as? ApplicationInfo - } catch (_: Throwable) { - null - } - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/PrivilegeSettingsStore.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/PrivilegeSettingsStore.kt deleted file mode 100644 index 6ac928ae0f..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/PrivilegeSettingsStore.kt +++ /dev/null @@ -1,137 +0,0 @@ -package io.nekohasekai.sfa.xposed - -import java.io.File -import java.lang.reflect.Method -import java.util.concurrent.ConcurrentHashMap - -object PrivilegeSettingsStore { - private const val SETTINGS_DIR = "/data/system/sing-box" - private const val SETTINGS_FILE = "privilege_settings.conf" - - @Volatile - private var enabled = false - - @Volatile - private var packageSet: Set = emptySet() - - @Volatile - private var interfaceRenameEnabled = false - - @Volatile - private var interfacePrefix = "en" - private val uidCache = ConcurrentHashMap() - - private val appGlobalsClass by lazy { Class.forName("android.app.AppGlobals") } - private val getPackageManagerMethod by lazy { appGlobalsClass.getMethod("getPackageManager") } - private var getPackagesForUidMethod: Method? = null - - fun update(enabled: Boolean, packages: Set, interfaceRenameEnabled: Boolean, interfacePrefix: String) { - this.enabled = enabled - packageSet = packages - this.interfaceRenameEnabled = interfaceRenameEnabled - this.interfacePrefix = normalizePrefix(interfacePrefix) - uidCache.clear() - HookErrorStore.i( - "PrivilegeSettingsStore", - "PrivilegeSettings updated: enabled=$enabled size=${packages.size} rename=$interfaceRenameEnabled prefix=${this.interfacePrefix}", - ) - writeSettingsFile() - } - - fun isEnabled(): Boolean = enabled - - fun shouldRenameInterface(): Boolean = interfaceRenameEnabled - - fun interfacePrefix(): String = interfacePrefix - - fun isUidSelected(uid: Int): Boolean { - val cached = uidCache[uid] - if (cached != null) { - return cached - } - val selected = getPackagesForUid(uid).any { packageSet.contains(it) } - uidCache[uid] = selected - return selected - } - - fun shouldHideUid(uid: Int): Boolean { - if (!enabled) { - return false - } - return isUidSelected(uid) - } - - private fun normalizePrefix(prefix: String): String { - val trimmed = prefix.trim() - if (trimmed.isEmpty()) { - return "en" - } - val filtered = buildString(trimmed.length) { - for (ch in trimmed) { - if (ch.isLetterOrDigit() || ch == '_') { - append(ch) - } - } - } - return if (filtered.isEmpty()) "en" else filtered - } - - private fun writeSettingsFile() { - try { - val dir = File(SETTINGS_DIR) - if (!dir.exists() && !dir.mkdirs()) { - HookErrorStore.e("PrivilegeSettingsStore", "Failed to create settings dir: ${dir.path}") - return - } - val file = File(dir, SETTINGS_FILE) - val packagesLine = packageSet.sorted().joinToString(",") - val content = buildString { - append("version=1\n") - append("enabled=") - append(if (enabled) "1" else "0") - append('\n') - append("rename=") - append(if (interfaceRenameEnabled) "1" else "0") - append('\n') - append("prefix=") - append(interfacePrefix) - append('\n') - append("packages=") - append(packagesLine) - append('\n') - } - file.writeText(content) - file.setReadable(true, true) - file.setWritable(true, true) - } catch (e: Throwable) { - HookErrorStore.e("PrivilegeSettingsStore", "Failed to write privilege settings file", e) - } - } - - private fun getPackagesForUid(uid: Int): List { - val pm = getPackageManager() ?: return emptyList() - return try { - val method = getPackagesForUidMethod ?: run { - pm.javaClass.getMethod("getPackagesForUid", Int::class.javaPrimitiveType).also { - getPackagesForUidMethod = it - } - } - val result = method.invoke(pm, uid) - when (result) { - is Array<*> -> result.filterIsInstance() - is List<*> -> result.filterIsInstance() - else -> emptyList() - } - } catch (e: Throwable) { - HookErrorStore.e("PrivilegeSettingsStore", "getPackagesForUid failed for uid=$uid", e) - emptyList() - } - } - - private fun getPackageManager(): Any? = try { - getPackageManagerMethod.invoke(null) - } catch (e: Throwable) { - HookErrorStore.e("PrivilegeSettingsStore", "getPackageManager failed", e) - null - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/VpnAppStore.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/VpnAppStore.kt deleted file mode 100644 index f13ded4ab3..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/VpnAppStore.kt +++ /dev/null @@ -1,188 +0,0 @@ -package io.nekohasekai.sfa.xposed - -import android.Manifest -import android.content.pm.ApplicationInfo -import android.content.pm.PackageInfo -import android.content.pm.PackageManager -import android.os.Binder -import android.os.SystemClock -import io.nekohasekai.sfa.BuildConfig -import java.lang.reflect.Method -import java.util.concurrent.ConcurrentHashMap - -object VpnAppStore { - private const val PER_USER_RANGE = 100000 - private const val REFRESH_INTERVAL_MS = 60_000L - private const val UID_CACHE_MS = 5_000L - - private data class CacheEntry(val atMs: Long, val value: T) - - private val vpnPackagesByUser = ConcurrentHashMap>>() - private val uidVpnCache = ConcurrentHashMap>() - private val uidPackagesCache = ConcurrentHashMap>>() - - private val appGlobalsClass by lazy { Class.forName("android.app.AppGlobals") } - private val getPackageManagerMethod by lazy { appGlobalsClass.getMethod("getPackageManager") } - - @Volatile - private var pmClass: Class<*>? = null - private var getPackagesForUidMethod: Method? = null - private var getInstalledPackagesMethodLong: Method? = null - private var getInstalledPackagesMethodInt: Method? = null - private var getListMethod: Method? = null - - fun isVpnUid(uid: Int): Boolean { - val now = SystemClock.uptimeMillis() - val cached = uidVpnCache[uid] - if (cached != null && now - cached.atMs < UID_CACHE_MS) { - return cached.value - } - val callerPackages = getPackagesForUid(uid) - val userId = uid / PER_USER_RANGE - val vpnSet = getVpnPackages(userId) - val result = callerPackages.any { vpnSet.contains(it) } - uidVpnCache[uid] = CacheEntry(now, result) - return result - } - - fun isVpnPackage(packageName: String, userId: Int): Boolean = getVpnPackages(userId).contains(packageName) - - fun isVpnUidExcludeSelf(uid: Int): Boolean { - val packages = getPackagesForUid(uid) - if (packages.contains(BuildConfig.APPLICATION_ID)) { - return false - } - val userId = uid / PER_USER_RANGE - val vpnSet = getVpnPackages(userId) - return packages.any { vpnSet.contains(it) } - } - - fun getPackagesForUid(uid: Int): List { - val now = SystemClock.uptimeMillis() - val cached = uidPackagesCache[uid] - if (cached != null && now - cached.atMs < UID_CACHE_MS) { - return cached.value - } - val result = binderLocalScope { - val pm = getPackageManager() ?: return@binderLocalScope emptyList() - try { - val method = getPackagesForUidMethod ?: run { - pm.javaClass.getMethod("getPackagesForUid", Int::class.javaPrimitiveType).also { - getPackagesForUidMethod = it - } - } - when (val raw = method.invoke(pm, uid)) { - is Array<*> -> raw.filterIsInstance() - is List<*> -> raw.filterIsInstance() - else -> emptyList() - } - } catch (e: Throwable) { - HookErrorStore.e("VpnAppStore", "getPackagesForUid failed for uid=$uid", e) - emptyList() - } - } - uidPackagesCache[uid] = CacheEntry(now, result) - return result - } - - private fun getVpnPackages(userId: Int): Set { - val now = SystemClock.uptimeMillis() - val cached = vpnPackagesByUser[userId] - if (cached != null && now - cached.atMs < REFRESH_INTERVAL_MS) { - return cached.value - } - val refreshed = scanVpnPackages(userId) - vpnPackagesByUser[userId] = CacheEntry(now, refreshed) - uidVpnCache.clear() - return refreshed - } - - private fun scanVpnPackages(userId: Int): Set { - return binderLocalScope { - val pm = getPackageManager() ?: return@binderLocalScope emptySet() - val flags = PackageManager.MATCH_DISABLED_COMPONENTS or - PackageManager.MATCH_DIRECT_BOOT_AWARE or - PackageManager.MATCH_DIRECT_BOOT_UNAWARE or - PackageManager.GET_SERVICES - val packages = getInstalledPackagesCompat(pm, flags.toLong(), userId) - val result = HashSet() - for (pkg in packages) { - val appInfo = pkg.applicationInfo ?: continue - if (isSystemApp(appInfo)) continue - val services = pkg.services ?: continue - if (services.any { it.permission == Manifest.permission.BIND_VPN_SERVICE }) { - result.add(pkg.packageName) - } - } - HookErrorStore.d("VpnAppStore", "VPN apps refreshed user=$userId count=${result.size}") - result - } - } - - private fun getInstalledPackagesCompat(pm: Any, flags: Long, userId: Int): List { - val result = try { - val method = getInstalledPackagesMethodLong ?: run { - pm.javaClass.getMethod( - "getInstalledPackages", - Long::class.javaPrimitiveType, - Int::class.javaPrimitiveType, - ).also { getInstalledPackagesMethodLong = it } - } - method.invoke(pm, flags, userId) - } catch (_: Throwable) { - try { - val method = getInstalledPackagesMethodInt ?: run { - pm.javaClass.getMethod( - "getInstalledPackages", - Int::class.javaPrimitiveType, - Int::class.javaPrimitiveType, - ).also { getInstalledPackagesMethodInt = it } - } - method.invoke(pm, flags.toInt(), userId) - } catch (e: Throwable) { - HookErrorStore.e("VpnAppStore", "getInstalledPackages failed", e) - return emptyList() - } - } - return unwrapParceledListSlice(result) - } - - private fun isSystemApp(info: ApplicationInfo): Boolean = info.flags and ApplicationInfo.FLAG_SYSTEM != 0 || - info.flags and ApplicationInfo.FLAG_UPDATED_SYSTEM_APP != 0 - - private fun getPackageManager(): Any? = try { - getPackageManagerMethod.invoke(null) - } catch (e: Throwable) { - HookErrorStore.e("VpnAppStore", "getPackageManager failed", e) - null - } - - private inline fun binderLocalScope(block: () -> T): T { - val token = Binder.clearCallingIdentity() - return try { - block() - } finally { - Binder.restoreCallingIdentity(token) - } - } - - private fun unwrapParceledListSlice(raw: Any?): List { - if (raw == null) return emptyList() - if (raw is List<*>) { - return raw.filterIsInstance() - } - return try { - val method = getListMethod ?: run { - raw.javaClass.getMethod("getList").also { getListMethod = it } - } - val list = method.invoke(raw) - if (list is List<*>) { - list.filterIsInstance() - } else { - emptyList() - } - } catch (_: Throwable) { - emptyList() - } - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/VpnHideContext.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/VpnHideContext.kt deleted file mode 100644 index 50ffa96189..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/VpnHideContext.kt +++ /dev/null @@ -1,19 +0,0 @@ -package io.nekohasekai.sfa.xposed - -object VpnHideContext { - private val targetUid = ThreadLocal() - - fun setTargetUid(uid: Int) { - targetUid.set(uid) - } - - fun consumeTargetUid(): Int? { - val value = targetUid.get() - targetUid.remove() - return value - } - - fun clear() { - targetUid.remove() - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/VpnSanitizer.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/VpnSanitizer.kt deleted file mode 100644 index 1aa35f1da4..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/VpnSanitizer.kt +++ /dev/null @@ -1,153 +0,0 @@ -package io.nekohasekai.sfa.xposed - -import android.net.LinkProperties -import android.net.NetworkCapabilities -import android.net.NetworkInfo -import android.net.ProxyInfo -import android.os.Build -import android.os.Parcel -import android.os.Process -import de.robv.android.xposed.XposedHelpers -import java.util.Locale - -object VpnSanitizer { - private val vpnInterfacePrefixes = arrayOf( - "tun", - ) - - private val getStackedLinksMethod by lazy { - LinkProperties::class.java.getMethod("getStackedLinks") - } - private val removeStackedLinkMethod by lazy { - LinkProperties::class.java.getMethod("removeStackedLink", String::class.java) - } - private val setHttpProxyMethod by lazy { - LinkProperties::class.java.getMethod("setHttpProxy", ProxyInfo::class.java) - } - private val removeTransportTypeMethod by lazy { - NetworkCapabilities::class.java.getMethod("removeTransportType", Int::class.javaPrimitiveType) - } - private val addCapabilityMethod by lazy { - NetworkCapabilities::class.java.getMethod("addCapability", Int::class.javaPrimitiveType) - } - - fun shouldHide(uid: Int): Boolean { - if (!PrivilegeSettingsStore.shouldHideUid(uid)) { - return false - } - if (VpnAppStore.isVpnUidExcludeSelf(uid)) { - return false - } - return true - } - - fun sanitizeRequestCapabilities(source: NetworkCapabilities): NetworkCapabilities { - val caps = NetworkCapabilities(source) - sanitizeTransport(caps) - return caps - } - - fun sanitizeNetworkCapabilities(source: NetworkCapabilities): NetworkCapabilities { - val caps = NetworkCapabilities(source) - sanitizeTransport(caps) - clearUnderlyingNetworks(caps) - clearOwnerUid(caps) - clearVpnTransportInfo(caps) - return caps - } - - fun sanitizeLinkProperties(source: LinkProperties): LinkProperties { - val lp = cloneLinkProperties(source) - clearHttpProxy(lp) - val iface = lp.interfaceName - if (isVpnInterface(iface)) { - lp.setInterfaceName(null) - } - @Suppress("UNCHECKED_CAST") - val stacked = getStackedLinksMethod.invoke(lp) as? List - if (!stacked.isNullOrEmpty()) { - for (link in stacked) { - clearHttpProxy(link) - val iface = link.interfaceName - if (iface != null && isVpnInterface(iface)) { - removeStackedLinkMethod.invoke(lp, iface) - } - } - } - return lp - } - - fun hasVpnInterface(lp: LinkProperties): Boolean { - if (isVpnInterface(lp.interfaceName)) { - return true - } - @Suppress("UNCHECKED_CAST") - val stacked = getStackedLinksMethod.invoke(lp) as? List ?: return false - return stacked.any { isVpnInterface(it.interfaceName) } - } - - fun isVpnInterface(iface: String?): Boolean { - if (iface.isNullOrEmpty()) return false - val name = iface.lowercase(Locale.US) - return vpnInterfacePrefixes.any { name.startsWith(it) } - } - - private fun sanitizeTransport(caps: NetworkCapabilities) { - removeTransportTypeMethod.invoke(caps, NetworkCapabilities.TRANSPORT_VPN) - addCapabilityMethod.invoke(caps, NetworkCapabilities.NET_CAPABILITY_NOT_VPN) - } - - private fun clearUnderlyingNetworks(caps: NetworkCapabilities) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - val field = XposedHelpers.findField(NetworkCapabilities::class.java, "mUnderlyingNetworks") - field.set(caps, null) - } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - val field = XposedHelpers.findFieldIfExists(NetworkCapabilities::class.java, "mUnderlyingNetworks") - field?.set(caps, null) - } - } - - private fun clearOwnerUid(caps: NetworkCapabilities) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - val field = XposedHelpers.findField(NetworkCapabilities::class.java, "mOwnerUid") - field.setInt(caps, Process.INVALID_UID) - } - } - - private fun clearVpnTransportInfo(caps: NetworkCapabilities) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { - return - } - val field = XposedHelpers.findField(NetworkCapabilities::class.java, "mTransportInfo") - val info = field.get(caps) ?: return - if (info.javaClass.name.contains("VpnTransportInfo")) { - field.set(caps, null) - } - } - - private fun clearHttpProxy(lp: LinkProperties) { - setHttpProxyMethod.invoke(lp, null as ProxyInfo?) - } - - fun cloneLinkProperties(source: LinkProperties): LinkProperties { - val parcel = Parcel.obtain() - return try { - source.writeToParcel(parcel, 0) - parcel.setDataPosition(0) - LinkProperties.CREATOR.createFromParcel(parcel) - } finally { - parcel.recycle() - } - } - - fun cloneNetworkInfo(source: NetworkInfo): NetworkInfo { - val parcel = Parcel.obtain() - return try { - source.writeToParcel(parcel, 0) - parcel.setDataPosition(0) - NetworkInfo.CREATOR.createFromParcel(parcel) - } finally { - parcel.recycle() - } - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/XposedActivation.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/XposedActivation.kt deleted file mode 100644 index ab4a2f0cae..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/XposedActivation.kt +++ /dev/null @@ -1,36 +0,0 @@ -package io.nekohasekai.sfa.xposed - -import android.content.Context -import android.os.Process - -object XposedActivation { - private const val PREFS_NAME = "xposed_activation" - private const val KEY_ACTIVATED_PID = "activated_pid" - private const val KEY_ACTIVATED_AT = "activated_at" - private const val KEY_SYSTEM_IN_SCOPE = "system_in_scope" - - fun markActivated(context: Context) { - context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - .edit() - .putInt(KEY_ACTIVATED_PID, Process.myPid()) - .putLong(KEY_ACTIVATED_AT, System.currentTimeMillis()) - .apply() - } - - fun updateScope(context: Context, scope: Collection) { - val hasSystemScope = scope.any { it == "system" || it == "android" } - context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - .edit() - .putBoolean(KEY_SYSTEM_IN_SCOPE, hasSystemScope) - .putLong(KEY_ACTIVATED_AT, System.currentTimeMillis()) - .apply() - } - - fun isActivated(context: Context): Boolean { - val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - if (prefs.contains(KEY_SYSTEM_IN_SCOPE)) { - return prefs.getBoolean(KEY_SYSTEM_IN_SCOPE, false) - } - return prefs.getInt(KEY_ACTIVATED_PID, -1) == Process.myPid() - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/XposedInit.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/XposedInit.kt deleted file mode 100644 index 10fe4a298e..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/XposedInit.kt +++ /dev/null @@ -1,54 +0,0 @@ -package io.nekohasekai.sfa.xposed - -import android.content.Context -import io.github.libxposed.api.XposedInterface -import io.github.libxposed.api.XposedModule -import io.github.libxposed.api.XposedModuleInterface -import io.nekohasekai.sfa.xposed.hooks.HookIConnectivityManagerOnTransact -import io.nekohasekai.sfa.xposed.hooks.hidevpn.ConnectivityServiceHookHelper -import io.nekohasekai.sfa.xposed.hooks.hidevpn.HookNetworkCapabilitiesWriteToParcel -import io.nekohasekai.sfa.xposed.hooks.hidevpn.HookNetworkInterfaceGetName -import io.nekohasekai.sfa.xposed.hooks.hidevpnapp.HookPackageManagerGetInstalledPackages - -class XposedInit(base: XposedInterface, param: XposedModuleInterface.ModuleLoadedParam) : XposedModule(base, param) { - - private val activityThreadClass by lazy { Class.forName("android.app.ActivityThread") } - private val currentActivityThreadMethod by lazy { activityThreadClass.getMethod("currentActivityThread") } - private val getSystemContextMethod by lazy { activityThreadClass.getMethod("getSystemContext") } - - override fun onSystemServerLoaded(param: XposedModuleInterface.SystemServerLoadedParam) { - val systemContext = resolveSystemContext() - HookErrorStore.i("XposedInit", "handleSystemServerLoaded") - val hooks = arrayOf( - ConnectivityServiceHookHelper(param.classLoader), - HookIConnectivityManagerOnTransact(param.classLoader, systemContext), - HookPackageManagerGetInstalledPackages(param.classLoader), - HookNetworkCapabilitiesWriteToParcel(), - HookNetworkInterfaceGetName(param.classLoader), - ) - - hooks.forEach { hook -> - try { - hook.injectHook() - } catch (e: Throwable) { - HookErrorStore.e( - "XposedInit", - "Failed to inject ${hook.javaClass.simpleName}", - e, - ) - } - } - } - - companion object { - const val TAG = "sing-box-lsposed" - } - - private fun resolveSystemContext(): Context? = try { - val currentThread = currentActivityThreadMethod.invoke(null) - getSystemContextMethod.invoke(currentThread) as? Context - } catch (e: Throwable) { - HookErrorStore.e("XposedInit", "resolveSystemContext failed", e) - null - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/IConnectivityManager+onTransact.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/IConnectivityManager+onTransact.kt deleted file mode 100644 index 707e7f7b82..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/IConnectivityManager+onTransact.kt +++ /dev/null @@ -1,196 +0,0 @@ -package io.nekohasekai.sfa.xposed.hooks - -import android.content.Context -import android.content.pm.PackageInfo -import android.os.Binder -import android.os.Parcel -import de.robv.android.xposed.XposedHelpers -import io.nekohasekai.sfa.BuildConfig -import io.nekohasekai.sfa.bg.PackageEntry -import io.nekohasekai.sfa.bg.ParceledListSlice -import io.nekohasekai.sfa.xposed.HookErrorStore -import io.nekohasekai.sfa.xposed.HookStatusKeys -import io.nekohasekai.sfa.xposed.HookStatusStore -import io.nekohasekai.sfa.xposed.PrivilegeSettingsStore - -class HookIConnectivityManagerOnTransact(private val classLoader: ClassLoader, private val context: Context?) : XHook { - private companion object { - private const val SOURCE = "HookIConnectivityManagerOnTransact" - } - - override fun injectHook() { - val stub = XposedHelpers.findClass("android.net.IConnectivityManager\$Stub", classLoader) - val descriptor = XposedHelpers.getStaticObjectField(stub, "DESCRIPTOR") as String - XposedHelpers.findAndHookMethod( - stub, - "onTransact", - Int::class.javaPrimitiveType, - Parcel::class.java, - Parcel::class.java, - Int::class.javaPrimitiveType, - object : SafeMethodHook(SOURCE) { - override fun beforeHook(param: MethodHookParam) { - val code = param.args[0] as Int - if (code != HookStatusKeys.TRANSACTION_STATUS && - code != HookStatusKeys.TRANSACTION_UPDATE_PRIVILEGE_SETTINGS && - code != HookStatusKeys.TRANSACTION_GET_ERRORS && - code != HookStatusKeys.TRANSACTION_GET_INSTALLED_PACKAGES - ) { - return - } - val data = param.args[1] as Parcel - val reply = param.args[2] as Parcel? - try { - data.enforceInterface(descriptor) - } catch (e: Throwable) { - HookErrorStore.e(SOURCE, "IConnectivityManager transact bad interface", e) - reply?.writeException(SecurityException("bad interface")) - param.result = true - return - } - if (!isCallerAllowed()) { - reply!!.writeException(SecurityException("unauthorized")) - param.result = true - return - } - if (code == HookStatusKeys.TRANSACTION_STATUS) { - val status = HookStatusStore.snapshot() - reply!!.writeNoException() - reply.writeInt(if (status.active) 1 else 0) - reply.writeLong(status.lastPatchedAt) - reply.writeInt(status.version) - reply.writeInt(status.systemPid) - param.result = true - return - } - if (code == HookStatusKeys.TRANSACTION_GET_ERRORS) { - val hasWarnings = HookErrorStore.hasWarnings() - val entries = HookErrorStore.snapshot() - reply!!.writeNoException() - reply.writeInt(if (hasWarnings) 1 else 0) - ParceledListSlice(entries).writeToParcel(reply, 0) - param.result = true - return - } - if (code == HookStatusKeys.TRANSACTION_GET_INSTALLED_PACKAGES) { - val flags = data.readLong() - val userId = data.readInt() - val packages = getInstalledPackages(flags, userId) - reply!!.writeNoException() - ParceledListSlice(packages).writeToParcel(reply, 0) - param.result = true - return - } - val enabled = data.readInt() != 0 - val slice = ParceledListSlice.CREATOR.createFromParcel(data, PackageEntry::class.java.classLoader) - val packages = HashSet() - for (entry in slice.list) { - if (entry is PackageEntry) { - packages.add(entry.packageName) - } - } - var renameEnabled = false - var prefix = "en" - if (data.dataAvail() >= 4) { - renameEnabled = data.readInt() != 0 - if (data.dataAvail() > 0) { - prefix = data.readString() ?: "en" - } - } - PrivilegeSettingsStore.update(enabled, packages, renameEnabled, prefix) - reply!!.writeNoException() - param.result = true - } - }, - ) - HookErrorStore.i(SOURCE, "Hooked IConnectivityManager.onTransact") - } - - private fun isCallerAllowed(): Boolean { - val uid = Binder.getCallingUid() - if (uid == 0) return true - val pm = context?.packageManager - if (pm == null) { - HookErrorStore.e(SOURCE, "isCallerAllowed: context or packageManager is null, uid=$uid") - return false - } - return try { - val packages = pm.getPackagesForUid(uid) - if (packages == null) { - HookErrorStore.w(SOURCE, "isCallerAllowed: getPackagesForUid returned null for uid=$uid") - return false - } - packages.any { it == BuildConfig.APPLICATION_ID } - } catch (e: Throwable) { - HookErrorStore.e(SOURCE, "isCallerAllowed failed for uid=$uid", e) - false - } - } - - private fun getInstalledPackages(flags: Long, userId: Int): List { - return binderLocalScope { - val pm = getPackageManager() ?: return@binderLocalScope emptyList() - getInstalledPackagesCompat(pm, flags, userId) - } - } - - private inline fun binderLocalScope(block: () -> T): T { - val token = Binder.clearCallingIdentity() - return try { - block() - } finally { - Binder.restoreCallingIdentity(token) - } - } - - private fun getPackageManager(): Any? = try { - val appGlobals = Class.forName("android.app.AppGlobals") - val method = appGlobals.getMethod("getPackageManager") - method.invoke(null) - } catch (e: Throwable) { - HookErrorStore.e(SOURCE, "getPackageManager failed", e) - null - } - - private fun getInstalledPackagesCompat(pm: Any, flags: Long, userId: Int): List { - val result = try { - val method = pm.javaClass.getMethod( - "getInstalledPackages", - Long::class.javaPrimitiveType, - Int::class.javaPrimitiveType, - ) - method.invoke(pm, flags, userId) - } catch (_: Throwable) { - try { - val method = pm.javaClass.getMethod( - "getInstalledPackages", - Int::class.javaPrimitiveType, - Int::class.javaPrimitiveType, - ) - method.invoke(pm, flags.toInt(), userId) - } catch (e: Throwable) { - HookErrorStore.e(SOURCE, "getInstalledPackages failed", e) - return emptyList() - } - } - return unwrapParceledListSlice(result) - } - - private fun unwrapParceledListSlice(raw: Any?): List { - if (raw == null) return emptyList() - if (raw is List<*>) { - return raw.filterIsInstance() - } - return try { - val method = raw.javaClass.getMethod("getList") - val list = method.invoke(raw) - if (list is List<*>) { - list.filterIsInstance() - } else { - emptyList() - } - } catch (_: Throwable) { - emptyList() - } - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/SafeMethodHook.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/SafeMethodHook.kt deleted file mode 100644 index a62533a6f1..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/SafeMethodHook.kt +++ /dev/null @@ -1,32 +0,0 @@ -package io.nekohasekai.sfa.xposed.hooks - -import de.robv.android.xposed.XC_MethodHook -import io.nekohasekai.sfa.xposed.HookErrorStore - -abstract class SafeMethodHook(private val source: String) : XC_MethodHook() { - @Volatile - private var disabled = false - - final override fun beforeHookedMethod(param: MethodHookParam) { - if (disabled) return - try { - beforeHook(param) - } catch (e: Throwable) { - disabled = true - HookErrorStore.e(source, "Hook disabled due to unrecoverable error", e) - } - } - - final override fun afterHookedMethod(param: MethodHookParam) { - if (disabled) return - try { - afterHook(param) - } catch (e: Throwable) { - disabled = true - HookErrorStore.e(source, "Hook disabled due to unrecoverable error", e) - } - } - - protected open fun beforeHook(param: MethodHookParam) {} - protected open fun afterHook(param: MethodHookParam) {} -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/XHook.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/XHook.kt deleted file mode 100644 index f5d7619d40..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/XHook.kt +++ /dev/null @@ -1,5 +0,0 @@ -package io.nekohasekai.sfa.xposed.hooks - -interface XHook { - fun injectHook() -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+CONNECTIVITY_ACTION.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+CONNECTIVITY_ACTION.kt deleted file mode 100644 index ebc825e1da..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+CONNECTIVITY_ACTION.kt +++ /dev/null @@ -1,36 +0,0 @@ -package io.nekohasekai.sfa.xposed.hooks.hidevpn - -import android.net.ConnectivityManager -import android.net.NetworkInfo -import de.robv.android.xposed.XposedHelpers -import io.nekohasekai.sfa.xposed.VpnSanitizer -import io.nekohasekai.sfa.xposed.hooks.SafeMethodHook - -class HookConnectivityManagerConnectivityAction(private val helper: ConnectivityServiceHookHelper) { - private companion object { - private const val SOURCE = "HookConnectivityManagerConnectivityAction" - } - - fun install() { - XposedHelpers.findAndHookMethod( - helper.cls, - "sendGeneralBroadcast", - NetworkInfo::class.java, - String::class.java, - object : SafeMethodHook(SOURCE) { - override fun beforeHook(param: MethodHookParam) { - val info = param.args[0] as? NetworkInfo ?: return - if (info.type != ConnectivityManager.TYPE_VPN) return - val defaultNai = XposedHelpers.callMethod(param.thisObject, "getDefaultNetwork") - ?: return - if (helper.isVpnNai(defaultNai)) { - return - } - val replacement = XposedHelpers.getObjectField(defaultNai, "networkInfo") as? NetworkInfo - ?: return - param.args[0] = VpnSanitizer.cloneNetworkInfo(replacement) - } - }, - ) - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+PROXY_CHANGE_ACTION.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+PROXY_CHANGE_ACTION.kt deleted file mode 100644 index 46339d2472..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+PROXY_CHANGE_ACTION.kt +++ /dev/null @@ -1,83 +0,0 @@ -package io.nekohasekai.sfa.xposed.hooks.hidevpn - -import android.content.Context -import android.content.Intent -import android.net.Proxy -import android.net.ProxyInfo -import android.os.Binder -import android.os.UserHandle -import de.robv.android.xposed.XposedHelpers -import io.nekohasekai.sfa.xposed.HookErrorStore -import io.nekohasekai.sfa.xposed.hooks.SafeMethodHook - -class HookConnectivityManagerProxyChangeAction(private val helper: ConnectivityServiceHookHelper) { - private companion object { - private const val SOURCE = "HookProxyChangeAction" - } - - fun install() { - if (helper.sdkInt >= 29) { - try { - hookProxyBroadcastTracker() - return - } catch (e: Throwable) { - HookErrorStore.w(SOURCE, "hookProxyBroadcastTracker failed: ${e.message}", e) - } - } - - try { - hookLegacyProxyBroadcast() - } catch (e: Throwable) { - HookErrorStore.w(SOURCE, "hookLegacyProxyBroadcast failed: ${e.message}", e) - } - } - - private fun hookProxyBroadcastTracker() { - val trackerClass = helper.resolveConnectivityModuleClass("ProxyTracker", "connectivity") - XposedHelpers.findAndHookMethod( - trackerClass, - "sendProxyBroadcast", - object : SafeMethodHook(SOURCE) { - override fun beforeHook(param: MethodHookParam) { - val tracker = param.thisObject ?: return - val context = XposedHelpers.getObjectField(tracker, "mContext") as Context - val proxyInfo = emptyProxyInfo() - val intent = Intent(Proxy.PROXY_CHANGE_ACTION) - intent.addFlags(Intent.FLAG_RECEIVER_REPLACE_PENDING) - intent.putExtra("android.intent.extra.PROXY_INFO", proxyInfo) - val ident = Binder.clearCallingIdentity() - try { - val userAll = try { - UserHandle::class.java.getField("ALL").get(null) as? UserHandle - } catch (_: Throwable) { - null - } - if (userAll != null) { - context.sendStickyBroadcastAsUser(intent, userAll) - } else { - context.sendStickyBroadcast(intent) - } - } finally { - Binder.restoreCallingIdentity(ident) - } - param.result = null - } - }, - ) - } - - private fun hookLegacyProxyBroadcast() { - XposedHelpers.findAndHookMethod( - helper.cls, - "sendProxyBroadcast", - ProxyInfo::class.java, - object : SafeMethodHook(SOURCE) { - override fun beforeHook(param: MethodHookParam) { - param.args[0] = emptyProxyInfo() - } - }, - ) - } - - private fun emptyProxyInfo(): ProxyInfo = ProxyInfo.buildDirectProxy("", 0) -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getActiveNetwork.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getActiveNetwork.kt deleted file mode 100644 index 9ce9809402..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getActiveNetwork.kt +++ /dev/null @@ -1,41 +0,0 @@ -package io.nekohasekai.sfa.xposed.hooks.hidevpn - -import android.os.Binder -import de.robv.android.xposed.XposedHelpers -import io.nekohasekai.sfa.xposed.hooks.SafeMethodHook - -class HookConnectivityManagerGetActiveNetwork(private val helper: ConnectivityServiceHookHelper) { - private companion object { - private const val SOURCE = "HookConnectivityManagerGetActiveNetwork" - } - - fun install() { - XposedHelpers.findAndHookMethod( - helper.cls, - "getActiveNetwork", - object : SafeMethodHook(SOURCE) { - override fun afterHook(param: MethodHookParam) { - val uid = Binder.getCallingUid() - if (!helper.shouldHide(param.thisObject, uid)) return - val replacement = helper.getUnderlyingNetwork(param.thisObject, uid) ?: return - param.result = replacement - } - }, - ) - - XposedHelpers.findAndHookMethod( - helper.cls, - "getActiveNetworkForUid", - Int::class.javaPrimitiveType, - Boolean::class.javaPrimitiveType, - object : SafeMethodHook(SOURCE) { - override fun afterHook(param: MethodHookParam) { - val uid = param.args[0] as Int - if (!helper.shouldHide(param.thisObject, uid)) return - val replacement = helper.getUnderlyingNetwork(param.thisObject, uid) ?: return - param.result = replacement - } - }, - ) - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getActiveNetworkInfo.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getActiveNetworkInfo.kt deleted file mode 100644 index 165337bc37..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getActiveNetworkInfo.kt +++ /dev/null @@ -1,51 +0,0 @@ -package io.nekohasekai.sfa.xposed.hooks.hidevpn - -import android.net.ConnectivityManager -import android.net.NetworkInfo -import android.os.Binder -import de.robv.android.xposed.XposedHelpers -import io.nekohasekai.sfa.xposed.hooks.SafeMethodHook - -class HookConnectivityManagerGetActiveNetworkInfo(private val helper: ConnectivityServiceHookHelper) { - private companion object { - private const val SOURCE = "HookConnectivityManagerGetActiveNetworkInfo" - } - - fun install() { - XposedHelpers.findAndHookMethod( - helper.cls, - "getActiveNetworkInfo", - object : SafeMethodHook(SOURCE) { - override fun afterHook(param: MethodHookParam) { - val uid = Binder.getCallingUid() - if (!helper.shouldHide(param.thisObject, uid)) return - val info = param.result as? NetworkInfo ?: return - if (info.type != ConnectivityManager.TYPE_VPN) return - val replacement = helper.getUnderlyingNetworkInfo(param.thisObject, uid) - if (replacement != null) { - param.result = replacement - } - } - }, - ) - - XposedHelpers.findAndHookMethod( - helper.cls, - "getActiveNetworkInfoForUid", - Int::class.javaPrimitiveType, - Boolean::class.javaPrimitiveType, - object : SafeMethodHook(SOURCE) { - override fun afterHook(param: MethodHookParam) { - val uid = param.args[0] as Int - if (!helper.shouldHide(param.thisObject, uid)) return - val info = param.result as? NetworkInfo ?: return - if (info.type != ConnectivityManager.TYPE_VPN) return - val replacement = helper.getUnderlyingNetworkInfo(param.thisObject, uid) - if (replacement != null) { - param.result = replacement - } - } - }, - ) - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getAllNetworkInfo.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getAllNetworkInfo.kt deleted file mode 100644 index 8417399ebf..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getAllNetworkInfo.kt +++ /dev/null @@ -1,30 +0,0 @@ -package io.nekohasekai.sfa.xposed.hooks.hidevpn - -import android.net.ConnectivityManager -import android.net.NetworkInfo -import android.os.Binder -import de.robv.android.xposed.XposedHelpers -import io.nekohasekai.sfa.xposed.hooks.SafeMethodHook - -class HookConnectivityManagerGetAllNetworkInfo(private val helper: ConnectivityServiceHookHelper) { - private companion object { - private const val SOURCE = "HookConnectivityManagerGetAllNetworkInfo" - } - - fun install() { - XposedHelpers.findAndHookMethod( - helper.cls, - "getAllNetworkInfo", - object : SafeMethodHook(SOURCE) { - override fun afterHook(param: MethodHookParam) { - val uid = Binder.getCallingUid() - if (!helper.shouldHide(param.thisObject, uid)) return - @Suppress("UNCHECKED_CAST") - val infos = param.result as? Array ?: return - val filtered = infos.filter { it.type != ConnectivityManager.TYPE_VPN } - param.result = filtered.toTypedArray() - } - }, - ) - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getAllNetworks.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getAllNetworks.kt deleted file mode 100644 index 5d3af0d2fc..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getAllNetworks.kt +++ /dev/null @@ -1,29 +0,0 @@ -package io.nekohasekai.sfa.xposed.hooks.hidevpn - -import android.net.Network -import android.os.Binder -import de.robv.android.xposed.XposedHelpers -import io.nekohasekai.sfa.xposed.hooks.SafeMethodHook - -class HookConnectivityManagerGetAllNetworks(private val helper: ConnectivityServiceHookHelper) { - private companion object { - private const val SOURCE = "HookConnectivityManagerGetAllNetworks" - } - - fun install() { - XposedHelpers.findAndHookMethod( - helper.cls, - "getAllNetworks", - object : SafeMethodHook(SOURCE) { - override fun afterHook(param: MethodHookParam) { - val uid = Binder.getCallingUid() - if (!helper.shouldHide(param.thisObject, uid)) return - @Suppress("UNCHECKED_CAST") - val networks = param.result as? Array ?: return - val filtered = networks.filter { !helper.isVpnNetwork(param.thisObject, it) } - param.result = filtered.toTypedArray() - } - }, - ) - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getDefaultProxy.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getDefaultProxy.kt deleted file mode 100644 index acade8fa25..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getDefaultProxy.kt +++ /dev/null @@ -1,43 +0,0 @@ -package io.nekohasekai.sfa.xposed.hooks.hidevpn - -import android.net.Network -import android.net.ProxyInfo -import android.os.Binder -import de.robv.android.xposed.XposedHelpers -import io.nekohasekai.sfa.xposed.VpnSanitizer -import io.nekohasekai.sfa.xposed.hooks.SafeMethodHook - -class HookConnectivityManagerGetDefaultProxy(private val helper: ConnectivityServiceHookHelper) { - private companion object { - private const val SOURCE = "HookConnectivityManagerGetDefaultProxy" - } - - fun install() { - XposedHelpers.findAndHookMethod( - helper.cls, - "getProxyForNetwork", - Network::class.java, - object : SafeMethodHook(SOURCE) { - override fun afterHook(param: MethodHookParam) { - val uid = Binder.getCallingUid() - if (!VpnSanitizer.shouldHide(uid)) return - param.result as? ProxyInfo ?: return - param.result = null - } - }, - ) - - XposedHelpers.findAndHookMethod( - helper.cls, - "getGlobalProxy", - object : SafeMethodHook(SOURCE) { - override fun afterHook(param: MethodHookParam) { - val uid = Binder.getCallingUid() - if (!VpnSanitizer.shouldHide(uid)) return - param.result as? ProxyInfo ?: return - param.result = null - } - }, - ) - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getLinkProperties.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getLinkProperties.kt deleted file mode 100644 index a2e9d413c0..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getLinkProperties.kt +++ /dev/null @@ -1,94 +0,0 @@ -package io.nekohasekai.sfa.xposed.hooks.hidevpn - -import android.net.ConnectivityManager -import android.net.LinkProperties -import android.net.Network -import android.os.Binder -import de.robv.android.xposed.XposedHelpers -import io.nekohasekai.sfa.xposed.HookErrorStore -import io.nekohasekai.sfa.xposed.VpnSanitizer -import io.nekohasekai.sfa.xposed.hooks.SafeMethodHook - -class HookConnectivityManagerGetLinkProperties(private val helper: ConnectivityServiceHookHelper) { - private companion object { - private const val SOURCE = "HookGetLinkProperties" - } - - fun install() { - if (helper.sdkInt >= 30) { - try { - hookLinkPropertiesRestricted() - } catch (e: Throwable) { - HookErrorStore.w(SOURCE, "hookLinkPropertiesRestricted failed: ${e.message}", e) - } - } - - try { - hookGetLinkProperties() - } catch (e: Throwable) { - HookErrorStore.w(SOURCE, "hookGetLinkProperties failed: ${e.message}", e) - } - - try { - hookGetLinkPropertiesForType() - } catch (e: Throwable) { - HookErrorStore.w(SOURCE, "hookGetLinkPropertiesForType failed: ${e.message}", e) - } - } - - private fun hookLinkPropertiesRestricted() { - XposedHelpers.findAndHookMethod( - helper.cls, - "linkPropertiesRestrictedForCallerPermissions", - LinkProperties::class.java, - Int::class.javaPrimitiveType, - Int::class.javaPrimitiveType, - object : SafeMethodHook(SOURCE) { - override fun afterHook(param: MethodHookParam) { - val callerUid = param.args[2] as Int - val lp = param.result as? LinkProperties ?: return - if (!VpnSanitizer.hasVpnInterface(lp)) return - if (!VpnSanitizer.shouldHide(callerUid)) return - val underlying = helper.getUnderlyingLinkProperties(param.thisObject, callerUid) - param.result = underlying ?: VpnSanitizer.sanitizeLinkProperties(lp) - } - }, - ) - } - - private fun hookGetLinkProperties() { - XposedHelpers.findAndHookMethod( - helper.cls, - "getLinkProperties", - Network::class.java, - object : SafeMethodHook(SOURCE) { - override fun afterHook(param: MethodHookParam) { - val uid = Binder.getCallingUid() - if (!helper.shouldHide(param.thisObject, uid)) return - val lp = param.result as? LinkProperties ?: return - if (!VpnSanitizer.hasVpnInterface(lp)) return - val underlying = helper.getUnderlyingLinkProperties(param.thisObject, uid) - param.result = underlying ?: VpnSanitizer.sanitizeLinkProperties(lp) - } - }, - ) - } - - private fun hookGetLinkPropertiesForType() { - XposedHelpers.findAndHookMethod( - helper.cls, - "getLinkPropertiesForType", - Int::class.javaPrimitiveType, - object : SafeMethodHook(SOURCE) { - override fun afterHook(param: MethodHookParam) { - val uid = Binder.getCallingUid() - if (!helper.shouldHide(param.thisObject, uid)) return - val networkType = param.args[0] as Int - if (networkType == ConnectivityManager.TYPE_VPN) { - param.result = null - } - } - }, - ) - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getNetworkCapabilities.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getNetworkCapabilities.kt deleted file mode 100644 index 9e7a539500..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getNetworkCapabilities.kt +++ /dev/null @@ -1,161 +0,0 @@ -package io.nekohasekai.sfa.xposed.hooks.hidevpn - -import android.net.Network -import android.net.NetworkCapabilities -import android.os.Binder -import de.robv.android.xposed.XposedHelpers -import io.nekohasekai.sfa.xposed.HookErrorStore -import io.nekohasekai.sfa.xposed.VpnSanitizer -import io.nekohasekai.sfa.xposed.hooks.SafeMethodHook - -class HookConnectivityManagerGetNetworkCapabilities(private val helper: ConnectivityServiceHookHelper) { - private companion object { - private const val SOURCE = "HookGetNetworkCapabilities" - } - - fun install() { - // Hook networkCapabilitiesRestrictedForCallerPermissions (API 28+) - if (helper.sdkInt >= 28) { - try { - hookNetworkCapabilitiesRestricted() - } catch (e: Throwable) { - HookErrorStore.w(SOURCE, "hookNetworkCapabilitiesRestricted failed: ${e.message}", e) - } - } - - // Hook getNetworkCapabilities based on API level - when { - helper.sdkInt >= 31 -> { - try { - hookGetNetworkCapabilitiesV12() - } catch (e: Throwable) { - HookErrorStore.w(SOURCE, "hookGetNetworkCapabilitiesV12 failed: ${e.message}", e) - try { - hookGetNetworkCapabilitiesV11() - } catch (e2: Throwable) { - HookErrorStore.e(SOURCE, "hookGetNetworkCapabilitiesV11 failed: ${e2.message}", e2) - } - } - } - helper.sdkInt >= 30 -> { - try { - hookGetNetworkCapabilitiesV11() - } catch (e: Throwable) { - HookErrorStore.w(SOURCE, "hookGetNetworkCapabilitiesV11 failed: ${e.message}", e) - try { - hookGetNetworkCapabilitiesV8() - } catch (e2: Throwable) { - HookErrorStore.e(SOURCE, "hookGetNetworkCapabilitiesV8 failed: ${e2.message}", e2) - } - } - } - else -> { - try { - hookGetNetworkCapabilitiesV8() - } catch (e: Throwable) { - HookErrorStore.e(SOURCE, "hookGetNetworkCapabilitiesV8 failed: ${e.message}", e) - } - } - } - - // Hook createWithLocationInfoSanitizedIfNecessaryWhenParceled (API 31+) - if (helper.sdkInt >= 31) { - try { - hookCreateWithLocationInfoSanitized() - } catch (e: Throwable) { - HookErrorStore.w(SOURCE, "hookCreateWithLocationInfoSanitized failed: ${e.message}", e) - } - } - } - - private fun hookNetworkCapabilitiesRestricted() { - XposedHelpers.findAndHookMethod( - helper.cls, - "networkCapabilitiesRestrictedForCallerPermissions", - NetworkCapabilities::class.java, - Int::class.javaPrimitiveType, - Int::class.javaPrimitiveType, - object : SafeMethodHook(SOURCE) { - override fun afterHook(param: MethodHookParam) { - val callerUid = param.args[2] as Int - val nc = param.result as? NetworkCapabilities ?: return - if (!nc.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) return - if (!VpnSanitizer.shouldHide(callerUid)) return - param.result = VpnSanitizer.sanitizeNetworkCapabilities(nc) - } - }, - ) - } - - private fun hookGetNetworkCapabilitiesV8() { - XposedHelpers.findAndHookMethod( - helper.cls, - "getNetworkCapabilities", - Network::class.java, - object : SafeMethodHook(SOURCE) { - override fun afterHook(param: MethodHookParam) { - sanitizeNetworkCapabilitiesResult(param) - } - }, - ) - } - - private fun hookGetNetworkCapabilitiesV11() { - XposedHelpers.findAndHookMethod( - helper.cls, - "getNetworkCapabilities", - Network::class.java, - String::class.java, - object : SafeMethodHook(SOURCE) { - override fun afterHook(param: MethodHookParam) { - sanitizeNetworkCapabilitiesResult(param) - } - }, - ) - } - - private fun hookGetNetworkCapabilitiesV12() { - XposedHelpers.findAndHookMethod( - helper.cls, - "getNetworkCapabilities", - Network::class.java, - String::class.java, - String::class.java, - object : SafeMethodHook(SOURCE) { - override fun afterHook(param: MethodHookParam) { - sanitizeNetworkCapabilitiesResult(param) - } - }, - ) - } - - private fun sanitizeNetworkCapabilitiesResult(param: de.robv.android.xposed.XC_MethodHook.MethodHookParam) { - val uid = Binder.getCallingUid() - if (!helper.shouldHide(param.thisObject, uid)) return - val nc = param.result as? NetworkCapabilities ?: return - if (!nc.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) return - param.result = VpnSanitizer.sanitizeNetworkCapabilities(nc) - } - - private fun hookCreateWithLocationInfoSanitized() { - XposedHelpers.findAndHookMethod( - helper.cls, - "createWithLocationInfoSanitizedIfNecessaryWhenParceled", - NetworkCapabilities::class.java, - Boolean::class.javaPrimitiveType, - Int::class.javaPrimitiveType, - Int::class.javaPrimitiveType, - String::class.java, - String::class.java, - object : SafeMethodHook(SOURCE) { - override fun afterHook(param: MethodHookParam) { - val callerUid = param.args[3] as Int - if (!helper.shouldHide(param.thisObject, callerUid)) return - val nc = param.result as? NetworkCapabilities ?: return - if (!nc.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) return - param.result = VpnSanitizer.sanitizeNetworkCapabilities(nc) - } - }, - ) - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getNetworkForType.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getNetworkForType.kt deleted file mode 100644 index 51d215113b..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getNetworkForType.kt +++ /dev/null @@ -1,29 +0,0 @@ -package io.nekohasekai.sfa.xposed.hooks.hidevpn - -import android.net.ConnectivityManager -import android.os.Binder -import de.robv.android.xposed.XposedHelpers -import io.nekohasekai.sfa.xposed.hooks.SafeMethodHook - -class HookConnectivityManagerGetNetworkForType(private val helper: ConnectivityServiceHookHelper) { - private companion object { - private const val SOURCE = "HookConnectivityManagerGetNetworkForType" - } - - fun install() { - XposedHelpers.findAndHookMethod( - helper.cls, - "getNetworkForType", - Int::class.javaPrimitiveType, - object : SafeMethodHook(SOURCE) { - override fun afterHook(param: MethodHookParam) { - val type = param.args[0] as Int - if (type != ConnectivityManager.TYPE_VPN) return - val uid = Binder.getCallingUid() - if (!helper.shouldHide(param.thisObject, uid)) return - param.result = null - } - }, - ) - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getNetworkInfo.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getNetworkInfo.kt deleted file mode 100644 index c3b2aad338..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getNetworkInfo.kt +++ /dev/null @@ -1,54 +0,0 @@ -package io.nekohasekai.sfa.xposed.hooks.hidevpn - -import android.net.ConnectivityManager -import android.net.Network -import android.net.NetworkInfo -import android.os.Binder -import de.robv.android.xposed.XposedHelpers -import io.nekohasekai.sfa.xposed.VpnSanitizer -import io.nekohasekai.sfa.xposed.hooks.SafeMethodHook - -class HookConnectivityManagerGetNetworkInfo(private val helper: ConnectivityServiceHookHelper) { - private companion object { - private const val SOURCE = "HookConnectivityManagerGetNetworkInfo" - } - - fun install() { - XposedHelpers.findAndHookMethod( - helper.cls, - "getNetworkInfo", - Int::class.javaPrimitiveType, - object : SafeMethodHook(SOURCE) { - override fun afterHook(param: MethodHookParam) { - val type = param.args[0] as Int - if (type != ConnectivityManager.TYPE_VPN) return - val uid = Binder.getCallingUid() - if (!helper.shouldHide(param.thisObject, uid)) return - param.result = null - } - }, - ) - - XposedHelpers.findAndHookMethod( - helper.cls, - "getNetworkInfoForUid", - Network::class.java, - Int::class.javaPrimitiveType, - Boolean::class.javaPrimitiveType, - object : SafeMethodHook(SOURCE) { - override fun afterHook(param: MethodHookParam) { - val uid = param.args[1] as Int - if (!helper.shouldHide(param.thisObject, uid)) return - val info = param.result as? NetworkInfo ?: return - if (info.type != ConnectivityManager.TYPE_VPN) return - val replacement = helper.getUnderlyingNetworkInfo(param.thisObject, uid) - param.result = if (replacement != null) { - VpnSanitizer.cloneNetworkInfo(replacement) - } else { - null - } - } - }, - ) - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+requestNetwork.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+requestNetwork.kt deleted file mode 100644 index f8001714d6..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+requestNetwork.kt +++ /dev/null @@ -1,574 +0,0 @@ -package io.nekohasekai.sfa.xposed.hooks.hidevpn - -import android.net.NetworkCapabilities -import android.os.Binder -import android.os.Bundle -import de.robv.android.xposed.XposedHelpers -import io.nekohasekai.sfa.xposed.HookErrorStore -import io.nekohasekai.sfa.xposed.VpnHideContext -import io.nekohasekai.sfa.xposed.VpnSanitizer -import io.nekohasekai.sfa.xposed.hooks.SafeMethodHook - -class HookConnectivityManagerRequestNetwork(private val helper: ConnectivityServiceHookHelper) { - private companion object { - private const val SOURCE = "HookRequestNetwork" - } - - fun install() { - // Hook requestNetwork based on API level - hookRequestNetwork() - - // Hook listenForNetwork based on API level - hookListenForNetwork() - - // Hook pendingRequestForNetwork - hookPendingRequestForNetwork() - - // Hook pendingListenForNetwork - hookPendingListenForNetwork() - - // Hook createDefaultNetworkCapabilitiesForUid (API 28+) - if (helper.sdkInt >= 28) { - try { - hookCreateDefaultNetworkCapabilities() - } catch (e: Throwable) { - HookErrorStore.w(SOURCE, "hookCreateDefaultNetworkCapabilities failed: ${e.message}", e) - } - } - - // Hook copyDefaultNetworkCapabilitiesForUid (API 31+) - if (helper.sdkInt >= 31) { - try { - hookCopyDefaultNetworkCapabilities() - } catch (e: Throwable) { - HookErrorStore.w(SOURCE, "hookCopyDefaultNetworkCapabilities failed: ${e.message}", e) - } - } - - // Hook callCallbackForRequest - hookCallCallbackForRequest() - - // Hook sendPendingIntentForRequest - try { - hookSendPendingIntentForRequest() - } catch (e: Throwable) { - HookErrorStore.w(SOURCE, "hookSendPendingIntentForRequest failed: ${e.message}", e) - } - } - - private fun hookRequestNetwork() { - val methods = listOf( - "V16" to { hookRequestNetworkV16() }, - "V12" to { hookRequestNetworkV12() }, - "V11" to { hookRequestNetworkV11() }, - "V8" to { hookRequestNetworkV8() }, - ) - for ((version, hook) in methods) { - try { - hook() - return - } catch (e: Throwable) { - HookErrorStore.w(SOURCE, "hookRequestNetwork$version failed: ${e.message}", e) - } - } - HookErrorStore.e(SOURCE, "All hookRequestNetwork variants failed") - } - - private fun hookListenForNetwork() { - val methods = listOf( - "V16" to { hookListenForNetworkV16() }, - "V12" to { hookListenForNetworkV12() }, - "V11" to { hookListenForNetworkV11() }, - "V8" to { hookListenForNetworkV8() }, - ) - for ((version, hook) in methods) { - try { - hook() - return - } catch (e: Throwable) { - HookErrorStore.w(SOURCE, "hookListenForNetwork$version failed: ${e.message}", e) - } - } - HookErrorStore.e(SOURCE, "All hookListenForNetwork variants failed") - } - - private fun hookPendingRequestForNetwork() { - val methods = listOf( - "V12" to { hookPendingRequestForNetworkV12() }, - "V11" to { hookPendingRequestForNetworkV11() }, - "V8" to { hookPendingRequestForNetworkV8() }, - ) - for ((version, hook) in methods) { - try { - hook() - return - } catch (e: Throwable) { - HookErrorStore.w(SOURCE, "hookPendingRequestForNetwork$version failed: ${e.message}", e) - } - } - HookErrorStore.e(SOURCE, "All hookPendingRequestForNetwork variants failed") - } - - private fun hookPendingListenForNetwork() { - val methods = listOf( - "V12" to { hookPendingListenForNetworkV12() }, - "V11" to { hookPendingListenForNetworkV11() }, - "V8" to { hookPendingListenForNetworkV8() }, - ) - for ((version, hook) in methods) { - try { - hook() - return - } catch (e: Throwable) { - HookErrorStore.w(SOURCE, "hookPendingListenForNetwork$version failed: ${e.message}", e) - } - } - HookErrorStore.e(SOURCE, "All hookPendingListenForNetwork variants failed") - } - - // region requestNetwork versions - - private fun hookRequestNetworkV8() { - XposedHelpers.findAndHookMethod( - helper.cls, - "requestNetwork", - NetworkCapabilities::class.java, - android.os.Messenger::class.java, - Int::class.javaPrimitiveType, - android.os.IBinder::class.java, - Int::class.javaPrimitiveType, - object : SafeMethodHook(SOURCE) { - override fun beforeHook(param: MethodHookParam) { - val uid = Binder.getCallingUid() - if (!VpnSanitizer.shouldHide(uid)) return - val nc = param.args[0] as? NetworkCapabilities ?: return - param.args[0] = VpnSanitizer.sanitizeRequestCapabilities(nc) - } - }, - ) - } - - private fun hookRequestNetworkV11() { - XposedHelpers.findAndHookMethod( - helper.cls, - "requestNetwork", - NetworkCapabilities::class.java, - android.os.Messenger::class.java, - Int::class.javaPrimitiveType, - android.os.IBinder::class.java, - Int::class.javaPrimitiveType, - String::class.java, - object : SafeMethodHook(SOURCE) { - override fun beforeHook(param: MethodHookParam) { - val uid = Binder.getCallingUid() - if (!VpnSanitizer.shouldHide(uid)) return - val nc = param.args[0] as? NetworkCapabilities ?: return - param.args[0] = VpnSanitizer.sanitizeRequestCapabilities(nc) - } - }, - ) - } - - private fun hookRequestNetworkV12() { - XposedHelpers.findAndHookMethod( - helper.cls, - "requestNetwork", - Int::class.javaPrimitiveType, - NetworkCapabilities::class.java, - Int::class.javaPrimitiveType, - android.os.Messenger::class.java, - Int::class.javaPrimitiveType, - android.os.IBinder::class.java, - Int::class.javaPrimitiveType, - Int::class.javaPrimitiveType, - String::class.java, - String::class.java, - object : SafeMethodHook(SOURCE) { - override fun beforeHook(param: MethodHookParam) { - val uid = Binder.getCallingUid() - if (!VpnSanitizer.shouldHide(uid)) return - val nc = param.args[1] as? NetworkCapabilities ?: return - param.args[1] = VpnSanitizer.sanitizeRequestCapabilities(nc) - } - }, - ) - } - - private fun hookRequestNetworkV16() { - XposedHelpers.findAndHookMethod( - helper.cls, - "requestNetwork", - Int::class.javaPrimitiveType, - NetworkCapabilities::class.java, - Int::class.javaPrimitiveType, - android.os.Messenger::class.java, - Int::class.javaPrimitiveType, - android.os.IBinder::class.java, - Int::class.javaPrimitiveType, - Int::class.javaPrimitiveType, - String::class.java, - String::class.java, - Int::class.javaPrimitiveType, - object : SafeMethodHook(SOURCE) { - override fun beforeHook(param: MethodHookParam) { - val uid = Binder.getCallingUid() - if (!VpnSanitizer.shouldHide(uid)) return - val nc = param.args[1] as? NetworkCapabilities ?: return - param.args[1] = VpnSanitizer.sanitizeRequestCapabilities(nc) - } - }, - ) - } - - // endregion - - // region listenForNetwork versions - - private fun hookListenForNetworkV8() { - XposedHelpers.findAndHookMethod( - helper.cls, - "listenForNetwork", - NetworkCapabilities::class.java, - android.os.Messenger::class.java, - android.os.IBinder::class.java, - object : SafeMethodHook(SOURCE) { - override fun beforeHook(param: MethodHookParam) { - val uid = Binder.getCallingUid() - if (!VpnSanitizer.shouldHide(uid)) return - val nc = param.args[0] as? NetworkCapabilities ?: return - param.args[0] = VpnSanitizer.sanitizeRequestCapabilities(nc) - } - }, - ) - } - - private fun hookListenForNetworkV11() { - XposedHelpers.findAndHookMethod( - helper.cls, - "listenForNetwork", - NetworkCapabilities::class.java, - android.os.Messenger::class.java, - android.os.IBinder::class.java, - String::class.java, - object : SafeMethodHook(SOURCE) { - override fun beforeHook(param: MethodHookParam) { - val uid = Binder.getCallingUid() - if (!VpnSanitizer.shouldHide(uid)) return - val nc = param.args[0] as? NetworkCapabilities ?: return - param.args[0] = VpnSanitizer.sanitizeRequestCapabilities(nc) - } - }, - ) - } - - private fun hookListenForNetworkV12() { - XposedHelpers.findAndHookMethod( - helper.cls, - "listenForNetwork", - NetworkCapabilities::class.java, - android.os.Messenger::class.java, - android.os.IBinder::class.java, - Int::class.javaPrimitiveType, - String::class.java, - String::class.java, - object : SafeMethodHook(SOURCE) { - override fun beforeHook(param: MethodHookParam) { - val uid = Binder.getCallingUid() - if (!VpnSanitizer.shouldHide(uid)) return - val nc = param.args[0] as? NetworkCapabilities ?: return - param.args[0] = VpnSanitizer.sanitizeRequestCapabilities(nc) - } - }, - ) - } - - private fun hookListenForNetworkV16() { - XposedHelpers.findAndHookMethod( - helper.cls, - "listenForNetwork", - NetworkCapabilities::class.java, - android.os.Messenger::class.java, - android.os.IBinder::class.java, - Int::class.javaPrimitiveType, - String::class.java, - String::class.java, - Int::class.javaPrimitiveType, - object : SafeMethodHook(SOURCE) { - override fun beforeHook(param: MethodHookParam) { - val uid = Binder.getCallingUid() - if (!VpnSanitizer.shouldHide(uid)) return - val nc = param.args[0] as? NetworkCapabilities ?: return - param.args[0] = VpnSanitizer.sanitizeRequestCapabilities(nc) - } - }, - ) - } - - // endregion - - // region pendingRequestForNetwork versions - - private fun hookPendingRequestForNetworkV8() { - XposedHelpers.findAndHookMethod( - helper.cls, - "pendingRequestForNetwork", - NetworkCapabilities::class.java, - android.app.PendingIntent::class.java, - object : SafeMethodHook(SOURCE) { - override fun beforeHook(param: MethodHookParam) { - val uid = Binder.getCallingUid() - if (!VpnSanitizer.shouldHide(uid)) return - val nc = param.args[0] as? NetworkCapabilities ?: return - param.args[0] = VpnSanitizer.sanitizeRequestCapabilities(nc) - } - }, - ) - } - - private fun hookPendingRequestForNetworkV11() { - XposedHelpers.findAndHookMethod( - helper.cls, - "pendingRequestForNetwork", - NetworkCapabilities::class.java, - android.app.PendingIntent::class.java, - String::class.java, - object : SafeMethodHook(SOURCE) { - override fun beforeHook(param: MethodHookParam) { - val uid = Binder.getCallingUid() - if (!VpnSanitizer.shouldHide(uid)) return - val nc = param.args[0] as? NetworkCapabilities ?: return - param.args[0] = VpnSanitizer.sanitizeRequestCapabilities(nc) - } - }, - ) - } - - private fun hookPendingRequestForNetworkV12() { - XposedHelpers.findAndHookMethod( - helper.cls, - "pendingRequestForNetwork", - NetworkCapabilities::class.java, - android.app.PendingIntent::class.java, - String::class.java, - String::class.java, - object : SafeMethodHook(SOURCE) { - override fun beforeHook(param: MethodHookParam) { - val uid = Binder.getCallingUid() - if (!VpnSanitizer.shouldHide(uid)) return - val nc = param.args[0] as? NetworkCapabilities ?: return - param.args[0] = VpnSanitizer.sanitizeRequestCapabilities(nc) - } - }, - ) - } - - // endregion - - // region pendingListenForNetwork versions - - private fun hookPendingListenForNetworkV8() { - XposedHelpers.findAndHookMethod( - helper.cls, - "pendingListenForNetwork", - NetworkCapabilities::class.java, - android.app.PendingIntent::class.java, - object : SafeMethodHook(SOURCE) { - override fun beforeHook(param: MethodHookParam) { - val uid = Binder.getCallingUid() - if (!VpnSanitizer.shouldHide(uid)) return - val nc = param.args[0] as? NetworkCapabilities ?: return - param.args[0] = VpnSanitizer.sanitizeRequestCapabilities(nc) - } - }, - ) - } - - private fun hookPendingListenForNetworkV11() { - XposedHelpers.findAndHookMethod( - helper.cls, - "pendingListenForNetwork", - NetworkCapabilities::class.java, - android.app.PendingIntent::class.java, - String::class.java, - object : SafeMethodHook(SOURCE) { - override fun beforeHook(param: MethodHookParam) { - val uid = Binder.getCallingUid() - if (!VpnSanitizer.shouldHide(uid)) return - val nc = param.args[0] as? NetworkCapabilities ?: return - param.args[0] = VpnSanitizer.sanitizeRequestCapabilities(nc) - } - }, - ) - } - - private fun hookPendingListenForNetworkV12() { - XposedHelpers.findAndHookMethod( - helper.cls, - "pendingListenForNetwork", - NetworkCapabilities::class.java, - android.app.PendingIntent::class.java, - String::class.java, - String::class.java, - object : SafeMethodHook(SOURCE) { - override fun beforeHook(param: MethodHookParam) { - val uid = Binder.getCallingUid() - if (!VpnSanitizer.shouldHide(uid)) return - val nc = param.args[0] as? NetworkCapabilities ?: return - param.args[0] = VpnSanitizer.sanitizeRequestCapabilities(nc) - } - }, - ) - } - - // endregion - - // region default capabilities - - private fun hookCreateDefaultNetworkCapabilities() { - XposedHelpers.findAndHookMethod( - helper.cls, - "createDefaultNetworkCapabilitiesForUid", - Int::class.javaPrimitiveType, - object : SafeMethodHook(SOURCE) { - override fun afterHook(param: MethodHookParam) { - val uid = param.args[0] as? Int ?: return - if (!VpnSanitizer.shouldHide(uid)) return - val nc = param.result as? NetworkCapabilities ?: return - param.result = VpnSanitizer.sanitizeRequestCapabilities(nc) - } - }, - ) - } - - private fun hookCopyDefaultNetworkCapabilities() { - XposedHelpers.findAndHookMethod( - helper.cls, - "copyDefaultNetworkCapabilitiesForUid", - NetworkCapabilities::class.java, - Int::class.javaPrimitiveType, - Int::class.javaPrimitiveType, - String::class.java, - object : SafeMethodHook(SOURCE) { - override fun afterHook(param: MethodHookParam) { - val requestorUid = param.args[2] as? Int ?: return - if (!VpnSanitizer.shouldHide(requestorUid)) return - val nc = param.result as? NetworkCapabilities ?: return - param.result = VpnSanitizer.sanitizeRequestCapabilities(nc) - } - }, - ) - } - - // endregion - - // region callback hooks - - private fun hookCallCallbackForRequest() { - if (helper.sdkInt >= 36) { - // API 36+ has both WithAgent and WithBundle variants - try { - hookCallCallbackForRequestWithAgent() - } catch (e: Throwable) { - HookErrorStore.w(SOURCE, "hookCallCallbackForRequestWithAgent failed: ${e.message}", e) - } - try { - hookCallCallbackForRequestWithBundle() - } catch (e: Throwable) { - HookErrorStore.w(SOURCE, "hookCallCallbackForRequestWithBundle failed: ${e.message}", e) - } - } else { - try { - hookCallCallbackForRequestWithAgent() - } catch (e: Throwable) { - HookErrorStore.w(SOURCE, "hookCallCallbackForRequestWithAgent failed: ${e.message}", e) - } - } - } - - private fun hookCallCallbackForRequestWithAgent() { - val (nriClass, naiClass) = helper.resolveNriAndNaiClasses() - XposedHelpers.findAndHookMethod( - helper.cls, - "callCallbackForRequest", - nriClass, - naiClass, - Int::class.javaPrimitiveType, - Int::class.javaPrimitiveType, - object : SafeMethodHook(SOURCE) { - override fun beforeHook(param: MethodHookParam) { - val nri = param.args[0] ?: return - val uid = helper.getAsUid(nri) - if (!VpnSanitizer.shouldHide(uid)) return - val networkAgent = param.args[1] - if (networkAgent != null && helper.isVpnNai(networkAgent)) { - val underlying = helper.getUnderlyingNai(param.thisObject, uid) - if (underlying != null) { - param.args[1] = underlying - } - } - VpnHideContext.setTargetUid(uid) - } - - override fun afterHook(param: MethodHookParam) { - VpnHideContext.clear() - } - }, - ) - } - - private fun hookCallCallbackForRequestWithBundle() { - val (nriClass, _) = helper.resolveNriAndNaiClasses() - XposedHelpers.findAndHookMethod( - helper.cls, - "callCallbackForRequest", - nriClass, - Int::class.javaPrimitiveType, - Bundle::class.java, - Int::class.javaPrimitiveType, - object : SafeMethodHook(SOURCE) { - override fun beforeHook(param: MethodHookParam) { - val nri = param.args[0] ?: return - val uid = helper.getAsUid(nri) - if (!VpnSanitizer.shouldHide(uid)) return - VpnHideContext.setTargetUid(uid) - } - - override fun afterHook(param: MethodHookParam) { - VpnHideContext.clear() - } - }, - ) - } - - private fun hookSendPendingIntentForRequest() { - val (nriClass, naiClass) = helper.resolveNriAndNaiClasses() - XposedHelpers.findAndHookMethod( - helper.cls, - "sendPendingIntentForRequest", - nriClass, - naiClass, - Int::class.javaPrimitiveType, - object : SafeMethodHook(SOURCE) { - override fun beforeHook(param: MethodHookParam) { - val nri = param.args[0] ?: return - val uid = helper.getAsUid(nri) - if (!VpnSanitizer.shouldHide(uid)) return - val networkAgent = param.args[1] - if (networkAgent != null && helper.isVpnNai(networkAgent)) { - val underlying = helper.getUnderlyingNai(param.thisObject, uid) - if (underlying != null) { - param.args[1] = underlying - } - } - VpnHideContext.setTargetUid(uid) - } - - override fun afterHook(param: MethodHookParam) { - VpnHideContext.clear() - } - }, - ) - } - - // endregion -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityServiceHookHelper.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityServiceHookHelper.kt deleted file mode 100644 index 39b0b3b85b..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityServiceHookHelper.kt +++ /dev/null @@ -1,530 +0,0 @@ -package io.nekohasekai.sfa.xposed.hooks.hidevpn - -import android.content.Context -import android.net.LinkProperties -import android.net.Network -import android.net.NetworkInfo -import android.os.Build -import android.os.IBinder -import de.robv.android.xposed.XC_MethodHook -import de.robv.android.xposed.XposedHelpers -import io.nekohasekai.sfa.xposed.HookErrorStore -import io.nekohasekai.sfa.xposed.PrivilegeSettingsStore -import io.nekohasekai.sfa.xposed.VpnAppStore -import io.nekohasekai.sfa.xposed.VpnSanitizer -import io.nekohasekai.sfa.xposed.hooks.SafeMethodHook -import io.nekohasekai.sfa.xposed.hooks.XHook -import java.lang.reflect.Method -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.atomic.AtomicBoolean - -class ConnectivityServiceHookHelper(private val classLoader: ClassLoader) : XHook { - companion object { - private const val SOURCE = "ConnectivityServiceHookHelper" - } - - private val hooked = AtomicBoolean(false) - private val initializerHooked = AtomicBoolean(false) - private var classLoadUnhook: XC_MethodHook.Unhook? = null - private val serviceManagerHooked = AtomicBoolean(false) - private var connectivityClassLoader: ClassLoader = classLoader - private val skipLogKeys = ConcurrentHashMap() - val sdkInt = Build.VERSION.SDK_INT - - lateinit var cls: Class<*> - private set - - private val serviceManagerClass by lazy { Class.forName("android.os.ServiceManager") } - private val checkServiceMethod by lazy { serviceManagerClass.getMethod("checkService", String::class.java) } - - private var getVpnForUidMethod: Method? = null - private lateinit var getVpnUnderlyingNetworksMethod: Method - private lateinit var getNetworkAgentInfoForNetworkMethod: Method - private var getFilteredNetworkInfoMethod: Method? = null - private lateinit var getDefaultNetworkMethod: Method - private lateinit var isVPNMethod: Method - private var networkMethod: Method? = null - - override fun injectHook() { - val foundClass = findConnectivityServiceClass() - if (foundClass != null) { - installHooks(foundClass, "direct") - return - } - hookConnectivityServiceInitializer() - hookClassLoaderFallback() - tryHookFromServiceManager() - } - - private fun installHooks(cls: Class<*>, source: String) { - if (!hooked.compareAndSet(false, true)) { - return - } - this.cls = cls - connectivityClassLoader = cls.classLoader ?: classLoader - initMethodCache() - HookErrorStore.i( - SOURCE, - "Installing ConnectivityService hooks ($source) cls=${cls.name} loader=${connectivityClassLoader.javaClass.name}", - ) - - // Install all individual hooks - HookConnectivityManagerGetActiveNetwork(this).install() - HookConnectivityManagerGetActiveNetworkInfo(this).install() - HookConnectivityManagerGetNetworkInfo(this).install() - HookConnectivityManagerGetAllNetworkInfo(this).install() - HookConnectivityManagerGetAllNetworks(this).install() - HookConnectivityManagerGetNetworkForType(this).install() - HookConnectivityManagerGetNetworkCapabilities(this).install() - HookConnectivityManagerGetLinkProperties(this).install() - HookConnectivityManagerRequestNetwork(this).install() - HookConnectivityManagerGetDefaultProxy(this).install() - HookConnectivityManagerConnectivityAction(this).install() - HookConnectivityManagerProxyChangeAction(this).install() - - HookErrorStore.i(SOURCE, "Hooked ConnectivityService ($source) cls=${cls.name}") - } - - private fun initMethodCache() { - val intType = Int::class.javaPrimitiveType!! - val booleanType = Boolean::class.javaPrimitiveType!! - val naiClass = resolveConnectivityModuleClass("NetworkAgentInfo", "connectivity") - if (sdkInt >= 31) { - getVpnForUidMethod = findDeclaredMethod(cls, "getVpnForUid", intType) - if (getVpnForUidMethod == null) { - HookErrorStore.w(SOURCE, "getVpnForUid not found; falling back to underlying networks") - } - } - getVpnUnderlyingNetworksMethod = requireDeclaredMethod(cls, "getVpnUnderlyingNetworks", intType) - getNetworkAgentInfoForNetworkMethod = requireDeclaredMethod(cls, "getNetworkAgentInfoForNetwork", Network::class.java) - if (sdkInt >= 31) { - getFilteredNetworkInfoMethod = findDeclaredMethod( - cls, - "getFilteredNetworkInfo", - naiClass, - intType, - booleanType, - ) - if (getFilteredNetworkInfoMethod == null) { - HookErrorStore.w(SOURCE, "getFilteredNetworkInfo not found; network info sanitization disabled") - } - } - getDefaultNetworkMethod = requireDeclaredMethod(cls, "getDefaultNetwork") - isVPNMethod = requireDeclaredMethod(naiClass, "isVPN") - networkMethod = findDeclaredMethod(naiClass, "network") - if (networkMethod == null) { - HookErrorStore.w(SOURCE, "NetworkAgentInfo.network() not found; falling back to field access") - } - } - - // region Service Discovery - - private fun findConnectivityServiceClass(): Class<*>? { - val candidates = listOf( - "com.android.server.ConnectivityService", - ) - val loaders = listOf( - classLoader, - classLoader.parent, - Thread.currentThread().contextClassLoader, - ClassLoader.getSystemClassLoader(), - ClassLoader.getSystemClassLoader()?.parent, - ) - for (name in candidates) { - for (loader in loaders) { - try { - val found = if (loader != null) { - Class.forName(name, false, loader) - } else { - Class.forName(name) - } - HookErrorStore.i( - SOURCE, - "ConnectivityService class found: $name via ${loader?.javaClass?.name ?: "null"}", - ) - return found - } catch (_: Throwable) { - } - } - } - HookErrorStore.i(SOURCE, "ConnectivityService class not found in known classloaders") - return null - } - - private fun hookConnectivityServiceInitializer() { - if (sdkInt < 31 || sdkInt >= 33) { - HookErrorStore.d(SOURCE, "Skip ConnectivityServiceInitializer: sdk=$sdkInt (only exists in API 31-32)") - return - } - val candidates = listOf( - "com.android.server.ConnectivityServiceInitializer", - "com.android.server.ConnectivityServiceInitializerB", - ) - val loaders = listOf( - classLoader, - classLoader.parent, - Thread.currentThread().contextClassLoader, - ClassLoader.getSystemClassLoader(), - ClassLoader.getSystemClassLoader()?.parent, - ) - for (name in candidates) { - for (loader in loaders) { - val cls = try { - if (loader != null) { - Class.forName(name, false, loader) - } else { - Class.forName(name) - } - } catch (_: Throwable) { - null - } ?: continue - try { - if (initializerHooked.get()) { - return - } - XposedHelpers.findAndHookConstructor( - cls, - Context::class.java, - object : SafeMethodHook(SOURCE) { - override fun afterHook(param: MethodHookParam) { - if (hooked.get()) return - val instance = param.thisObject ?: return - val connectivity = findConnectivityServiceInstance(instance) ?: return - installHooks(connectivity.javaClass, "initializer_ctor") - } - }, - ) - XposedHelpers.findAndHookMethod( - cls, - "onStart", - object : SafeMethodHook(SOURCE) { - override fun afterHook(param: MethodHookParam) { - if (hooked.get()) return - val instance = param.thisObject ?: return - val connectivity = findConnectivityServiceInstance(instance) ?: return - installHooks(connectivity.javaClass, "initializer") - } - }, - ) - initializerHooked.set(true) - HookErrorStore.i( - SOURCE, - "Hooked $name (ctor/onStart) via ${loader?.javaClass?.name ?: "null"}", - ) - return - } catch (e: Throwable) { - HookErrorStore.w(SOURCE, "Hook $name failed: ${e.message}", e) - } - } - } - HookErrorStore.d(SOURCE, "ConnectivityServiceInitializer not found in known classloaders") - } - - private fun hookClassLoaderFallback() { - if (classLoadUnhook != null) { - return - } - try { - classLoadUnhook = XposedHelpers.findAndHookMethod( - ClassLoader::class.java, - "loadClass", - String::class.java, - Boolean::class.javaPrimitiveType, - object : SafeMethodHook(SOURCE) { - override fun afterHook(param: MethodHookParam) { - val name = param.args[0] as? String ?: return - if (hooked.get()) { - classLoadUnhook?.unhook() - classLoadUnhook = null - return - } - when (name) { - "com.android.server.ConnectivityService" -> { - val cls = param.result as? Class<*> ?: return - HookErrorStore.i( - SOURCE, - "ConnectivityService loaded via ${param.thisObject.javaClass.name}", - ) - installHooks(cls, "loadClass") - classLoadUnhook?.unhook() - classLoadUnhook = null - } - "com.android.server.ConnectivityServiceInitializer", - "com.android.server.ConnectivityServiceInitializerB", - -> { - if (sdkInt < 31) return - if (initializerHooked.get()) return - val cls = param.result as? Class<*> ?: return - HookErrorStore.i( - SOURCE, - "ConnectivityServiceInitializer loaded via ${param.thisObject.javaClass.name}", - ) - hookConnectivityServiceInitializerClass(cls) - } - } - } - }, - ) - HookErrorStore.i(SOURCE, "Hooked ClassLoader.loadClass for ConnectivityService") - } catch (e: Throwable) { - HookErrorStore.w(SOURCE, "Hook ClassLoader.loadClass failed: ${e.message}", e) - } - } - - private fun tryHookFromServiceManager() { - if (hooked.get()) return - val binder = try { - checkServiceMethod.invoke(null, Context.CONNECTIVITY_SERVICE) as? IBinder - } catch (_: Throwable) { - null - } - if (binder != null) { - HookErrorStore.i( - SOURCE, - "ConnectivityService binder from ServiceManager: ${binder.javaClass.name}", - ) - installHooks(binder.javaClass, "ServiceManager.checkService") - return - } - hookServiceManagerAddService() - } - - private fun hookServiceManagerAddService() { - if (!serviceManagerHooked.compareAndSet(false, true)) { - return - } - try { - val serviceManager = Class.forName("android.os.ServiceManager") - XposedHelpers.findAndHookMethod( - serviceManager, - "addService", - String::class.java, - IBinder::class.java, - Boolean::class.javaPrimitiveType, - Int::class.javaPrimitiveType, - object : SafeMethodHook(SOURCE) { - override fun afterHook(param: MethodHookParam) { - if (hooked.get()) return - val name = param.args[0] as? String ?: return - if (name != Context.CONNECTIVITY_SERVICE) return - val binder = param.args[1] as? IBinder ?: return - HookErrorStore.i( - SOURCE, - "ConnectivityService registered: ${binder.javaClass.name}", - ) - installHooks(binder.javaClass, "ServiceManager.addService") - } - }, - ) - HookErrorStore.i(SOURCE, "Hooked ServiceManager.addService for ConnectivityService") - } catch (e: Throwable) { - HookErrorStore.w(SOURCE, "Hook ServiceManager.addService failed: ${e.message}", e) - } - } - - private fun hookConnectivityServiceInitializerClass(cls: Class<*>) { - if (sdkInt < 31) return - if (initializerHooked.get()) return - try { - XposedHelpers.findAndHookConstructor( - cls, - Context::class.java, - object : SafeMethodHook(SOURCE) { - override fun afterHook(param: MethodHookParam) { - if (hooked.get()) return - val instance = param.thisObject ?: return - val connectivity = findConnectivityServiceInstance(instance) ?: return - installHooks(connectivity.javaClass, "initializer_ctor") - } - }, - ) - XposedHelpers.findAndHookMethod( - cls, - "onStart", - object : SafeMethodHook(SOURCE) { - override fun afterHook(param: MethodHookParam) { - if (hooked.get()) return - val instance = param.thisObject ?: return - val connectivity = findConnectivityServiceInstance(instance) ?: return - installHooks(connectivity.javaClass, "initializer") - } - }, - ) - initializerHooked.set(true) - HookErrorStore.i(SOURCE, "Hooked ${cls.name} (ctor/onStart) via loadClass") - } catch (e: Throwable) { - HookErrorStore.w(SOURCE, "Hook ${cls.name} via loadClass failed: ${e.message}", e) - } - } - - private fun findConnectivityServiceInstance(instance: Any): Any? { - try { - val direct = XposedHelpers.getObjectField(instance, "mConnectivity") - if (direct != null) { - return direct - } - } catch (_: Throwable) { - } - return try { - val fields = instance.javaClass.declaredFields - for (field in fields) { - if (field.type.name.endsWith(".ConnectivityService")) { - field.isAccessible = true - val value = field.get(instance) - if (value != null) { - return value - } - } - } - null - } catch (_: Throwable) { - null - } - } - - // endregion - - // region Helper Methods - - fun shouldHide(connectivityService: Any, uid: Int): Boolean { - if (!PrivilegeSettingsStore.isEnabled()) { - logSkipOnce(uid, "hide_disabled", "Skip hide: uid=$uid hide settings disabled") - return false - } - if (!PrivilegeSettingsStore.isUidSelected(uid)) { - logSkipOnce(uid, "hide_not_selected", "Skip hide: uid=$uid not in hide list") - return false - } - if (VpnAppStore.isVpnUidExcludeSelf(uid)) { - logSkipOnce(uid, "uid_vpn_app", "Skip hide: uid=$uid vpn app") - return false - } - val hasVpn = hasVpnForUid(connectivityService, uid) - if (!hasVpn) { - logSkipOnce(uid, "uid_no_vpn", "Skip hide: uid=$uid noVpnForUid") - } - return hasVpn - } - - fun hasVpnForUid(connectivityService: Any, uid: Int): Boolean { - if (sdkInt >= 31) { - val vpnForUidMethod = getVpnForUidMethod - if (vpnForUidMethod != null) { - return vpnForUidMethod.invoke(connectivityService, uid) != null - } - } - @Suppress("UNCHECKED_CAST") - val networks = getVpnUnderlyingNetworksMethod.invoke(connectivityService, uid) as? Array - return networks != null && networks.isNotEmpty() - } - - fun isVpnNetwork(connectivityService: Any, network: Network): Boolean { - val nai = getNetworkAgentInfoForNetworkMethod.invoke(connectivityService, network) ?: return false - return isVpnNai(nai) - } - - fun isVpnNai(nai: Any): Boolean = isVPNMethod.invoke(nai) as Boolean - - fun getUnderlyingNetwork(connectivityService: Any, uid: Int): Network? { - val nai = getUnderlyingNai(connectivityService, uid) ?: return null - val method = networkMethod - return if (method != null) { - method.invoke(nai) as Network? - } else { - XposedHelpers.getObjectField(nai, "network") as? Network - } - } - - fun getUnderlyingLinkProperties(connectivityService: Any, uid: Int): LinkProperties? { - val nai = getUnderlyingNai(connectivityService, uid) ?: return null - val lp = XposedHelpers.getObjectField(nai, "linkProperties") as LinkProperties? ?: return null - return VpnSanitizer.cloneLinkProperties(lp) - } - - fun getUnderlyingNetworkInfo(connectivityService: Any, uid: Int): NetworkInfo? { - val nai = getUnderlyingNai(connectivityService, uid) ?: return null - val method = getFilteredNetworkInfoMethod - if (method != null) { - return method.invoke(connectivityService, nai, uid, false) as NetworkInfo? - } - return XposedHelpers.getObjectField(nai, "networkInfo") as? NetworkInfo - } - - fun getUnderlyingNai(connectivityService: Any, uid: Int): Any? { - @Suppress("UNCHECKED_CAST") - val networks = getVpnUnderlyingNetworksMethod.invoke(connectivityService, uid) as? Array - if (networks != null && networks.isNotEmpty()) { - return getNetworkAgentInfoForNetworkMethod.invoke(connectivityService, networks[0]) - } - val defaultNai = getDefaultNetworkMethod.invoke(connectivityService) - if (defaultNai != null && !isVpnNai(defaultNai)) { - return defaultNai - } - return null - } - - private fun findDeclaredMethod(target: Class<*>, name: String, vararg parameterTypes: Class<*>): Method? { - var current: Class<*>? = target - while (current != null) { - try { - return current.getDeclaredMethod(name, *parameterTypes).apply { isAccessible = true } - } catch (_: NoSuchMethodException) { - current = current.superclass - } - } - return null - } - - private fun requireDeclaredMethod(target: Class<*>, name: String, vararg parameterTypes: Class<*>): Method = findDeclaredMethod(target, name, *parameterTypes) - ?: throw NoSuchMethodException("${target.name}#$name") - - /** - * Resolves a class from the Connectivity module, handling APEX package rewriting. - * - * When the Connectivity module runs as an APEX (Android 12+), all classes get prefixed - * with "android.net.connectivity.". This method derives the correct prefix from - * the already-loaded ConnectivityService class. - * - * @param simpleClassName Simple class name (e.g., "ProxyTracker") - * @param subPackage Sub-package under com.android.server (e.g., "connectivity"), or null - */ - fun resolveConnectivityModuleClass(simpleClassName: String, subPackage: String? = null): Class<*> { - val base = cls.name - val serverPackage = if (base.endsWith(".ConnectivityService")) { - base.removeSuffix(".ConnectivityService") - } else { - base.substringBeforeLast(".ConnectivityService", base) - } - - val fullClassName = if (subPackage != null) { - "$serverPackage.$subPackage.$simpleClassName" - } else { - "$serverPackage.$simpleClassName" - } - - return XposedHelpers.findClass(fullClassName, connectivityClassLoader) - } - - fun resolveNriAndNaiClasses(): Pair, Class<*>> { - val nriClass = XposedHelpers.findClass( - cls.name + '$' + "NetworkRequestInfo", - connectivityClassLoader, - ) - val naiClass = resolveConnectivityModuleClass("NetworkAgentInfo", "connectivity") - return Pair(nriClass, naiClass) - } - - fun getAsUid(nri: Any): Int { - val fieldName = if (sdkInt >= 31) "mAsUid" else "mUid" - return XposedHelpers.getIntField(nri, fieldName) - } - - fun logSkipOnce(uid: Int, reason: String, message: String) { - val key = "$uid:$reason" - if (skipLogKeys.putIfAbsent(key, true) == null) { - HookErrorStore.d(SOURCE, message) - } - } - - // endregion -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/NetworkCapabilities+writeToParcel.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/NetworkCapabilities+writeToParcel.kt deleted file mode 100644 index 2f7800868e..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/NetworkCapabilities+writeToParcel.kt +++ /dev/null @@ -1,120 +0,0 @@ -package io.nekohasekai.sfa.xposed.hooks.hidevpn - -import android.net.NetworkCapabilities -import android.os.Binder -import android.os.Parcel -import de.robv.android.xposed.XposedBridge -import de.robv.android.xposed.XposedHelpers -import io.nekohasekai.sfa.xposed.HookErrorStore -import io.nekohasekai.sfa.xposed.HookStatusStore -import io.nekohasekai.sfa.xposed.VpnHideContext -import io.nekohasekai.sfa.xposed.VpnSanitizer -import io.nekohasekai.sfa.xposed.hooks.SafeMethodHook -import io.nekohasekai.sfa.xposed.hooks.XHook - -class HookNetworkCapabilitiesWriteToParcel : XHook { - private companion object { - private const val SOURCE = "HookNCWriteToParcel" - } - - private val copyCtor by lazy { - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) { - NetworkCapabilities::class.java.getDeclaredConstructor( - NetworkCapabilities::class.java, - Long::class.javaPrimitiveType, - ).apply { isAccessible = true } - } else { - NetworkCapabilities::class.java.getDeclaredConstructor( - NetworkCapabilities::class.java, - ).apply { isAccessible = true } - } - } - private val removeTransportTypeMethod by lazy { - NetworkCapabilities::class.java.getMethod("removeTransportType", Int::class.javaPrimitiveType) - } - private val addCapabilityMethod by lazy { - NetworkCapabilities::class.java.getMethod("addCapability", Int::class.javaPrimitiveType) - } - - private val inWrite = ThreadLocal.withInitial { false } - - override fun injectHook() { - XposedHelpers.findAndHookMethod( - NetworkCapabilities::class.java, - "writeToParcel", - Parcel::class.java, - Int::class.javaPrimitiveType!!, - object : SafeMethodHook(SOURCE) { - override fun beforeHook(param: MethodHookParam) { - if (inWrite.get() == true) { - return - } - val targetUid = VpnHideContext.consumeTargetUid() - val shouldHide = when { - targetUid != null -> VpnSanitizer.shouldHide(targetUid) - else -> VpnSanitizer.shouldHide(Binder.getCallingUid()) - } - if (!shouldHide) { - return - } - val caps = param.thisObject as NetworkCapabilities - val sanitized = copyNetworkCapabilities(caps) - sanitizeNetworkCapabilities(sanitized) - HookStatusStore.markPatched() - inWrite.set(true) - try { - XposedBridge.invokeOriginalMethod(param.method, sanitized, param.args) - param.result = null - } finally { - inWrite.set(false) - } - } - }, - ) - HookStatusStore.markHookActive() - HookErrorStore.i(SOURCE, "Hooked NetworkCapabilities.writeToParcel (sender)") - } - - private fun copyNetworkCapabilities(caps: NetworkCapabilities): NetworkCapabilities = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) { - copyCtor.newInstance(caps, 0L) as NetworkCapabilities - } else { - copyCtor.newInstance(caps) as NetworkCapabilities - } - - private fun sanitizeNetworkCapabilities(caps: NetworkCapabilities) { - removeTransportTypeMethod.invoke(caps, NetworkCapabilities.TRANSPORT_VPN) - addCapabilityMethod.invoke(caps, NetworkCapabilities.NET_CAPABILITY_NOT_VPN) - clearVpnTransportInfo(caps) - clearUnderlyingNetworks(caps) - clearOwnerUid(caps) - } - - private fun clearVpnTransportInfo(caps: NetworkCapabilities) { - if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.Q) { - return - } - val field = XposedHelpers.findField(NetworkCapabilities::class.java, "mTransportInfo") - val info = field.get(caps) ?: return - if (info.javaClass.name.contains("VpnTransportInfo")) { - field.set(caps, null) - } - } - - private fun clearUnderlyingNetworks(caps: NetworkCapabilities) { - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) { - val field = XposedHelpers.findField(NetworkCapabilities::class.java, "mUnderlyingNetworks") - field.set(caps, null) - } else if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) { - val field = XposedHelpers.findFieldIfExists(NetworkCapabilities::class.java, "mUnderlyingNetworks") - field?.set(caps, null) - } - } - - private fun clearOwnerUid(caps: NetworkCapabilities) { - if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.R) { - return - } - val field = XposedHelpers.findField(NetworkCapabilities::class.java, "mOwnerUid") - field.setInt(caps, android.os.Process.INVALID_UID) - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/NetworkInterface+getName.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/NetworkInterface+getName.kt deleted file mode 100644 index f9b7a2086a..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/NetworkInterface+getName.kt +++ /dev/null @@ -1,299 +0,0 @@ -package io.nekohasekai.sfa.xposed.hooks.hidevpn - -import android.system.Os -import android.system.OsConstants -import android.system.StructTimeval -import de.robv.android.xposed.XposedHelpers -import io.nekohasekai.sfa.xposed.HookErrorStore -import io.nekohasekai.sfa.xposed.PrivilegeSettingsStore -import io.nekohasekai.sfa.xposed.hooks.SafeMethodHook -import io.nekohasekai.sfa.xposed.hooks.XHook -import java.io.FileDescriptor -import java.net.SocketAddress -import java.nio.ByteBuffer -import java.nio.ByteOrder -import java.util.concurrent.atomic.AtomicInteger - -class HookNetworkInterfaceGetName(private val classLoader: ClassLoader) : XHook { - private companion object { - private const val SOURCE = "HookNetworkInterfaceGetName" - private const val MAX_NAME_LEN = 15 - private const val MAX_SUFFIX = 63 - private const val NLMSG_HEADER_LEN = 16 - private const val IFINFO_MSG_LEN = 16 - private const val NLA_HEADER_LEN = 4 - private const val RTM_NEWLINK = 16 - private const val IFLA_IFNAME = 3 - private const val NLM_F_REQUEST = 0x1 - private const val NLM_F_ACK = 0x4 - private const val NLMSG_ERROR = 2 - private const val IFF_UP = 0x1 - } - - private val netlinkSocketAddressClass by lazy { Class.forName("android.system.NetlinkSocketAddress") } - private val netlinkSocketAddressCtor by lazy { - netlinkSocketAddressClass.getConstructor(Int::class.javaPrimitiveType, Int::class.javaPrimitiveType) - } - - private val seq = AtomicInteger(1) - - override fun injectHook() { - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) { - hookJniGetNameApi33Plus() - } else { - hookJniGetNameLegacy() - } - } - - private fun hookJniGetNameApi33Plus() { - val vpnClass = findVpnClass() - val depsClass = XposedHelpers.findClass("${vpnClass.name}\$Dependencies", classLoader) - XposedHelpers.findAndHookMethod( - depsClass, - "jniGetName", - vpnClass, - Int::class.javaPrimitiveType, - object : SafeMethodHook(SOURCE) { - override fun afterHook(param: MethodHookParam) { - processJniGetNameResult(param) - } - }, - ) - HookErrorStore.i(SOURCE, "Hooked ${depsClass.name}.jniGetName (API 33+)") - } - - private fun hookJniGetNameLegacy() { - val cls = findVpnClass() - XposedHelpers.findAndHookMethod( - cls, - "jniGetName", - Int::class.javaPrimitiveType, - object : SafeMethodHook(SOURCE) { - override fun afterHook(param: MethodHookParam) { - processJniGetNameResult(param) - } - }, - ) - HookErrorStore.i(SOURCE, "Hooked ${cls.name}.jniGetName (legacy)") - } - - private fun processJniGetNameResult(param: de.robv.android.xposed.XC_MethodHook.MethodHookParam) { - val result = param.result - if (result !is String) { - if (result != null) { - HookErrorStore.e(SOURCE, "jniGetName returned unexpected type: ${result.javaClass.name}") - } - return - } - if (!PrivilegeSettingsStore.shouldRenameInterface()) return - if (!isTunInterface(result)) return - val prefix = PrivilegeSettingsStore.interfacePrefix() - val renamed = renameInterface(result, prefix) ?: return - param.result = renamed - } - - private fun findVpnClass(): Class<*> = XposedHelpers.findClass("com.android.server.connectivity.Vpn", classLoader) - - private fun isTunInterface(name: String): Boolean = name.startsWith("tun") - - private fun renameInterface(oldName: String, prefix: String): String? { - val oldIndex = getInterfaceIndex(oldName) - if (oldIndex <= 0) { - HookErrorStore.e(SOURCE, "rename interface: old name not found (old=$oldName)") - return null - } - val newName = findAvailableName(prefix) - if (newName == null) { - HookErrorStore.e(SOURCE, "rename interface: no available name (prefix=$prefix)") - return null - } - if (newName == oldName) { - return oldName - } - if (!renameWithNetlink(oldIndex, newName)) { - HookErrorStore.e(SOURCE, "rename failed: $oldName -> $newName") - return null - } - val newIndex = getInterfaceIndex(newName) - if (newIndex <= 0) { - HookErrorStore.e( - SOURCE, - "rename interface: new name not found (old=$oldName index=$oldIndex)", - ) - return null - } - HookErrorStore.i(SOURCE, "rename interface: $oldName -> $newName") - return newName - } - - private fun getInterfaceIndex(name: String): Int = Os.if_nametoindex(name) - - private fun findAvailableName(prefix: String): String? { - val base = prefix.trim() - if (base.isEmpty()) { - return null - } - for (i in 0..MAX_SUFFIX) { - val candidate = buildInterfaceName(base, i) ?: return null - if (getInterfaceIndex(candidate) == 0) { - return candidate - } - } - return null - } - - private fun buildInterfaceName(prefix: String, suffix: Int): String? { - val suffixText = suffix.toString() - val maxPrefixLen = MAX_NAME_LEN - suffixText.length - if (maxPrefixLen <= 0) { - return null - } - val trimmed = if (prefix.length > maxPrefixLen) { - prefix.substring(0, maxPrefixLen) - } else { - prefix - } - return trimmed + suffixText - } - - private fun renameWithNetlink(index: Int, newName: String): Boolean { - val fd = openNetlinkSocket() - try { - val renameResult = sendNetlinkMessage( - fd, - buildLinkMessage(index, newName, 0, 0, seq.getAndIncrement()), - OsConstants.EBUSY, - ) ?: return false - if (renameResult == 0) { - return true - } - if (renameResult != OsConstants.EBUSY) { - HookErrorStore.e(SOURCE, "rename interface: netlink ack errno=$renameResult") - return false - } - val downResult = sendNetlinkMessage( - fd, - buildLinkMessage(index, null, 0, IFF_UP, seq.getAndIncrement()), - ) ?: return false - if (downResult != 0) { - HookErrorStore.e(SOURCE, "rename interface: set down failed errno=$downResult") - return false - } - val retryResult = sendNetlinkMessage( - fd, - buildLinkMessage(index, newName, 0, 0, seq.getAndIncrement()), - ) ?: return false - if (retryResult != 0) { - HookErrorStore.e(SOURCE, "rename interface: retry failed errno=$retryResult") - return false - } - val upResult = sendNetlinkMessage( - fd, - buildLinkMessage(index, null, IFF_UP, IFF_UP, seq.getAndIncrement()), - ) - if (upResult != null && upResult != 0) { - HookErrorStore.w(SOURCE, "rename interface: set up failed errno=$upResult") - } - return true - } catch (e: Throwable) { - HookErrorStore.e(SOURCE, "rename interface: netlink exception", e) - return false - } finally { - try { - Os.close(fd) - } catch (e: Throwable) { - HookErrorStore.w(SOURCE, "close netlink socket failed", e) - } - } - } - - private fun openNetlinkSocket(): FileDescriptor { - val fd = Os.socket(OsConstants.AF_NETLINK, OsConstants.SOCK_RAW, OsConstants.NETLINK_ROUTE) - Os.setsockoptTimeval( - fd, - OsConstants.SOL_SOCKET, - OsConstants.SO_RCVTIMEO, - StructTimeval.fromMillis(200), - ) - val address = buildNetlinkAddress() - Os.connect(fd, address) - return fd - } - - private fun buildNetlinkAddress(): SocketAddress = netlinkSocketAddressCtor.newInstance(0, 0) as SocketAddress - - private fun buildLinkMessage(index: Int, ifName: String?, flags: Int, change: Int, seq: Int): ByteArray { - val nameBytes = ifName?.let { (it + "\u0000").toByteArray(Charsets.US_ASCII) } - val attrLen = if (nameBytes != null) NLA_HEADER_LEN + nameBytes.size else 0 - val attrAligned = align(attrLen) - val totalLength = NLMSG_HEADER_LEN + IFINFO_MSG_LEN + attrAligned - val buffer = ByteBuffer.allocate(totalLength).order(ByteOrder.nativeOrder()) - buffer.putInt(totalLength) - buffer.putShort(RTM_NEWLINK.toShort()) - buffer.putShort((NLM_F_REQUEST or NLM_F_ACK).toShort()) - buffer.putInt(seq) - buffer.putInt(Os.getpid()) - buffer.put(OsConstants.AF_UNSPEC.toByte()) - buffer.put(0.toByte()) - buffer.putShort(0) - buffer.putInt(index) - buffer.putInt(flags) - buffer.putInt(change) - if (nameBytes != null) { - buffer.putShort(attrLen.toShort()) - buffer.putShort(IFLA_IFNAME.toShort()) - buffer.put(nameBytes) - val pad = attrAligned - attrLen - repeat(pad) { - buffer.put(0.toByte()) - } - } - return buffer.array() - } - - private fun align(length: Int): Int = (length + 3) and -4 - - private fun sendNetlinkMessage(fd: FileDescriptor, message: ByteArray, suppressErrno: Int? = null): Int? { - Os.write(fd, message, 0, message.size) - val ack = readNetlinkAck(fd) - if (ack == null) { - HookErrorStore.e(SOURCE, "rename interface: netlink ack missing") - return null - } - if (ack.errno != 0 && ack.errno != suppressErrno) { - HookErrorStore.e( - SOURCE, - "rename interface: netlink ack errno=${ack.errno} seq=${ack.seq} pid=${ack.pid}", - ) - } - return ack.errno - } - - private data class NetlinkAck(val errno: Int, val seq: Int, val pid: Int) - - private fun readNetlinkAck(fd: FileDescriptor): NetlinkAck? { - val buffer = ByteArray(4096) - val length = Os.read(fd, buffer, 0, buffer.size) - if (length <= 0 || length < NLMSG_HEADER_LEN) { - return null - } - val byteBuffer = ByteBuffer.wrap(buffer, 0, length).order(ByteOrder.nativeOrder()) - val msgLen = byteBuffer.int - val msgType = byteBuffer.short.toInt() and 0xFFFF - byteBuffer.short - val msgSeq = byteBuffer.int - val msgPid = byteBuffer.int - if (msgLen < NLMSG_HEADER_LEN || msgLen > length) { - return null - } - if (msgType != NLMSG_ERROR) { - return NetlinkAck(0, msgSeq, msgPid) - } - if (byteBuffer.remaining() < 4) { - return null - } - val error = byteBuffer.int - val errno = if (error == 0) 0 else -error - return NetlinkAck(errno, msgSeq, msgPid) - } -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpnapp/PackageManager+getInstalledPackages.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpnapp/PackageManager+getInstalledPackages.kt deleted file mode 100644 index 24c96c2e04..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpnapp/PackageManager+getInstalledPackages.kt +++ /dev/null @@ -1,299 +0,0 @@ -package io.nekohasekai.sfa.xposed.hooks.hidevpnapp - -import android.content.pm.ResolveInfo -import android.os.Binder -import android.os.Build -import android.os.Process -import de.robv.android.xposed.XC_MethodHook -import de.robv.android.xposed.XposedBridge -import de.robv.android.xposed.XposedHelpers -import io.nekohasekai.sfa.BuildConfig -import io.nekohasekai.sfa.xposed.HookErrorStore -import io.nekohasekai.sfa.xposed.PrivilegeSettingsStore -import io.nekohasekai.sfa.xposed.VpnAppStore -import io.nekohasekai.sfa.xposed.hooks.SafeMethodHook -import io.nekohasekai.sfa.xposed.hooks.XHook -import java.lang.reflect.Method - -class HookPackageManagerGetInstalledPackages(private val classLoader: ClassLoader) : XHook { - private companion object { - private const val SOURCE = "HookPMGetInstalledPackages" - private const val PER_USER_RANGE = 100000 - } - - @Volatile - private var lastPackageNameClass: Class<*>? = null - private var getPackageNameMethod: Method? = null - - override fun injectHook() { - val hooked = ArrayList() - val sdk = Build.VERSION.SDK_INT - when { - // VANILLA_ICE_CREAM - sdk >= 35 -> { - hookAppsFilter33Plus(hooked) - hookArchivedPackageInternal(hooked) - } - sdk >= Build.VERSION_CODES.TIRAMISU -> { - hookAppsFilter33Plus(hooked) - } - sdk >= Build.VERSION_CODES.R -> { - hookAppsFilter30(hooked) - } - else -> { - hookPmsLegacy(hooked) - } - } - if (hooked.isNotEmpty()) { - HookErrorStore.i(SOURCE, "Hooked hide applist: ${hooked.joinToString()}") - } else { - HookErrorStore.w(SOURCE, "Hide applist hook not applied") - } - } - - private fun hookAppsFilter33Plus(hooked: MutableList) { - val cls = XposedHelpers.findClassIfExists("com.android.server.pm.AppsFilterImpl", classLoader) - if (cls == null) { - HookErrorStore.e(SOURCE, "Class com.android.server.pm.AppsFilterImpl not found") - return - } - val unhooks = try { - XposedBridge.hookAllMethods( - cls, - "shouldFilterApplication", - object : SafeMethodHook(SOURCE) { - override fun beforeHook(param: MethodHookParam) { - val callingUid = param.args[1] as Int - val callerPackages = getCallerPackages(callingUid) ?: return - val target = param.args[3]!! - val targetPackage = extractPackageName(target) ?: return - if (shouldHidePackage(callingUid, callerPackages, targetPackage)) { - param.result = true - } - } - }, - ) - } catch (e: Throwable) { - HookErrorStore.w(SOURCE, "Skip AppsFilterImpl.shouldFilterApplication: ${e.message}", e) - emptySet() - } - if (unhooks.isNotEmpty()) { - hooked.add("AppsFilterImpl.shouldFilterApplication") - } - } - - private fun hookAppsFilter30(hooked: MutableList) { - val cls = XposedHelpers.findClassIfExists("com.android.server.pm.AppsFilter", classLoader) - if (cls == null) { - HookErrorStore.e(SOURCE, "Class com.android.server.pm.AppsFilter not found") - return - } - val unhooks = try { - XposedBridge.hookAllMethods( - cls, - "shouldFilterApplication", - object : SafeMethodHook(SOURCE) { - override fun beforeHook(param: MethodHookParam) { - val callingUid = param.args[0] as Int - val callerPackages = getCallerPackages(callingUid) ?: return - val target = param.args[2]!! - val targetPackage = extractPackageName(target) ?: return - if (shouldHidePackage(callingUid, callerPackages, targetPackage)) { - param.result = true - } - } - }, - ) - } catch (e: Throwable) { - HookErrorStore.w(SOURCE, "Skip AppsFilter.shouldFilterApplication: ${e.message}", e) - emptySet() - } - if (unhooks.isNotEmpty()) { - hooked.add("AppsFilter.shouldFilterApplication") - } - } - - private fun hookArchivedPackageInternal(hooked: MutableList) { - val cls = XposedHelpers.findClassIfExists("com.android.server.pm.PackageManagerService", classLoader) - if (cls == null) { - HookErrorStore.e(SOURCE, "Class com.android.server.pm.PackageManagerService not found") - return - } - val unhooks = try { - XposedBridge.hookAllMethods( - cls, - "getArchivedPackageInternal", - object : SafeMethodHook(SOURCE) { - override fun beforeHook(param: MethodHookParam) { - val callingUid = Binder.getCallingUid() - val callerPackages = getCallerPackages(callingUid) ?: return - val targetPackage = param.args[0]!!.toString() - if (shouldHidePackage(callingUid, callerPackages, targetPackage)) { - param.result = null - } - } - }, - ) - } catch (e: Throwable) { - HookErrorStore.w(SOURCE, "Skip PackageManagerService.getArchivedPackageInternal: ${e.message}", e) - emptySet() - } - if (unhooks.isNotEmpty()) { - hooked.add("PackageManagerService.getArchivedPackageInternal") - } - } - - private fun hookPmsLegacy(hooked: MutableList) { - val cls = XposedHelpers.findClassIfExists("com.android.server.pm.PackageManagerService", classLoader) - if (cls == null) { - HookErrorStore.e(SOURCE, "Class com.android.server.pm.PackageManagerService not found") - return - } - val filterHooks = try { - XposedBridge.hookAllMethods( - cls, - "filterAppAccessLPr", - object : SafeMethodHook(SOURCE) { - override fun beforeHook(param: MethodHookParam) { - val callingUid = param.args[1] as Int - val callerPackages = getCallerPackages(callingUid) ?: return - val target = param.args[0]!! - val targetPackage = extractPackageName(target) ?: return - if (shouldHidePackage(callingUid, callerPackages, targetPackage)) { - param.result = true - } - } - }, - ) - } catch (e: Throwable) { - HookErrorStore.w(SOURCE, "Skip PackageManagerService.filterAppAccessLPr: ${e.message}", e) - emptySet() - } - if (filterHooks.isNotEmpty()) { - hooked.add("PackageManagerService.filterAppAccessLPr") - } - - val resolutionHooks = try { - XposedBridge.hookAllMethods( - cls, - "applyPostResolutionFilter", - object : SafeMethodHook(SOURCE) { - override fun afterHook(param: MethodHookParam) { - val callingUid = param.args[3] as Int - val callerPackages = getCallerPackages(callingUid) ?: return - val rawResult = param.result ?: return - when (rawResult) { - is MutableCollection<*> -> { - @Suppress("UNCHECKED_CAST") - val result = rawResult as MutableCollection - val iterator = result.iterator() - while (iterator.hasNext()) { - val info = iterator.next() - val targetPackage = with(info) { - activityInfo?.packageName - ?: serviceInfo?.packageName - ?: providerInfo?.packageName - ?: resolvePackageName - } - if (targetPackage != null && - shouldHidePackage(callingUid, callerPackages, targetPackage) - ) { - iterator.remove() - } - } - } - is List<*> -> { - val filtered = rawResult.filterNot { item -> - val info = item as? ResolveInfo ?: return@filterNot false - val targetPackage = with(info) { - activityInfo?.packageName - ?: serviceInfo?.packageName - ?: providerInfo?.packageName - ?: resolvePackageName - } - targetPackage != null && - shouldHidePackage(callingUid, callerPackages, targetPackage) - } - param.result = filtered - } - } - } - }, - ) - } catch (e: Throwable) { - HookErrorStore.w(SOURCE, "Skip PackageManagerService.applyPostResolutionFilter: ${e.message}", e) - emptySet() - } - if (resolutionHooks.isNotEmpty()) { - hooked.add("PackageManagerService.applyPostResolutionFilter") - } - } - - private fun getCallerPackages(callingUid: Int): List? { - if (callingUid < Process.FIRST_APPLICATION_UID) { - return null - } - if (!PrivilegeSettingsStore.shouldHideUid(callingUid)) { - return null - } - val packages = VpnAppStore.getPackagesForUid(callingUid) - if (packages.isEmpty()) { - return null - } - if (packages.contains(BuildConfig.APPLICATION_ID)) { - return null - } - return packages - } - - private fun shouldHidePackage( - callingUid: Int, - callerPackages: List, - targetPackage: String, - ): Boolean { - if (callerPackages.contains(targetPackage)) { - return false - } - val userId = callingUid / PER_USER_RANGE - if (!VpnAppStore.isVpnPackage(targetPackage, userId)) { - return false - } - return true - } - - private fun extractPackageName(arg: Any?): String? { - if (arg == null) return null - try { - val argClass = arg.javaClass - val method = if (lastPackageNameClass == argClass && getPackageNameMethod != null) { - getPackageNameMethod!! - } else { - argClass.getMethod("getPackageName").also { - lastPackageNameClass = argClass - getPackageNameMethod = it - } - } - val result = method.invoke(arg) as String? - if (!result.isNullOrEmpty()) { - return result - } - } catch (_: NoSuchMethodException) { - } catch (e: Throwable) { - HookErrorStore.w(SOURCE, "extractPackageName via getPackageName() failed for ${arg.javaClass.name}", e) - } - val fields = arrayOf("packageName", "mPackageName", "name", "mName") - for (name in fields) { - val field = XposedHelpers.findFieldIfExists(arg.javaClass, name) ?: continue - try { - val result = field.get(arg) as String? - if (!result.isNullOrEmpty()) { - return result - } - } catch (e: Throwable) { - HookErrorStore.w(SOURCE, "extractPackageName via field $name failed for ${arg.javaClass.name}", e) - } - } - HookErrorStore.w(SOURCE, "extractPackageName failed for ${arg.javaClass.name}") - return null - } -} diff --git a/sing-box/clients/android/app/src/main/res/drawable/ic_filter_list_24.xml b/sing-box/clients/android/app/src/main/res/drawable/ic_filter_list_24.xml deleted file mode 100644 index 344421f0fb..0000000000 --- a/sing-box/clients/android/app/src/main/res/drawable/ic_filter_list_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/res/drawable/ic_pause_24.xml b/sing-box/clients/android/app/src/main/res/drawable/ic_pause_24.xml deleted file mode 100644 index 7b10b5607f..0000000000 --- a/sing-box/clients/android/app/src/main/res/drawable/ic_pause_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/res/drawable/ic_search_24.xml b/sing-box/clients/android/app/src/main/res/drawable/ic_search_24.xml deleted file mode 100644 index 3285a07714..0000000000 --- a/sing-box/clients/android/app/src/main/res/drawable/ic_search_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/res/layout/activity_add_profile.xml b/sing-box/clients/android/app/src/main/res/layout/activity_add_profile.xml new file mode 100644 index 0000000000..4657c248c6 --- /dev/null +++ b/sing-box/clients/android/app/src/main/res/layout/activity_add_profile.xml @@ -0,0 +1,188 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/res/layout/activity_config_override.xml b/sing-box/clients/android/app/src/main/res/layout/activity_config_override.xml new file mode 100644 index 0000000000..163350ec40 --- /dev/null +++ b/sing-box/clients/android/app/src/main/res/layout/activity_config_override.xml @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +