Repository: MatsuriDayo/NekoBoxForAndroid Branch: main Commit: 5768494d8ae3 Files: 524 Total size: 1.8 MB Directory structure: gitextract_rsn9ha2i/ ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug-report-en.md │ │ ├── bug-report-zh_cn.md │ │ ├── feature_request-en.md │ │ └── feature_request-zh_cn.md │ └── workflows/ │ ├── preview.yml │ └── release.yml ├── .gitignore ├── AUTHORS ├── LICENSE ├── README.md ├── app/ │ ├── .gitignore │ ├── build.gradle.kts │ ├── executableSo/ │ │ └── .gitignore │ ├── proguard-rules.pro │ ├── schemas/ │ │ ├── io.nekohasekai.sagernet.database.SagerDatabase/ │ │ │ ├── 1.json │ │ │ ├── 2.json │ │ │ ├── 3.json │ │ │ ├── 4.json │ │ │ ├── 5.json │ │ │ └── 6.json │ │ ├── io.nekohasekai.sagernet.database.preference.PublicDatabase/ │ │ │ └── 1.json │ │ └── moe.matsuri.nb4a.TempDatabase/ │ │ └── 1.json │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ ├── aidl/ │ │ └── io/ │ │ └── nekohasekai/ │ │ └── sagernet/ │ │ └── aidl/ │ │ ├── ISagerNetService.aidl │ │ ├── ISagerNetServiceCallback.aidl │ │ ├── SpeedDisplayData.aidl │ │ └── TrafficData.aidl │ ├── assets/ │ │ ├── LICENSE │ │ ├── proxy_packagename.txt │ │ └── yacd.version.txt │ ├── java/ │ │ ├── com/ │ │ │ └── github/ │ │ │ └── shadowsocks/ │ │ │ └── plugin/ │ │ │ ├── Utils.kt │ │ │ └── fragment/ │ │ │ └── AlertDialogFragment.kt │ │ ├── io/ │ │ │ └── nekohasekai/ │ │ │ └── sagernet/ │ │ │ ├── BootReceiver.kt │ │ │ ├── Constants.kt │ │ │ ├── QuickToggleShortcut.kt │ │ │ ├── SagerNet.kt │ │ │ ├── aidl/ │ │ │ │ ├── SpeedDisplayData.kt │ │ │ │ └── TrafficData.kt │ │ │ ├── bg/ │ │ │ │ ├── AbstractInstance.kt │ │ │ │ ├── BaseService.kt │ │ │ │ ├── Executable.kt │ │ │ │ ├── GuardedProcessPool.kt │ │ │ │ ├── ProxyService.kt │ │ │ │ ├── SagerConnection.kt │ │ │ │ ├── ServiceNotification.kt │ │ │ │ ├── SubscriptionUpdater.kt │ │ │ │ ├── TileService.kt │ │ │ │ ├── VpnService.kt │ │ │ │ └── proto/ │ │ │ │ ├── BoxInstance.kt │ │ │ │ ├── ProxyInstance.kt │ │ │ │ ├── TestInstance.kt │ │ │ │ ├── TrafficLooper.kt │ │ │ │ ├── TrafficUpdater.kt │ │ │ │ └── UrlTest.kt │ │ │ ├── database/ │ │ │ │ ├── DataStore.kt │ │ │ │ ├── GroupManager.kt │ │ │ │ ├── ParcelizeBridge.java │ │ │ │ ├── ProfileManager.kt │ │ │ │ ├── ProxyEntity.kt │ │ │ │ ├── ProxyGroup.kt │ │ │ │ ├── RuleEntity.kt │ │ │ │ ├── SagerDatabase.kt │ │ │ │ ├── StringCollectionConverter.kt │ │ │ │ ├── SubscriptionBean.java │ │ │ │ └── preference/ │ │ │ │ ├── EditTextPreferenceModifiers.kt │ │ │ │ ├── KeyValuePair.kt │ │ │ │ ├── OnPreferenceDataStoreChangeListener.kt │ │ │ │ ├── PublicDatabase.kt │ │ │ │ └── RoomPreferenceDataStore.kt │ │ │ ├── fmt/ │ │ │ │ ├── AbstractBean.java │ │ │ │ ├── ConfigBuilder.kt │ │ │ │ ├── KryoConverters.java │ │ │ │ ├── PluginEntry.kt │ │ │ │ ├── Serializable.kt │ │ │ │ ├── TypeMap.kt │ │ │ │ ├── UniversalFmt.kt │ │ │ │ ├── gson/ │ │ │ │ │ └── GsonConverters.java │ │ │ │ ├── http/ │ │ │ │ │ ├── HttpBean.java │ │ │ │ │ └── HttpFmt.kt │ │ │ │ ├── hysteria/ │ │ │ │ │ ├── HysteriaBean.java │ │ │ │ │ └── HysteriaFmt.kt │ │ │ │ ├── internal/ │ │ │ │ │ ├── ChainBean.java │ │ │ │ │ └── InternalBean.java │ │ │ │ ├── mieru/ │ │ │ │ │ ├── MieruBean.java │ │ │ │ │ └── MieruFmt.kt │ │ │ │ ├── naive/ │ │ │ │ │ ├── NaiveBean.java │ │ │ │ │ └── NaiveFmt.kt │ │ │ │ ├── shadowsocks/ │ │ │ │ │ ├── ShadowsocksBean.java │ │ │ │ │ └── ShadowsocksFmt.kt │ │ │ │ ├── socks/ │ │ │ │ │ ├── SOCKSBean.java │ │ │ │ │ └── SOCKSFmt.kt │ │ │ │ ├── ssh/ │ │ │ │ │ ├── SSHBean.java │ │ │ │ │ └── SSHFmt.kt │ │ │ │ ├── trojan/ │ │ │ │ │ ├── TrojanBean.java │ │ │ │ │ └── TrojanFmt.kt │ │ │ │ ├── trojan_go/ │ │ │ │ │ ├── TrojanGoBean.java │ │ │ │ │ └── TrojanGoFmt.kt │ │ │ │ ├── tuic/ │ │ │ │ │ ├── TuicBean.java │ │ │ │ │ └── TuicFmt.kt │ │ │ │ ├── v2ray/ │ │ │ │ │ ├── StandardV2RayBean.java │ │ │ │ │ ├── V2RayFmt.kt │ │ │ │ │ └── VMessBean.java │ │ │ │ └── wireguard/ │ │ │ │ ├── WireGuardBean.java │ │ │ │ └── WireGuardFmt.kt │ │ │ ├── group/ │ │ │ │ ├── GroupInterfaceAdapter.kt │ │ │ │ ├── GroupUpdater.kt │ │ │ │ └── RawUpdater.kt │ │ │ ├── ktx/ │ │ │ │ ├── Asyncs.kt │ │ │ │ ├── Browsers.kt │ │ │ │ ├── Dialogs.kt │ │ │ │ ├── Dimens.kt │ │ │ │ ├── Formats.kt │ │ │ │ ├── Kryos.kt │ │ │ │ ├── Layouts.kt │ │ │ │ ├── Logs.kt │ │ │ │ ├── Nets.kt │ │ │ │ ├── Preferences.kt │ │ │ │ └── Utils.kt │ │ │ ├── plugin/ │ │ │ │ └── PluginManager.kt │ │ │ ├── ui/ │ │ │ │ ├── AboutFragment.kt │ │ │ │ ├── AppListActivity.kt │ │ │ │ ├── AppManagerActivity.kt │ │ │ │ ├── AssetsActivity.kt │ │ │ │ ├── BackupFragment.kt │ │ │ │ ├── BlankActivity.kt │ │ │ │ ├── ConfigurationFragment.kt │ │ │ │ ├── GroupFragment.kt │ │ │ │ ├── GroupSettingsActivity.kt │ │ │ │ ├── LogcatFragment.kt │ │ │ │ ├── MainActivity.kt │ │ │ │ ├── NamedFragment.kt │ │ │ │ ├── NetworkFragment.kt │ │ │ │ ├── ProfileSelectActivity.kt │ │ │ │ ├── QuickDisableShortcut.kt │ │ │ │ ├── QuickEnableShortcut.kt │ │ │ │ ├── RouteFragment.kt │ │ │ │ ├── RouteSettingsActivity.kt │ │ │ │ ├── ScannerActivity.kt │ │ │ │ ├── SettingsFragment.kt │ │ │ │ ├── SettingsPreferenceFragment.kt │ │ │ │ ├── StunActivity.kt │ │ │ │ ├── SwitchActivity.kt │ │ │ │ ├── ThemedActivity.kt │ │ │ │ ├── ToolbarFragment.kt │ │ │ │ ├── ToolsFragment.kt │ │ │ │ ├── VpnRequestActivity.kt │ │ │ │ ├── WebviewFragment.kt │ │ │ │ └── profile/ │ │ │ │ ├── ChainSettingsActivity.kt │ │ │ │ ├── ConfigEditActivity.kt │ │ │ │ ├── HttpSettingsActivity.kt │ │ │ │ ├── HysteriaSettingsActivity.kt │ │ │ │ ├── MieruSettingsActivity.kt │ │ │ │ ├── NaiveSettingsActivity.kt │ │ │ │ ├── ProfileSettingsActivity.kt │ │ │ │ ├── SSHSettingsActivity.kt │ │ │ │ ├── ShadowsocksSettingsActivity.kt │ │ │ │ ├── SocksSettingsActivity.kt │ │ │ │ ├── StandardV2RaySettingsActivity.kt │ │ │ │ ├── TrojanGoSettingsActivity.kt │ │ │ │ ├── TrojanSettingsActivity.kt │ │ │ │ ├── TuicSettingsActivity.kt │ │ │ │ ├── VMessSettingsActivity.kt │ │ │ │ └── WireGuardSettingsActivity.kt │ │ │ ├── utils/ │ │ │ │ ├── Commandline.kt │ │ │ │ ├── CrashHandler.kt │ │ │ │ ├── DefaultNetworkListener.kt │ │ │ │ ├── PackageCache.kt │ │ │ │ ├── Subnet.kt │ │ │ │ └── Theme.kt │ │ │ └── widget/ │ │ │ ├── AppListPreference.kt │ │ │ ├── AutoCollapseTextView.kt │ │ │ ├── FabProgressBehavior.kt │ │ │ ├── GroupPreference.kt │ │ │ ├── LinkOrContentPreference.kt │ │ │ ├── OutboundPreference.kt │ │ │ ├── QRCodeDialog.kt │ │ │ ├── ServiceButton.kt │ │ │ ├── StatsBar.kt │ │ │ ├── UndoSnackbarManager.kt │ │ │ ├── UserAgentPreference.kt │ │ │ └── WindowInsetsListeners.kt │ │ └── moe/ │ │ └── matsuri/ │ │ └── nb4a/ │ │ ├── NativeInterface.kt │ │ ├── Protocols.kt │ │ ├── SingBoxOptions.java │ │ ├── SingBoxOptionsUtil.kt │ │ ├── TempDatabase.kt │ │ ├── net/ │ │ │ └── LocalResolverImpl.kt │ │ ├── plugin/ │ │ │ └── Plugins.kt │ │ ├── proxy/ │ │ │ ├── PreferenceBinding.kt │ │ │ ├── PreferenceBindingManager.kt │ │ │ ├── anytls/ │ │ │ │ ├── AnyTLSBean.java │ │ │ │ ├── AnyTLSFmt.kt │ │ │ │ └── AnyTLSSettingsActivity.kt │ │ │ ├── config/ │ │ │ │ ├── ConfigBean.java │ │ │ │ └── ConfigSettingActivity.kt │ │ │ ├── neko/ │ │ │ │ └── NekoBean.java │ │ │ └── shadowtls/ │ │ │ ├── ShadowTLSBean.java │ │ │ ├── ShadowTLSFmt.kt │ │ │ └── ShadowTLSSettingsActivity.kt │ │ ├── ui/ │ │ │ ├── ColorPickerPreference.kt │ │ │ ├── ConnectionTestNotification.kt │ │ │ ├── Dialogs.kt │ │ │ ├── EditConfigPreference.kt │ │ │ ├── ExtendedKeyboard.kt │ │ │ ├── LongClickListPreference.kt │ │ │ ├── LongClickMenuPreference.kt │ │ │ ├── LongClickSwitchPreference.kt │ │ │ ├── MTUPreference.kt │ │ │ ├── SimpleMenuPreference.kt │ │ │ └── UrlTestPreference.kt │ │ └── utils/ │ │ ├── JavaUtil.java │ │ ├── KotlinUtil.kt │ │ ├── NGUtil.kt │ │ ├── SendLog.kt │ │ ├── Util.kt │ │ └── WebViewUtil.kt │ └── res/ │ ├── color/ │ │ ├── chip_background.xml │ │ ├── chip_ripple_color.xml │ │ ├── chip_text_color.xml │ │ ├── navigation_icon.xml │ │ └── navigation_item.xml │ ├── drawable/ │ │ ├── baseline_arrow_back_24.xml │ │ ├── baseline_construction_24.xml │ │ ├── baseline_delete_sweep_24.xml │ │ ├── baseline_developer_board_24.xml │ │ ├── baseline_flight_takeoff_24.xml │ │ ├── baseline_keyboard_tab_24.xml │ │ ├── baseline_public_24.xml │ │ ├── baseline_redo_24.xml │ │ ├── baseline_save_24.xml │ │ ├── baseline_send_24.xml │ │ ├── baseline_translate_24.xml │ │ ├── baseline_undo_24.xml │ │ ├── baseline_widgets_24.xml │ │ ├── baseline_wrap_text_24.xml │ │ ├── ic_action_copyright.xml │ │ ├── ic_action_delete.xml │ │ ├── ic_action_description.xml │ │ ├── ic_action_dns.xml │ │ ├── ic_action_done.xml │ │ ├── ic_action_lock.xml │ │ ├── ic_action_lock_open.xml │ │ ├── ic_action_note_add.xml │ │ ├── ic_action_settings.xml │ │ ├── ic_app_shortcut_background.xml │ │ ├── ic_av_playlist_add.xml │ │ ├── ic_baseline_add_road_24.xml │ │ ├── ic_baseline_airplanemode_active_24.xml │ │ ├── ic_baseline_android_24.xml │ │ ├── ic_baseline_bug_report_24.xml │ │ ├── ic_baseline_camera_24.xml │ │ ├── ic_baseline_card_giftcard_24.xml │ │ ├── ic_baseline_cast_connected_24.xml │ │ ├── ic_baseline_center_focus_weak_24.xml │ │ ├── ic_baseline_color_lens_24.xml │ │ ├── ic_baseline_compare_arrows_24.xml │ │ ├── ic_baseline_domain_24.xml │ │ ├── ic_baseline_download_24.xml │ │ ├── ic_baseline_emoji_emotions_24.xml │ │ ├── ic_baseline_fast_forward_24.xml │ │ ├── ic_baseline_fiber_manual_record_24.xml │ │ ├── ic_baseline_fingerprint_24.xml │ │ ├── ic_baseline_flip_camera_android_24.xml │ │ ├── ic_baseline_format_align_left_24.xml │ │ ├── ic_baseline_grid_3x3_24.xml │ │ ├── ic_baseline_home_24.xml │ │ ├── ic_baseline_http_24.xml │ │ ├── ic_baseline_https_24.xml │ │ ├── ic_baseline_import_contacts_24.xml │ │ ├── ic_baseline_info_24.xml │ │ ├── ic_baseline_layers_24.xml │ │ ├── ic_baseline_legend_toggle_24.xml │ │ ├── ic_baseline_link_24.xml │ │ ├── ic_baseline_local_bar_24.xml │ │ ├── ic_baseline_location_on_24.xml │ │ ├── ic_baseline_lock_24.xml │ │ ├── ic_baseline_low_priority_24.xml │ │ ├── ic_baseline_manage_search_24.xml │ │ ├── ic_baseline_more_vert_24.xml │ │ ├── ic_baseline_multiline_chart_24.xml │ │ ├── ic_baseline_multiple_stop_24.xml │ │ ├── ic_baseline_nat_24.xml │ │ ├── ic_baseline_nfc_24.xml │ │ ├── ic_baseline_no_encryption_gmailerrorred_24.xml │ │ ├── ic_baseline_person_24.xml │ │ ├── ic_baseline_push_pin_24.xml │ │ ├── ic_baseline_refresh_24.xml │ │ ├── ic_baseline_rule_folder_24.xml │ │ ├── ic_baseline_running_with_errors_24.xml │ │ ├── ic_baseline_sanitizer_24.xml │ │ ├── ic_baseline_security_24.xml │ │ ├── ic_baseline_shuffle_24.xml │ │ ├── ic_baseline_shutter_speed_24.xml │ │ ├── ic_baseline_speed_24.xml │ │ ├── ic_baseline_stream_24.xml │ │ ├── ic_baseline_texture_24.xml │ │ ├── ic_baseline_timelapse_24.xml │ │ ├── ic_baseline_transform_24.xml │ │ ├── ic_baseline_transgender_24.xml │ │ ├── ic_baseline_update_24.xml │ │ ├── ic_baseline_view_list_24.xml │ │ ├── ic_baseline_vpn_key_24.xml │ │ ├── ic_baseline_warning_24.xml │ │ ├── ic_baseline_wb_sunny_24.xml │ │ ├── ic_communication_phonelink_ring.xml │ │ ├── ic_device_data_usage.xml │ │ ├── ic_device_developer_mode.xml │ │ ├── ic_file_cloud_queue.xml │ │ ├── ic_file_file_upload.xml │ │ ├── ic_hardware_router.xml │ │ ├── ic_image_camera_alt.xml │ │ ├── ic_image_edit.xml │ │ ├── ic_image_looks_6.xml │ │ ├── ic_image_photo.xml │ │ ├── ic_maps_360.xml │ │ ├── ic_maps_directions.xml │ │ ├── ic_maps_directions_boat.xml │ │ ├── ic_navigation_apps.xml │ │ ├── ic_navigation_close.xml │ │ ├── ic_navigation_menu.xml │ │ ├── ic_notification_enhanced_encryption.xml │ │ ├── ic_qu_camera_launcher.xml │ │ ├── ic_qu_shadowsocks_foreground.xml │ │ ├── ic_qu_shadowsocks_launcher.xml │ │ ├── ic_service_active.xml │ │ ├── ic_service_busy.xml │ │ ├── ic_service_connected.xml │ │ ├── ic_service_connecting.xml │ │ ├── ic_service_idle.xml │ │ ├── ic_service_stopped.xml │ │ ├── ic_service_stopping.xml │ │ ├── ic_settings_password.xml │ │ ├── ic_social_emoji_symbols.xml │ │ ├── ic_social_share.xml │ │ └── terminal_scroll_shape.xml │ ├── drawable-v26/ │ │ ├── ic_qu_camera_launcher.xml │ │ └── ic_qu_shadowsocks_launcher.xml │ ├── layout/ │ │ ├── item_keyboard_key.xml │ │ ├── layout_about.xml │ │ ├── layout_add_entity.xml │ │ ├── layout_app_list.xml │ │ ├── layout_app_placeholder.xml │ │ ├── layout_appbar.xml │ │ ├── layout_apps.xml │ │ ├── layout_apps_item.xml │ │ ├── layout_asset_item.xml │ │ ├── layout_assets.xml │ │ ├── layout_backup.xml │ │ ├── layout_chain_settings.xml │ │ ├── layout_config_settings.xml │ │ ├── layout_debug.xml │ │ ├── layout_edit_config.xml │ │ ├── layout_edit_group.xml │ │ ├── layout_empty.xml │ │ ├── layout_empty_route.xml │ │ ├── layout_group.xml │ │ ├── layout_group_item.xml │ │ ├── layout_group_list.xml │ │ ├── layout_icon_list_item_2.xml │ │ ├── layout_import.xml │ │ ├── layout_loading.xml │ │ ├── layout_logcat.xml │ │ ├── layout_loglevel_help.xml │ │ ├── layout_main.xml │ │ ├── layout_mtu_help.xml │ │ ├── layout_network.xml │ │ ├── layout_password_dialog.xml │ │ ├── layout_profile.xml │ │ ├── layout_profile_list.xml │ │ ├── layout_progress.xml │ │ ├── layout_progress_list.xml │ │ ├── layout_route.xml │ │ ├── layout_route_item.xml │ │ ├── layout_scanner.xml │ │ ├── layout_settings_activity.xml │ │ ├── layout_stun.xml │ │ ├── layout_tools.xml │ │ ├── layout_urltest_preference_dialog.xml │ │ ├── layout_webview.xml │ │ └── simple_menu_dropdown_item.xml │ ├── menu/ │ │ ├── add_group_menu.xml │ │ ├── add_profile_menu.xml │ │ ├── add_route_menu.xml │ │ ├── app_list_menu.xml │ │ ├── group_action_menu.xml │ │ ├── import_asset_menu.xml │ │ ├── logcat_menu.xml │ │ ├── main_drawer_menu.xml │ │ ├── per_app_proxy_menu.xml │ │ ├── profile_apply_menu.xml │ │ ├── profile_config_menu.xml │ │ ├── profile_share_menu.xml │ │ ├── scanner_menu.xml │ │ ├── traffic_item_menu.xml │ │ ├── traffic_menu.xml │ │ └── yacd_menu.xml │ ├── mipmap-anydpi-v26/ │ │ └── ic_launcher.xml │ ├── raw/ │ │ ├── insecure.txt │ │ ├── not_encrypted.txt │ │ ├── shadowsocks_stream_cipher.txt │ │ └── vmess_md5_auth.txt │ ├── raw-zh-rCN/ │ │ ├── insecure.txt │ │ ├── not_encrypted.txt │ │ ├── shadowsocks_stream_cipher.txt │ │ └── vmess_md5_auth.txt │ ├── resources.properties │ ├── values/ │ │ ├── arrays.xml │ │ ├── attrs.xml │ │ ├── colors.xml │ │ ├── dimens.xml │ │ ├── ic_launcher_background.xml │ │ ├── strings.xml │ │ └── themes.xml │ ├── values-ar/ │ │ └── strings.xml │ ├── values-be/ │ │ └── strings.xml │ ├── values-de/ │ │ └── strings.xml │ ├── values-es/ │ │ └── strings.xml │ ├── values-fa/ │ │ └── strings.xml │ ├── values-fr/ │ │ └── strings.xml │ ├── values-in/ │ │ └── strings.xml │ ├── values-it/ │ │ └── strings.xml │ ├── values-ja/ │ │ └── strings.xml │ ├── values-ko/ │ │ └── strings.xml │ ├── values-nb-rNO/ │ │ └── strings.xml │ ├── values-night/ │ │ └── colors.xml │ ├── values-nl/ │ │ └── strings.xml │ ├── values-pt-rBR/ │ │ └── strings.xml │ ├── values-ru/ │ │ └── strings.xml │ ├── values-tr/ │ │ └── strings.xml │ ├── values-uk/ │ │ └── strings.xml │ ├── values-zh-rCN/ │ │ └── strings.xml │ ├── values-zh-rHK/ │ │ └── strings.xml │ ├── values-zh-rTW/ │ │ └── strings.xml │ └── xml/ │ ├── anytls_preferences.xml │ ├── backup_descriptor.xml │ ├── backup_rules.xml │ ├── balancer_preferences.xml │ ├── cache_paths.xml │ ├── config_preferences.xml │ ├── global_preferences.xml │ ├── group_preferences.xml │ ├── hysteria_preferences.xml │ ├── mieru_preferences.xml │ ├── naive_preferences.xml │ ├── name_preferences.xml │ ├── neko_preferences.xml │ ├── network_security_config.xml │ ├── route_preferences.xml │ ├── shadowsocks_preferences.xml │ ├── shadowtls_preferences.xml │ ├── shortcuts.xml │ ├── socks_preferences.xml │ ├── ssh_preferences.xml │ ├── standard_v2ray_preferences.xml │ ├── trojan_go_preferences.xml │ ├── tuic_preferences.xml │ └── wireguard_preferences.xml ├── build.gradle.kts ├── buildScript/ │ ├── copyLocal.sh │ ├── fdroid/ │ │ └── prebuild.sh │ ├── init/ │ │ ├── action/ │ │ │ └── gradle.sh │ │ ├── env.sh │ │ └── env_ndk.sh │ └── lib/ │ ├── assets.sh │ ├── core/ │ │ ├── build.sh │ │ ├── get_source.sh │ │ ├── get_source_env.sh │ │ └── init.sh │ └── core.sh ├── buildSrc/ │ ├── build.gradle.kts │ └── src/ │ └── main/ │ └── kotlin/ │ └── Helpers.kt ├── gradle/ │ └── wrapper/ │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradle.properties ├── gradlew ├── gradlew.bat ├── libcore/ │ ├── .gitignore │ ├── LICENSE │ ├── assets.go │ ├── assets_android.go │ ├── assets_other.go │ ├── box.go │ ├── box_include.go │ ├── build.sh │ ├── certs.go │ ├── crypto.go │ ├── device/ │ │ ├── debug.go │ │ └── device.go │ ├── dns_android.go │ ├── dns_box.go │ ├── ech/ │ │ └── ech.go │ ├── fix.go │ ├── geoip.go │ ├── geosite.go │ ├── go.mod │ ├── go.sum │ ├── http.go │ ├── init.sh │ ├── interface_monitor.go │ ├── io.go │ ├── nb4a.go │ ├── platform_box.go │ ├── platform_java.go │ ├── procfs/ │ │ └── procfs.go │ ├── stun/ │ │ ├── README │ │ ├── attribute.go │ │ ├── client.go │ │ ├── const.go │ │ ├── discover.go │ │ ├── doc.go │ │ ├── host.go │ │ ├── log.go │ │ ├── net.go │ │ ├── packet.go │ │ ├── response.go │ │ ├── tests.go │ │ └── utils.go │ └── stun.go ├── lint.xml ├── nb4a.properties ├── release.keystore ├── repositories.gradle.kts ├── run └── settings.gradle.kts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/ISSUE_TEMPLATE/bug-report-en.md ================================================ --- name: 'Bug Report' about: 'Please troubleshoot server-side issues and upgrade to the latest client before raising a question.' title: 'BUG: ' labels: '' assignees: '' --- ## Describe the problem Expected behavior: Actual behavior: ## How to reproduce Provide helpful screenshots, videos, text descriptions, subscription links, etc. ## log If you have logs, please upload them. Please see the detailed steps for exporting logs in the documentation. ================================================ FILE: .github/ISSUE_TEMPLATE/bug-report-zh_cn.md ================================================ --- name: '问题反馈' about: '在提出问题前请先自行排除服务器端问题和升级到最新客户端。' title: 'BUG: ' labels: '' assignees: '' --- ## 描述问题 预期行为: 实际行为: ## 如何复现 提供有帮助的截图,录像,文字说明,订阅链接等。 ## 日志 如果有日志,请上传。请在文档内查看导出日志的详细步骤。 ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request-en.md ================================================ --- name: 'Feature Request' about: 'Make suggestions for new features of the software' title: '' labels: '' assignees: '' --- ## Description suggestions ## Necessity of recommendations ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request-zh_cn.md ================================================ --- name: '功能请求' about: '对软件的新功能提出建议。' title: '' labels: '' assignees: '' --- ## 描述建议 ## 建议的必要性 ================================================ FILE: .github/workflows/preview.yml ================================================ name: Preview Build on: workflow_dispatch: inputs: jobs: libcore: name: Native Build (LibCore) runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Golang Status run: find buildScript libcore/*.sh | xargs cat | sha1sum > golang_status - name: Libcore Status run: git ls-files libcore | xargs cat | sha1sum > libcore_status - name: LibCore Cache id: cache uses: actions/cache@v4 with: path: | app/libs/libcore.aar key: ${{ hashFiles('.github/workflows/*', 'golang_status', 'libcore_status') }} - name: Install Golang if: steps.cache.outputs.cache-hit != 'true' uses: actions/setup-go@v5 with: go-version: ^1.25 - name: Native Build if: steps.cache.outputs.cache-hit != 'true' run: ./run lib core build: name: Build OSS APK runs-on: ubuntu-latest needs: - libcore steps: - name: Checkout uses: actions/checkout@v4 - name: Golang Status run: find buildScript libcore/*.sh | xargs cat | sha1sum > golang_status - name: Libcore Status run: git ls-files libcore | xargs cat | sha1sum > libcore_status - name: LibCore Cache uses: actions/cache@v4 with: path: | app/libs/libcore.aar key: ${{ hashFiles('.github/workflows/*', 'golang_status', 'libcore_status') }} - name: Gradle cache uses: actions/cache@v4 with: path: ~/.gradle key: gradle-oss-${{ hashFiles('**/*.gradle.kts') }} - name: Gradle Build env: BUILD_PLUGIN: none run: | echo "sdk.dir=${ANDROID_HOME}" > local.properties echo "ndk.dir=${ANDROID_HOME}/ndk/25.0.8775105" >> local.properties export LOCAL_PROPERTIES="${{ secrets.LOCAL_PROPERTIES }}" ./run init action gradle ./gradlew app:assemblePreviewRelease APK=$(find app/build/outputs/apk -name '*arm64-v8a*.apk') APK=$(dirname $APK) echo "APK=$APK" >> $GITHUB_ENV - uses: actions/upload-artifact@v4 with: name: APKs path: ${{ env.APK }} ================================================ FILE: .github/workflows/release.yml ================================================ name: Release Build on: workflow_dispatch: inputs: tag: description: "Release Tag" required: true publish: description: "Publish: If want ignore" required: false play: description: "Play: If want ignore" required: false jobs: libcore: name: Native Build (LibCore) runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Golang Status run: find buildScript libcore/*.sh | xargs cat | sha1sum > golang_status - name: Libcore Status run: git ls-files libcore | xargs cat | sha1sum > libcore_status - name: LibCore Cache id: cache uses: actions/cache@v4 with: path: | app/libs/libcore.aar key: ${{ hashFiles('.github/workflows/*', 'golang_status', 'libcore_status') }} - name: Install Golang if: steps.cache.outputs.cache-hit != 'true' uses: actions/setup-go@v5 with: go-version: ^1.25 - name: Native Build if: steps.cache.outputs.cache-hit != 'true' run: ./run lib core build: name: Build OSS APK runs-on: ubuntu-latest needs: - libcore steps: - name: Checkout uses: actions/checkout@v4 - name: Golang Status run: find buildScript libcore/*.sh | xargs cat | sha1sum > golang_status - name: Libcore Status run: git ls-files libcore | xargs cat | sha1sum > libcore_status - name: LibCore Cache uses: actions/cache@v4 with: path: | app/libs/libcore.aar key: ${{ hashFiles('.github/workflows/*', 'golang_status', 'libcore_status') }} - name: Gradle cache uses: actions/cache@v4 with: path: ~/.gradle key: gradle-oss-${{ hashFiles('**/*.gradle.kts') }} - name: Gradle Build env: BUILD_PLUGIN: none run: | echo "sdk.dir=${ANDROID_HOME}" > local.properties echo "ndk.dir=${ANDROID_HOME}/ndk/25.0.8775105" >> local.properties export LOCAL_PROPERTIES="${{ secrets.LOCAL_PROPERTIES }}" ./run init action gradle ./gradlew app:assembleOssRelease APK=$(find app/build/outputs/apk -name '*arm64-v8a*.apk') APK=$(dirname $APK) echo "APK=$APK" >> $GITHUB_ENV - uses: actions/upload-artifact@v4 with: name: APKs path: ${{ env.APK }} publish: name: Publish Release if: github.event.inputs.publish != 'y' runs-on: ubuntu-latest needs: build steps: - name: Checkout uses: actions/checkout@v4 - name: Donwload Artifacts uses: actions/download-artifact@v4 with: name: APKs path: artifacts - name: Release run: | wget -O ghr.tar.gz https://github.com/tcnksm/ghr/releases/download/v0.13.0/ghr_v0.13.0_linux_amd64.tar.gz tar -xvf ghr.tar.gz mv ghr*linux_amd64/ghr . mkdir apks find artifacts -name "*.apk" -exec cp {} apks \; ./ghr -delete -t "${{ github.token }}" -n "${{ github.event.inputs.tag }}" "${{ github.event.inputs.tag }}" apks play: name: Build Play Bundle if: github.event.inputs.play != 'y' runs-on: ubuntu-latest needs: - libcore steps: - name: Checkout uses: actions/checkout@v4 - name: Golang Status run: find buildScript libcore/*.sh | xargs cat | sha1sum > golang_status - name: Libcore Status run: git ls-files libcore | xargs cat | sha1sum > libcore_status - name: LibCore Cache uses: actions/cache@v4 with: path: | app/libs/libcore.aar key: ${{ hashFiles('.github/workflows/*', 'golang_status', 'libcore_status') }} - name: Gradle cache uses: actions/cache@v4 with: path: ~/.gradle key: gradle-play-${{ hashFiles('**/*.gradle.kts') }} - name: Checkout Library run: | git submodule update --init 'app/*' - name: Gradle Build run: | echo "sdk.dir=${ANDROID_HOME}" > local.properties echo "ndk.dir=${ANDROID_HOME}/ndk/25.0.8775105" >> local.properties export LOCAL_PROPERTIES="${{ secrets.LOCAL_PROPERTIES }}" ./run init action gradle ./gradlew bundlePlayRelease - uses: actions/upload-artifact@v3 with: name: AAB path: app/build/outputs/bundle/playRelease/app-play-release.aab ================================================ FILE: .gitignore ================================================ *.iml .gradle .idea .vscode .DS_Store build/ /captures .externalNativeBuild .cxx local.properties /app/libs/ /app/src/main/assets/sing-box /service_account_credentials.json jniLibs/ /library/libcore_build/ .idea/deploymentTargetDropDown.xml /nkmr # submodules /external ================================================ FILE: AUTHORS ================================================ SagerNet was originally created in late 2021, by nekohasekai . Here is an inevitably incomplete list of MUCH-APPRECIATED CONTRIBUTORS -- people who have submitted patches, fixed bugs, added translations, and generally made SagerNet that much better: https://github.com/SagerNet/SagerNet/graphs/contributors ================================================ FILE: LICENSE ================================================ Copyright (C) 2021 by nekohasekai This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . ================================================ FILE: README.md ================================================ # NekoBox for Android [![API](https://img.shields.io/badge/API-21%2B-brightgreen.svg?style=flat)](https://android-arsenal.com/api?level=21) [![Releases](https://img.shields.io/github/v/release/MatsuriDayo/NekoBoxForAndroid)](https://github.com/MatsuriDayo/NekoBoxForAndroid/releases) [![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-orange.svg)](https://www.gnu.org/licenses/gpl-3.0) sing-box / universal proxy toolchain for Android. 一款使用 sing-box 的 Android 通用代理软件. ## 下载 / Downloads [![GitHub All Releases](https://img.shields.io/github/downloads/Matsuridayo/NekoBoxForAndroid/total?label=downloads-total&logo=github&style=flat-square)](https://github.com/Matsuridayo/NekoBoxForAndroid/releases) [GitHub Releases 下载](https://github.com/Matsuridayo/NekoBoxForAndroid/releases) **Google Play 版本自 2024 年 5 月起已被第三方控制,为非开源版本,请不要下载。** **The Google Play version has been controlled by a third party since May 2024 and is a non-open source version. Please do not download it.** ## 更新日志 & Telegram 发布频道 / Changelog & Telegram Channel https://t.me/Matsuridayo ## 项目主页 & 文档 / Homepage & Documents https://matsuridayo.github.io ## 支持的代理协议 / Supported Proxy Protocols * SOCKS (4/4a/5) * HTTP(S) * SSH * Shadowsocks * VMess * Trojan * VLESS * AnyTLS * ShadowTLS * TUIC * Hysteria 1/2 * WireGuard * Trojan-Go (trojan-go-plugin) * NaïveProxy (naive-plugin) * Mieru (mieru-plugin) 请到[这里](https://matsuridayo.github.io/nb4a-plugin/)下载插件以获得完整的代理支持. Please visit [here](https://matsuridayo.github.io/nb4a-plugin/) to download plugins for full proxy supports. ## 支持的订阅格式 / Supported Subscription Format * 一些广泛使用的格式 (如 Shadowsocks, ClashMeta 和 v2rayN) * sing-box 出站 仅支持解析出站,即节点。分流规则等信息会被忽略。 * Some widely used formats (like Shadowsocks, ClashMeta and v2rayN) * sing-box outbound Only resolving outbound, i.e. nodes, is supported. Information such as diversion rules are ignored. ## 捐助 / Donate
如果这个项目对您有帮助, 可以通过捐赠的方式帮助我们维持这个项目. 捐赠满等额 50 USD 可以在「[捐赠榜](https://mtrdnt.pages.dev/donation_list)」显示头像, 如果您未被添加到这里, 欢迎联系我们补充. Donations of 50 USD or more can display your avatar on the [Donation List](https://mtrdnt.pages.dev/donation_list). If you are not added here, please contact us to add it. USDT TRC20 `TRhnA7SXE5Sap5gSG3ijxRmdYFiD4KRhPs` XMR `49bwESYQjoRL3xmvTcjZKHEKaiGywjLYVQJMUv79bXonGiyDCs8AzE3KiGW2ytTybBCpWJUvov8SjZZEGg66a4e59GXa6k5`
## Credits Core: - [SagerNet/sing-box](https://github.com/SagerNet/sing-box) Android GUI: - [shadowsocks/shadowsocks-android](https://github.com/shadowsocks/shadowsocks-android) - [SagerNet/SagerNet](https://github.com/SagerNet/SagerNet) Web Dashboard: - [Yacd-meta](https://github.com/MetaCubeX/Yacd-meta) ================================================ FILE: app/.gitignore ================================================ /build ================================================ FILE: app/build.gradle.kts ================================================ @file:Suppress("UnstableApiUsage") plugins { id("com.android.application") id("kotlin-android") id("com.google.devtools.ksp") id("kotlin-parcelize") } setupApp() android { compileOptions { isCoreLibraryDesugaringEnabled = true } ksp { arg("room.incremental", "true") arg("room.schemaLocation", "$projectDir/schemas") } bundle { language { enableSplit = false } } buildFeatures { buildConfig = true viewBinding = true aidl = true } namespace = "io.nekohasekai.sagernet" packaging { jniLibs { useLegacyPackaging = true } } androidResources { generateLocaleConfig = true } } dependencies { implementation(fileTree("libs")) implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4") implementation("androidx.core:core-ktx:1.9.0") implementation("androidx.recyclerview:recyclerview:1.3.0") implementation("androidx.activity:activity-ktx:1.10.1") implementation("androidx.fragment:fragment-ktx:1.5.6") implementation("androidx.browser:browser:1.5.0") implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0") implementation("androidx.constraintlayout:constraintlayout:2.1.4") implementation("androidx.navigation:navigation-fragment-ktx:2.5.3") implementation("androidx.navigation:navigation-ui-ktx:2.5.3") implementation("androidx.preference:preference-ktx:1.2.0") implementation("androidx.appcompat:appcompat:1.6.1") implementation("androidx.work:work-runtime-ktx:2.8.1") implementation("androidx.work:work-multiprocess:2.8.1") implementation("com.google.android.material:material:1.8.0") implementation("com.google.code.gson:gson:2.9.0") implementation("com.github.jenly1314:zxing-lite:2.1.1") implementation("com.blacksquircle.ui:editorkit:2.6.0") implementation("com.blacksquircle.ui:language-base:2.6.0") implementation("com.blacksquircle.ui:language-json:2.6.0") implementation("com.squareup.okhttp3:okhttp:5.0.0-alpha.3") implementation("org.yaml:snakeyaml:1.30") implementation("com.github.daniel-stoneuk:material-about-library:3.2.0-rc01") implementation("com.jakewharton:process-phoenix:2.1.2") implementation("com.esotericsoftware:kryo:5.2.1") implementation("com.google.guava:guava:31.0.1-android") implementation("org.ini4j:ini4j:0.5.4") implementation("com.simplecityapps:recyclerview-fastscroll:2.0.1") { exclude(group = "androidx.recyclerview") exclude(group = "androidx.appcompat") } implementation("androidx.room:room-runtime:2.6.1") ksp("androidx.room:room-compiler:2.6.1") implementation("androidx.room:room-ktx:2.6.1") implementation("com.github.MatrixDev.Roomigrant:RoomigrantLib:0.3.4") ksp("com.github.MatrixDev.Roomigrant:RoomigrantCompiler:0.3.4") coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.3") } ================================================ FILE: app/executableSo/.gitignore ================================================ *.so ================================================ FILE: app/proguard-rules.pro ================================================ -repackageclasses '' -allowaccessmodification -keep class io.nekohasekai.sagernet.** { *;} -keep class moe.matsuri.nb4a.** { *;} # Clean Kotlin -assumenosideeffects class kotlin.jvm.internal.Intrinsics { static void checkParameterIsNotNull(java.lang.Object, java.lang.String); static void checkExpressionValueIsNotNull(java.lang.Object, java.lang.String); static void checkNotNullExpressionValue(java.lang.Object, java.lang.String); static void checkReturnedValueIsNotNull(java.lang.Object, java.lang.String, java.lang.String); static void checkReturnedValueIsNotNull(java.lang.Object, java.lang.String); static void checkFieldIsNotNull(java.lang.Object, java.lang.String, java.lang.String); static void checkFieldIsNotNull(java.lang.Object, java.lang.String); static void checkNotNull(java.lang.Object); static void checkNotNull(java.lang.Object, java.lang.String); static void checkNotNullParameter(java.lang.Object, java.lang.String); static void throwUninitializedPropertyAccessException(java.lang.String); } # ini4j -keep public class org.ini4j.spi.** { (); } # SnakeYaml -keep class org.yaml.snakeyaml.** { *; } -dontobfuscate -keepattributes SourceFile -dontwarn java.beans.BeanInfo -dontwarn java.beans.FeatureDescriptor -dontwarn java.beans.IntrospectionException -dontwarn java.beans.Introspector -dontwarn java.beans.PropertyDescriptor -dontwarn java.beans.Transient -dontwarn java.beans.VetoableChangeListener -dontwarn java.beans.VetoableChangeSupport -dontwarn org.apache.harmony.xnet.provider.jsse.SSLParametersImpl -dontwarn org.bouncycastle.jce.provider.BouncyCastleProvider -dontwarn org.bouncycastle.jsse.BCSSLParameters -dontwarn org.bouncycastle.jsse.BCSSLSocket -dontwarn org.bouncycastle.jsse.provider.BouncyCastleJsseProvider -dontwarn org.openjsse.javax.net.ssl.SSLParameters -dontwarn org.openjsse.javax.net.ssl.SSLSocket -dontwarn org.openjsse.net.ssl.OpenJSSE -dontwarn java.beans.PropertyVetoException ================================================ FILE: app/schemas/io.nekohasekai.sagernet.database.SagerDatabase/1.json ================================================ { "formatVersion": 1, "database": { "version": 1, "identityHash": "f66fd943df1d9e86d281a2e32c9fdd47", "entities": [ { "tableName": "proxy_groups", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userOrder` INTEGER NOT NULL, `ungrouped` INTEGER NOT NULL, `name` TEXT, `type` INTEGER NOT NULL, `subscription` BLOB, `order` INTEGER NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "userOrder", "columnName": "userOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "ungrouped", "columnName": "ungrouped", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": false }, { "fieldPath": "type", "columnName": "type", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "subscription", "columnName": "subscription", "affinity": "BLOB", "notNull": false }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [], "foreignKeys": [] }, { "tableName": "proxy_entities", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupId` INTEGER NOT NULL, `type` INTEGER NOT NULL, `userOrder` INTEGER NOT NULL, `tx` INTEGER NOT NULL, `rx` INTEGER NOT NULL, `status` INTEGER NOT NULL, `ping` INTEGER NOT NULL, `uuid` TEXT NOT NULL, `error` TEXT, `socksBean` BLOB, `httpBean` BLOB, `ssBean` BLOB, `vmessBean` BLOB, `trojanBean` BLOB, `trojanGoBean` BLOB, `naiveBean` BLOB, `hysteriaBean` BLOB, `tuicBean` BLOB, `sshBean` BLOB, `wgBean` BLOB, `chainBean` BLOB, `nekoBean` BLOB, `configBean` BLOB)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "groupId", "columnName": "groupId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "type", "columnName": "type", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "userOrder", "columnName": "userOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "tx", "columnName": "tx", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "rx", "columnName": "rx", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "status", "columnName": "status", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "ping", "columnName": "ping", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "uuid", "columnName": "uuid", "affinity": "TEXT", "notNull": true }, { "fieldPath": "error", "columnName": "error", "affinity": "TEXT", "notNull": false }, { "fieldPath": "socksBean", "columnName": "socksBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "httpBean", "columnName": "httpBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "ssBean", "columnName": "ssBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "vmessBean", "columnName": "vmessBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "trojanBean", "columnName": "trojanBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "trojanGoBean", "columnName": "trojanGoBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "naiveBean", "columnName": "naiveBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "hysteriaBean", "columnName": "hysteriaBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "tuicBean", "columnName": "tuicBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "sshBean", "columnName": "sshBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "wgBean", "columnName": "wgBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "chainBean", "columnName": "chainBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "nekoBean", "columnName": "nekoBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "configBean", "columnName": "configBean", "affinity": "BLOB", "notNull": false } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "groupId", "unique": false, "columnNames": [ "groupId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `groupId` ON `${TABLE_NAME}` (`groupId`)" } ], "foreignKeys": [] }, { "tableName": "rules", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `userOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `domains` TEXT NOT NULL, `ip` TEXT NOT NULL, `port` TEXT NOT NULL, `sourcePort` TEXT NOT NULL, `network` TEXT NOT NULL, `source` TEXT NOT NULL, `protocol` TEXT NOT NULL, `outbound` INTEGER NOT NULL, `packages` TEXT NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "userOrder", "columnName": "userOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "domains", "columnName": "domains", "affinity": "TEXT", "notNull": true }, { "fieldPath": "ip", "columnName": "ip", "affinity": "TEXT", "notNull": true }, { "fieldPath": "port", "columnName": "port", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sourcePort", "columnName": "sourcePort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "network", "columnName": "network", "affinity": "TEXT", "notNull": true }, { "fieldPath": "source", "columnName": "source", "affinity": "TEXT", "notNull": true }, { "fieldPath": "protocol", "columnName": "protocol", "affinity": "TEXT", "notNull": true }, { "fieldPath": "outbound", "columnName": "outbound", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "packages", "columnName": "packages", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "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, 'f66fd943df1d9e86d281a2e32c9fdd47')" ] } } ================================================ FILE: app/schemas/io.nekohasekai.sagernet.database.SagerDatabase/2.json ================================================ { "formatVersion": 1, "database": { "version": 2, "identityHash": "9ec160533656482a17cbd563e9e3e416", "entities": [ { "tableName": "proxy_groups", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userOrder` INTEGER NOT NULL, `ungrouped` INTEGER NOT NULL, `name` TEXT, `type` INTEGER NOT NULL, `subscription` BLOB, `order` INTEGER NOT NULL, `isSelector` INTEGER NOT NULL, `frontProxy` INTEGER NOT NULL, `landingProxy` INTEGER NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "userOrder", "columnName": "userOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "ungrouped", "columnName": "ungrouped", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": false }, { "fieldPath": "type", "columnName": "type", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "subscription", "columnName": "subscription", "affinity": "BLOB", "notNull": false }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "isSelector", "columnName": "isSelector", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "frontProxy", "columnName": "frontProxy", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "landingProxy", "columnName": "landingProxy", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "proxy_entities", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupId` INTEGER NOT NULL, `type` INTEGER NOT NULL, `userOrder` INTEGER NOT NULL, `tx` INTEGER NOT NULL, `rx` INTEGER NOT NULL, `status` INTEGER NOT NULL, `ping` INTEGER NOT NULL, `uuid` TEXT NOT NULL, `error` TEXT, `socksBean` BLOB, `httpBean` BLOB, `ssBean` BLOB, `vmessBean` BLOB, `trojanBean` BLOB, `trojanGoBean` BLOB, `naiveBean` BLOB, `hysteriaBean` BLOB, `tuicBean` BLOB, `sshBean` BLOB, `wgBean` BLOB, `shadowTLSBean` BLOB, `chainBean` BLOB, `nekoBean` BLOB, `configBean` BLOB)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "groupId", "columnName": "groupId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "type", "columnName": "type", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "userOrder", "columnName": "userOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "tx", "columnName": "tx", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "rx", "columnName": "rx", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "status", "columnName": "status", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "ping", "columnName": "ping", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "uuid", "columnName": "uuid", "affinity": "TEXT", "notNull": true }, { "fieldPath": "error", "columnName": "error", "affinity": "TEXT", "notNull": false }, { "fieldPath": "socksBean", "columnName": "socksBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "httpBean", "columnName": "httpBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "ssBean", "columnName": "ssBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "vmessBean", "columnName": "vmessBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "trojanBean", "columnName": "trojanBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "trojanGoBean", "columnName": "trojanGoBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "naiveBean", "columnName": "naiveBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "hysteriaBean", "columnName": "hysteriaBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "tuicBean", "columnName": "tuicBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "sshBean", "columnName": "sshBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "wgBean", "columnName": "wgBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "shadowTLSBean", "columnName": "shadowTLSBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "chainBean", "columnName": "chainBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "nekoBean", "columnName": "nekoBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "configBean", "columnName": "configBean", "affinity": "BLOB", "notNull": false } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [ { "name": "groupId", "unique": false, "columnNames": [ "groupId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `groupId` ON `${TABLE_NAME}` (`groupId`)" } ], "foreignKeys": [] }, { "tableName": "rules", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `userOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `domains` TEXT NOT NULL, `ip` TEXT NOT NULL, `port` TEXT NOT NULL, `sourcePort` TEXT NOT NULL, `network` TEXT NOT NULL, `source` TEXT NOT NULL, `protocol` TEXT NOT NULL, `outbound` INTEGER NOT NULL, `packages` TEXT NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "userOrder", "columnName": "userOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "domains", "columnName": "domains", "affinity": "TEXT", "notNull": true }, { "fieldPath": "ip", "columnName": "ip", "affinity": "TEXT", "notNull": true }, { "fieldPath": "port", "columnName": "port", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sourcePort", "columnName": "sourcePort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "network", "columnName": "network", "affinity": "TEXT", "notNull": true }, { "fieldPath": "source", "columnName": "source", "affinity": "TEXT", "notNull": true }, { "fieldPath": "protocol", "columnName": "protocol", "affinity": "TEXT", "notNull": true }, { "fieldPath": "outbound", "columnName": "outbound", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "packages", "columnName": "packages", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "autoGenerate": true, "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, '9ec160533656482a17cbd563e9e3e416')" ] } } ================================================ FILE: app/schemas/io.nekohasekai.sagernet.database.SagerDatabase/3.json ================================================ { "formatVersion": 1, "database": { "version": 3, "identityHash": "cff00d0142d9e53d2ca24a6a55cd213c", "entities": [ { "tableName": "proxy_groups", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userOrder` INTEGER NOT NULL, `ungrouped` INTEGER NOT NULL, `name` TEXT, `type` INTEGER NOT NULL, `subscription` BLOB, `order` INTEGER NOT NULL, `isSelector` INTEGER NOT NULL, `frontProxy` INTEGER NOT NULL, `landingProxy` INTEGER NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "userOrder", "columnName": "userOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "ungrouped", "columnName": "ungrouped", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": false }, { "fieldPath": "type", "columnName": "type", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "subscription", "columnName": "subscription", "affinity": "BLOB", "notNull": false }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "isSelector", "columnName": "isSelector", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "frontProxy", "columnName": "frontProxy", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "landingProxy", "columnName": "landingProxy", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "proxy_entities", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupId` INTEGER NOT NULL, `type` INTEGER NOT NULL, `userOrder` INTEGER NOT NULL, `tx` INTEGER NOT NULL, `rx` INTEGER NOT NULL, `status` INTEGER NOT NULL, `ping` INTEGER NOT NULL, `uuid` TEXT NOT NULL, `error` TEXT, `socksBean` BLOB, `httpBean` BLOB, `ssBean` BLOB, `vmessBean` BLOB, `trojanBean` BLOB, `trojanGoBean` BLOB, `mieruBean` BLOB, `naiveBean` BLOB, `hysteriaBean` BLOB, `tuicBean` BLOB, `sshBean` BLOB, `wgBean` BLOB, `shadowTLSBean` BLOB, `chainBean` BLOB, `nekoBean` BLOB, `configBean` BLOB)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "groupId", "columnName": "groupId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "type", "columnName": "type", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "userOrder", "columnName": "userOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "tx", "columnName": "tx", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "rx", "columnName": "rx", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "status", "columnName": "status", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "ping", "columnName": "ping", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "uuid", "columnName": "uuid", "affinity": "TEXT", "notNull": true }, { "fieldPath": "error", "columnName": "error", "affinity": "TEXT", "notNull": false }, { "fieldPath": "socksBean", "columnName": "socksBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "httpBean", "columnName": "httpBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "ssBean", "columnName": "ssBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "vmessBean", "columnName": "vmessBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "trojanBean", "columnName": "trojanBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "trojanGoBean", "columnName": "trojanGoBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "mieruBean", "columnName": "mieruBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "naiveBean", "columnName": "naiveBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "hysteriaBean", "columnName": "hysteriaBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "tuicBean", "columnName": "tuicBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "sshBean", "columnName": "sshBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "wgBean", "columnName": "wgBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "shadowTLSBean", "columnName": "shadowTLSBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "chainBean", "columnName": "chainBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "nekoBean", "columnName": "nekoBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "configBean", "columnName": "configBean", "affinity": "BLOB", "notNull": false } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [ { "name": "groupId", "unique": false, "columnNames": [ "groupId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `groupId` ON `${TABLE_NAME}` (`groupId`)" } ], "foreignKeys": [] }, { "tableName": "rules", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `userOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `domains` TEXT NOT NULL, `ip` TEXT NOT NULL, `port` TEXT NOT NULL, `sourcePort` TEXT NOT NULL, `network` TEXT NOT NULL, `source` TEXT NOT NULL, `protocol` TEXT NOT NULL, `outbound` INTEGER NOT NULL, `packages` TEXT NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "userOrder", "columnName": "userOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "domains", "columnName": "domains", "affinity": "TEXT", "notNull": true }, { "fieldPath": "ip", "columnName": "ip", "affinity": "TEXT", "notNull": true }, { "fieldPath": "port", "columnName": "port", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sourcePort", "columnName": "sourcePort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "network", "columnName": "network", "affinity": "TEXT", "notNull": true }, { "fieldPath": "source", "columnName": "source", "affinity": "TEXT", "notNull": true }, { "fieldPath": "protocol", "columnName": "protocol", "affinity": "TEXT", "notNull": true }, { "fieldPath": "outbound", "columnName": "outbound", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "packages", "columnName": "packages", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "autoGenerate": true, "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, 'cff00d0142d9e53d2ca24a6a55cd213c')" ] } } ================================================ FILE: app/schemas/io.nekohasekai.sagernet.database.SagerDatabase/4.json ================================================ { "formatVersion": 1, "database": { "version": 4, "identityHash": "cff00d0142d9e53d2ca24a6a55cd213c", "entities": [ { "tableName": "proxy_groups", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userOrder` INTEGER NOT NULL, `ungrouped` INTEGER NOT NULL, `name` TEXT, `type` INTEGER NOT NULL, `subscription` BLOB, `order` INTEGER NOT NULL, `isSelector` INTEGER NOT NULL, `frontProxy` INTEGER NOT NULL, `landingProxy` INTEGER NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "userOrder", "columnName": "userOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "ungrouped", "columnName": "ungrouped", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": false }, { "fieldPath": "type", "columnName": "type", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "subscription", "columnName": "subscription", "affinity": "BLOB", "notNull": false }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "isSelector", "columnName": "isSelector", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "frontProxy", "columnName": "frontProxy", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "landingProxy", "columnName": "landingProxy", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "proxy_entities", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupId` INTEGER NOT NULL, `type` INTEGER NOT NULL, `userOrder` INTEGER NOT NULL, `tx` INTEGER NOT NULL, `rx` INTEGER NOT NULL, `status` INTEGER NOT NULL, `ping` INTEGER NOT NULL, `uuid` TEXT NOT NULL, `error` TEXT, `socksBean` BLOB, `httpBean` BLOB, `ssBean` BLOB, `vmessBean` BLOB, `trojanBean` BLOB, `trojanGoBean` BLOB, `mieruBean` BLOB, `naiveBean` BLOB, `hysteriaBean` BLOB, `tuicBean` BLOB, `sshBean` BLOB, `wgBean` BLOB, `shadowTLSBean` BLOB, `chainBean` BLOB, `nekoBean` BLOB, `configBean` BLOB)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "groupId", "columnName": "groupId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "type", "columnName": "type", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "userOrder", "columnName": "userOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "tx", "columnName": "tx", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "rx", "columnName": "rx", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "status", "columnName": "status", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "ping", "columnName": "ping", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "uuid", "columnName": "uuid", "affinity": "TEXT", "notNull": true }, { "fieldPath": "error", "columnName": "error", "affinity": "TEXT", "notNull": false }, { "fieldPath": "socksBean", "columnName": "socksBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "httpBean", "columnName": "httpBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "ssBean", "columnName": "ssBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "vmessBean", "columnName": "vmessBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "trojanBean", "columnName": "trojanBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "trojanGoBean", "columnName": "trojanGoBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "mieruBean", "columnName": "mieruBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "naiveBean", "columnName": "naiveBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "hysteriaBean", "columnName": "hysteriaBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "tuicBean", "columnName": "tuicBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "sshBean", "columnName": "sshBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "wgBean", "columnName": "wgBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "shadowTLSBean", "columnName": "shadowTLSBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "chainBean", "columnName": "chainBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "nekoBean", "columnName": "nekoBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "configBean", "columnName": "configBean", "affinity": "BLOB", "notNull": false } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [ { "name": "groupId", "unique": false, "columnNames": [ "groupId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `groupId` ON `${TABLE_NAME}` (`groupId`)" } ], "foreignKeys": [] }, { "tableName": "rules", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `userOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `domains` TEXT NOT NULL, `ip` TEXT NOT NULL, `port` TEXT NOT NULL, `sourcePort` TEXT NOT NULL, `network` TEXT NOT NULL, `source` TEXT NOT NULL, `protocol` TEXT NOT NULL, `outbound` INTEGER NOT NULL, `packages` TEXT NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "userOrder", "columnName": "userOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "domains", "columnName": "domains", "affinity": "TEXT", "notNull": true }, { "fieldPath": "ip", "columnName": "ip", "affinity": "TEXT", "notNull": true }, { "fieldPath": "port", "columnName": "port", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sourcePort", "columnName": "sourcePort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "network", "columnName": "network", "affinity": "TEXT", "notNull": true }, { "fieldPath": "source", "columnName": "source", "affinity": "TEXT", "notNull": true }, { "fieldPath": "protocol", "columnName": "protocol", "affinity": "TEXT", "notNull": true }, { "fieldPath": "outbound", "columnName": "outbound", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "packages", "columnName": "packages", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "autoGenerate": true, "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, 'cff00d0142d9e53d2ca24a6a55cd213c')" ] } } ================================================ FILE: app/schemas/io.nekohasekai.sagernet.database.SagerDatabase/5.json ================================================ { "formatVersion": 1, "database": { "version": 5, "identityHash": "1dbf667053726c13d139a4d83c41f895", "entities": [ { "tableName": "proxy_groups", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userOrder` INTEGER NOT NULL, `ungrouped` INTEGER NOT NULL, `name` TEXT, `type` INTEGER NOT NULL, `subscription` BLOB, `order` INTEGER NOT NULL, `isSelector` INTEGER NOT NULL, `frontProxy` INTEGER NOT NULL, `landingProxy` INTEGER NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "userOrder", "columnName": "userOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "ungrouped", "columnName": "ungrouped", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": false }, { "fieldPath": "type", "columnName": "type", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "subscription", "columnName": "subscription", "affinity": "BLOB", "notNull": false }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "isSelector", "columnName": "isSelector", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "frontProxy", "columnName": "frontProxy", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "landingProxy", "columnName": "landingProxy", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "proxy_entities", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupId` INTEGER NOT NULL, `type` INTEGER NOT NULL, `userOrder` INTEGER NOT NULL, `tx` INTEGER NOT NULL, `rx` INTEGER NOT NULL, `status` INTEGER NOT NULL, `ping` INTEGER NOT NULL, `uuid` TEXT NOT NULL, `error` TEXT, `socksBean` BLOB, `httpBean` BLOB, `ssBean` BLOB, `vmessBean` BLOB, `trojanBean` BLOB, `trojanGoBean` BLOB, `mieruBean` BLOB, `naiveBean` BLOB, `hysteriaBean` BLOB, `tuicBean` BLOB, `sshBean` BLOB, `wgBean` BLOB, `shadowTLSBean` BLOB, `anyTLSBean` BLOB, `chainBean` BLOB, `nekoBean` BLOB, `configBean` BLOB)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "groupId", "columnName": "groupId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "type", "columnName": "type", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "userOrder", "columnName": "userOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "tx", "columnName": "tx", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "rx", "columnName": "rx", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "status", "columnName": "status", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "ping", "columnName": "ping", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "uuid", "columnName": "uuid", "affinity": "TEXT", "notNull": true }, { "fieldPath": "error", "columnName": "error", "affinity": "TEXT", "notNull": false }, { "fieldPath": "socksBean", "columnName": "socksBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "httpBean", "columnName": "httpBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "ssBean", "columnName": "ssBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "vmessBean", "columnName": "vmessBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "trojanBean", "columnName": "trojanBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "trojanGoBean", "columnName": "trojanGoBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "mieruBean", "columnName": "mieruBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "naiveBean", "columnName": "naiveBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "hysteriaBean", "columnName": "hysteriaBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "tuicBean", "columnName": "tuicBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "sshBean", "columnName": "sshBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "wgBean", "columnName": "wgBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "shadowTLSBean", "columnName": "shadowTLSBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "anyTLSBean", "columnName": "anyTLSBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "chainBean", "columnName": "chainBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "nekoBean", "columnName": "nekoBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "configBean", "columnName": "configBean", "affinity": "BLOB", "notNull": false } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [ { "name": "groupId", "unique": false, "columnNames": [ "groupId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `groupId` ON `${TABLE_NAME}` (`groupId`)" } ], "foreignKeys": [] }, { "tableName": "rules", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `userOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `domains` TEXT NOT NULL, `ip` TEXT NOT NULL, `port` TEXT NOT NULL, `sourcePort` TEXT NOT NULL, `network` TEXT NOT NULL, `source` TEXT NOT NULL, `protocol` TEXT NOT NULL, `outbound` INTEGER NOT NULL, `packages` TEXT NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "userOrder", "columnName": "userOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "domains", "columnName": "domains", "affinity": "TEXT", "notNull": true }, { "fieldPath": "ip", "columnName": "ip", "affinity": "TEXT", "notNull": true }, { "fieldPath": "port", "columnName": "port", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sourcePort", "columnName": "sourcePort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "network", "columnName": "network", "affinity": "TEXT", "notNull": true }, { "fieldPath": "source", "columnName": "source", "affinity": "TEXT", "notNull": true }, { "fieldPath": "protocol", "columnName": "protocol", "affinity": "TEXT", "notNull": true }, { "fieldPath": "outbound", "columnName": "outbound", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "packages", "columnName": "packages", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "autoGenerate": true, "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, '1dbf667053726c13d139a4d83c41f895')" ] } } ================================================ FILE: app/schemas/io.nekohasekai.sagernet.database.SagerDatabase/6.json ================================================ { "formatVersion": 1, "database": { "version": 6, "identityHash": "3d3db9106a89d6f20ef3fde6e81dbaa9", "entities": [ { "tableName": "proxy_groups", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userOrder` INTEGER NOT NULL, `ungrouped` INTEGER NOT NULL, `name` TEXT, `type` INTEGER NOT NULL, `subscription` BLOB, `order` INTEGER NOT NULL, `isSelector` INTEGER NOT NULL, `frontProxy` INTEGER NOT NULL, `landingProxy` INTEGER NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "userOrder", "columnName": "userOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "ungrouped", "columnName": "ungrouped", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": false }, { "fieldPath": "type", "columnName": "type", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "subscription", "columnName": "subscription", "affinity": "BLOB", "notNull": false }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "isSelector", "columnName": "isSelector", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "frontProxy", "columnName": "frontProxy", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "landingProxy", "columnName": "landingProxy", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "proxy_entities", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupId` INTEGER NOT NULL, `type` INTEGER NOT NULL, `userOrder` INTEGER NOT NULL, `tx` INTEGER NOT NULL, `rx` INTEGER NOT NULL, `status` INTEGER NOT NULL, `ping` INTEGER NOT NULL, `uuid` TEXT NOT NULL, `error` TEXT, `socksBean` BLOB, `httpBean` BLOB, `ssBean` BLOB, `vmessBean` BLOB, `trojanBean` BLOB, `trojanGoBean` BLOB, `mieruBean` BLOB, `naiveBean` BLOB, `hysteriaBean` BLOB, `tuicBean` BLOB, `sshBean` BLOB, `wgBean` BLOB, `shadowTLSBean` BLOB, `anyTLSBean` BLOB, `chainBean` BLOB, `nekoBean` BLOB, `configBean` BLOB)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "groupId", "columnName": "groupId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "type", "columnName": "type", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "userOrder", "columnName": "userOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "tx", "columnName": "tx", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "rx", "columnName": "rx", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "status", "columnName": "status", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "ping", "columnName": "ping", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "uuid", "columnName": "uuid", "affinity": "TEXT", "notNull": true }, { "fieldPath": "error", "columnName": "error", "affinity": "TEXT", "notNull": false }, { "fieldPath": "socksBean", "columnName": "socksBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "httpBean", "columnName": "httpBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "ssBean", "columnName": "ssBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "vmessBean", "columnName": "vmessBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "trojanBean", "columnName": "trojanBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "trojanGoBean", "columnName": "trojanGoBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "mieruBean", "columnName": "mieruBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "naiveBean", "columnName": "naiveBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "hysteriaBean", "columnName": "hysteriaBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "tuicBean", "columnName": "tuicBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "sshBean", "columnName": "sshBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "wgBean", "columnName": "wgBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "shadowTLSBean", "columnName": "shadowTLSBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "anyTLSBean", "columnName": "anyTLSBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "chainBean", "columnName": "chainBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "nekoBean", "columnName": "nekoBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "configBean", "columnName": "configBean", "affinity": "BLOB", "notNull": false } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [ { "name": "groupId", "unique": false, "columnNames": [ "groupId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `groupId` ON `${TABLE_NAME}` (`groupId`)" } ], "foreignKeys": [] }, { "tableName": "rules", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `config` TEXT NOT NULL DEFAULT '', `userOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `domains` TEXT NOT NULL, `ip` TEXT NOT NULL, `port` TEXT NOT NULL, `sourcePort` TEXT NOT NULL, `network` TEXT NOT NULL, `source` TEXT NOT NULL, `protocol` TEXT NOT NULL, `outbound` INTEGER NOT NULL, `packages` TEXT NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "config", "columnName": "config", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "userOrder", "columnName": "userOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "domains", "columnName": "domains", "affinity": "TEXT", "notNull": true }, { "fieldPath": "ip", "columnName": "ip", "affinity": "TEXT", "notNull": true }, { "fieldPath": "port", "columnName": "port", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sourcePort", "columnName": "sourcePort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "network", "columnName": "network", "affinity": "TEXT", "notNull": true }, { "fieldPath": "source", "columnName": "source", "affinity": "TEXT", "notNull": true }, { "fieldPath": "protocol", "columnName": "protocol", "affinity": "TEXT", "notNull": true }, { "fieldPath": "outbound", "columnName": "outbound", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "packages", "columnName": "packages", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "autoGenerate": true, "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, '3d3db9106a89d6f20ef3fde6e81dbaa9')" ] } } ================================================ FILE: app/schemas/io.nekohasekai.sagernet.database.preference.PublicDatabase/1.json ================================================ { "formatVersion": 1, "database": { "version": 1, "identityHash": "f1aab1fb633378621635c344dbc8ac7b", "entities": [ { "tableName": "KeyValuePair", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `valueType` INTEGER NOT NULL, `value` BLOB NOT NULL, PRIMARY KEY(`key`))", "fields": [ { "fieldPath": "key", "columnName": "key", "affinity": "TEXT", "notNull": true }, { "fieldPath": "valueType", "columnName": "valueType", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "value", "columnName": "value", "affinity": "BLOB", "notNull": true } ], "primaryKey": { "columnNames": [ "key" ], "autoGenerate": false }, "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, 'f1aab1fb633378621635c344dbc8ac7b')" ] } } ================================================ FILE: app/schemas/moe.matsuri.nb4a.TempDatabase/1.json ================================================ { "formatVersion": 1, "database": { "version": 1, "identityHash": "f1aab1fb633378621635c344dbc8ac7b", "entities": [ { "tableName": "KeyValuePair", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `valueType` INTEGER NOT NULL, `value` BLOB NOT NULL, PRIMARY KEY(`key`))", "fields": [ { "fieldPath": "key", "columnName": "key", "affinity": "TEXT", "notNull": true }, { "fieldPath": "valueType", "columnName": "valueType", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "value", "columnName": "value", "affinity": "BLOB", "notNull": true } ], "primaryKey": { "columnNames": [ "key" ], "autoGenerate": false }, "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, 'f1aab1fb633378621635c344dbc8ac7b')" ] } } ================================================ FILE: app/src/main/AndroidManifest.xml ================================================ ================================================ FILE: app/src/main/aidl/io/nekohasekai/sagernet/aidl/ISagerNetService.aidl ================================================ package io.nekohasekai.sagernet.aidl; import io.nekohasekai.sagernet.aidl.ISagerNetServiceCallback; interface ISagerNetService { int getState(); String getProfileName(); void registerCallback(in ISagerNetServiceCallback cb, int id); oneway void unregisterCallback(in ISagerNetServiceCallback cb); int urlTest(); } ================================================ FILE: app/src/main/aidl/io/nekohasekai/sagernet/aidl/ISagerNetServiceCallback.aidl ================================================ package io.nekohasekai.sagernet.aidl; import io.nekohasekai.sagernet.aidl.SpeedDisplayData; import io.nekohasekai.sagernet.aidl.TrafficData; oneway interface ISagerNetServiceCallback { void stateChanged(int state, String profileName, String msg); void missingPlugin(String profileName, String pluginName); void cbSpeedUpdate(in SpeedDisplayData stats); void cbTrafficUpdate(in TrafficData stats); void cbSelectorUpdate(long id); } ================================================ FILE: app/src/main/aidl/io/nekohasekai/sagernet/aidl/SpeedDisplayData.aidl ================================================ package io.nekohasekai.sagernet.aidl; parcelable SpeedDisplayData; ================================================ FILE: app/src/main/aidl/io/nekohasekai/sagernet/aidl/TrafficData.aidl ================================================ package io.nekohasekai.sagernet.aidl; parcelable TrafficData; ================================================ FILE: app/src/main/assets/LICENSE ================================================ Copyright (C) 2021 by nekohasekai This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . ================================================ FILE: app/src/main/assets/proxy_packagename.txt ================================================ amanita_design.samorost3.gp android au.com.shiftyjelly.pocketcasts bbc.mobile.news.ww be.mygod.vpnhotspot ch.protonmail.android cm.aptoide.pt co.wanqu.android com.alphainventor.filemanager com.amazon.kindle com.amazon.mshop.android.shopping com.android.chrome com.android.providers.downloads com.android.providers.downloads.ui com.android.providers.telephony com.android.settings com.android.vending com.android6park.m6park com.apkpure.aegon com.apkupdater com.app.pornhub com.arthurivanets.owly com.asahi.tida.tablet com.authy.authy com.avmovie com.ballistiq.artstation com.binance.dev com.bitly.app com.brave.browser com.brave.browser_beta com.breel.wallpapers18 com.bvanced.android.youtube com.chrome.beta com.chrome.canary com.chrome.dev com.cl.newt66y com.cradle.iitc_mobile org.exarhteam.iitc_mobile com.cygames.shadowverse com.dcard.freedom com.devhd.feedly com.devolver.reigns2 com.discord com.downloader.video.tumblr com.driverbrowser com.dropbox.android com.duolingo com.duckduckgo.mobile.android com.dv.adm com.estrongs.android.pop com.estrongs.android.pop.pro com.evernote com.facebook.katana com.facebook.lite com.facebook.mlite com.facebook.orca com.facebook.services com.facebook.system com.fastaccess.github com.felixfilip.scpae com.fireproofstudios.theroom4 com.firstrowria.pushnotificationtester com.flyersoft.moonreaderp com.fooview.android.fooview com.fvd.eversync com.gameloft.android.anmp.glofta8hm com.gameloft.android.anmp.glofta9hm com.gianlu.aria2app com.github.yeriomin.yalpstore com.google.android.apps.adm com.google.android.apps.books com.google.android.apps.docs com.google.android.apps.docs.editors.sheets com.google.android.apps.docs.editors.docs com.google.android.apps.docs.editors.slides com.google.android.apps.fitness com.google.android.apps.googleassistant com.google.android.apps.googlevoice com.google.android.apps.hangoutsdialer com.google.android.apps.inbox com.google.android.apps.magazines com.google.android.apps.maps com.google.android.apps.nbu.files com.google.android.apps.paidtasks com.google.android.apps.pdfviewer com.google.android.apps.photos com.google.android.apps.plus com.google.android.apps.translate com.google.android.gm com.google.android.gms com.google.android.gms.setup com.google.android.googlequicksearchbox com.google.android.gsf com.google.android.gsf.login com.google.android.ims com.google.android.inputmethod.latin com.google.android.instantapps.supervisor com.google.android.keep com.google.android.music com.google.android.ogyoutube com.google.android.partnersetup com.google.android.play.games com.google.android.street com.google.android.syncadapters.calendar com.google.android.syncadapters.contacts com.google.android.talk com.google.android.tts com.google.android.videos com.google.android.youtube com.google.ar.lens com.google.android.apps.authenticator2 com.hochan.coldsoup com.ifttt.ifttt com.imgur.mobile com.innologica.inoreader com.instagram.android com.instagram.lite com.instapaper.android com.jarvanh.vpntether com.kapp.youtube.final com.klinker.android.twitter_l com.lastpass.lpandroid com.linecorp.linelite com.lingodeer com.ltnnews.news com.mediapods.tumbpods com.mgoogle.android.gms com.microsoft.emmx com.microsoft.office.powerpoint com.microsoft.skydrive com.mixplorer com.msd.consumerchinese com.msd.professionalchinese com.mss2011c.sharehelper com.netflix.mediaclient com.newin.nplayer.pro com.nianticlabs.ingress.prime.qa com.nianticproject.ingress com.ninefolders.hd3 com.ninegag.android.app com.nintendo.zara com.nytimes.cn com.oasisfeng.island com.ocnt.liveapp.hw com.orekie.search com.patreon.android com.paypal.android.p2pmobile com.perol.asdpl.pixivez com.pinterest com.popularapp.periodcalendar com.popularapp.videodownloaderforinstagram com.pushbullet.android com.quoord.tapatalkpro.activity com.quora.android com.rayark.cytus2 com.rayark.implosion com.rayark.pluto com.reddit.frontpage com.resilio.sync com.rhmsoft.edit com.rubenmayayo.reddit com.sec.android.app.sbrowser com.sec.android.app.sbrowser.beta com.shanga.walli com.simplehabit.simplehabitapp com.slack com.snaptube.premium com.sololearn com.sonelli.juicessh com.sparkslab.dcardreader com.spotify.music com.spotify.lite com.tencent.huatuo com.termux com.teslacoilsw.launcher com.theinitium.news com.thomsonreuters.reuters com.thunkable.android.hritvik00.freenom com.topjohnwu.magisk com.tripadvisor.tripadvisor com.tumblr com.twitter.android com.u91porn com.u9porn com.ubisoft.dance.justdance2015companion com.udn.news com.utopia.pxview com.valvesoftware.android.steam.community com.vanced.manager com.vanced.android.youtube com.vanced.android.apps.youtube.music com.mgoogle.android.gms com.vimeo.android.videoapp com.vivaldi.browser com.vivaldi.browser.snapshot com.vkontakte.android com.whatsapp com.wire com.wuxiangai.refactor com.xda.labs com.xvideos.app com.yahoo.mobile.client.android.superapp com.yandex.browser com.yandex.browser.beta com.yandex.browser.alpha com.z28j.feel com.zhiliaoapp.musically con.medium.reader de.apkgrabber de.robv.android.xposed.installer dk.tacit.android.foldersync.full es.rafalense.telegram.themes es.rafalense.themes flipboard.app fm.moon.app fr.gouv.etalab.mastodon github.tornaco.xposedmoduletest idm.internet.download.manager idm.internet.download.manager.plus io.github.javiewer io.github.skyhacker2.magnetsearch io.va.exposed it.mvilla.android.fenix2 jp.bokete.app.android jp.naver.line.android jp.pxv.android luo.speedometergpspro m.cna.com.tw.App mark.via.gp me.tshine.easymark net.teeha.android.url_shortener net.tsapps.appsales onion.fire org.fdroid.fdroid org.freedownloadmanager.fdm org.kustom.widget org.mozilla.fennec_aurora org.mozilla.fenix org.mozilla.fenix.nightly org.mozilla.firefox org.mozilla.firefox_beta org.mozilla.focus org.schabi.newpipe org.telegram.messenger org.telegram.multi org.telegram.plus org.thunderdog.challegram org.torproject.android org.torproject.torbrowser_alpha org.wikipedia org.xbmc.kodi pl.zdunex25.updater tv.twitch.android.app tw.com.gamer.android.activecenter videodownloader.downloadvideo.downloader uk.co.bbc.learningenglish com.ted.android de.danoeh.antennapod com.kiwibrowser.browser nekox.messenger com.nextcloud.client com.aurora.store com.aurora.adroid chat.simplex.app im.vector.app network.loki.messenger eu.siacs.conversations xyz.nextalone.nagram de.danoeh.antennapod net.programmierecke.radiodroid2 im.fdx.v2ex ml.docilealligator.infinityforreddit com.bytemyth.ama app.vanadium.browser com.cakewallet.cake_wallet org.purplei2p.i2pd dk.tacit.android.foldersync.lite com.nononsenseapps.feeder com.m2049r.xmrwallet com.paypal.android.p2pmobile com.google.android.apps.googlevoice com.readdle.spark org.torproject.torbrowser com.deepl.mobiletranslator com.microsoft.bing com.keylesspalace.tusky com.ottplay.ottplay ru.iptvremote.android.iptv.pro jp.naver.line.android com.xmflsct.app.tooot com.forem.android app.revanced.android.youtube app.rvx.android.youtube app.rvx.android.apps.youtube.music com.mgoogle.android.gms com.pionex.client vip.mytokenpocket im.token.app com.linekong.mars24 com.feixiaohao com.aicoin.appandroid com.binance.dev com.kraken.trade com.okinc.okex.gp com.authy.authy air.com.rosettastone.mobile.CoursePlayer com.blizzard.bma com.amazon.kindle com.google.android.apps.fitness net.tsapps.appsales com.wemesh.android com.google.android.apps.googleassistant allen.town.focus.reader me.hyliu.fluent_reader_lite com.aljazeera.mobile com.ft.news de.marmaro.krt.ffupdater myradio.radio.fmradio.liveradio.radiostation com.google.earth eu.kanade.tachiyomi.j2k com.audials com.microsoft.skydrive com.mb.android.tg com.melodis.midomiMusicIdentifier.freemium com.foxnews.android ch.threema.app com.briarproject.briar.android foundation.e.apps com.valvesoftware.android.steam.friendsui com.imback.yeetalk so.onekey.app.wallet com.xc3fff0e.xmanager meditofoundation.medito com.picol.client com.streetwriters.notesnook shanghai.panewsApp.com org.coursera.android com.positron_it.zlib com.blizzard.messenger com.javdb.javrocket com.picacomic.fregata com.fxl.chacha me.proton.android.drive com.lastpass.lpandroid com.tradingview.tradingviewapp com.deviantart.android.damobile com.fusionmedia.investing com.ewa.ewaapp com.duolingo com.hellotalk io.github.huskydg.magisk com.jsy.xpgbox com.hostloc.app.hostloc com.dena.pokota com.vitorpamplona.amethyst com.zhiliaoapp.musically us.spotco.fennec_dos com.fongmi.android.tv com.pocketprep.android.itcybersecurity com.cloudtv com.glassdoor.app com.indeed.android.jobsearch com.linkedin.android com.github.tvbox.osc.bh com.example.douban com.sipnetic.app com.microsoft.rdc.androidx org.zwanoo.android.speedtest com.sonelli.juicessh com.scmp.newspulse org.lsposed.manager mnn.Android com.thomsonretuers.reuters com.guardian com.ttxapps.onesyncv2 org.fcitx.fcitx5.android.updater com.instagram.barcelona com.deniscerri.ytdl jp.pokemon.pokemonsleep com.github.android com.openai.chatgpt mega.privacy.android.app com.taptap.global tw.com.gamer.android.animad com.microsoft.copilot com.google.android.apps.aiwallpapers ai.x.grok com.google.android.apps.weather com.metrolist.music com.google.android.apps.youtube.creator ================================================ FILE: app/src/main/assets/yacd.version.txt ================================================ 3 ================================================ FILE: app/src/main/java/com/github/shadowsocks/plugin/Utils.kt ================================================ @file:JvmName("Utils") package com.github.shadowsocks.plugin import android.os.Parcelable import kotlinx.parcelize.Parcelize @Parcelize class Empty : Parcelable ================================================ FILE: app/src/main/java/com/github/shadowsocks/plugin/fragment/AlertDialogFragment.kt ================================================ package com.github.shadowsocks.plugin.fragment import android.app.Activity import android.content.DialogInterface import android.os.Bundle import android.os.Parcelable import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatDialogFragment import androidx.fragment.app.Fragment import androidx.fragment.app.setFragmentResult import androidx.fragment.app.setFragmentResultListener import com.google.android.material.dialog.MaterialAlertDialogBuilder /** * Based on: https://android.googlesource.com/platform/ packages/apps/ExactCalculator/+/8c43f06/src/com/android/calculator2/AlertDialogFragment.java */ abstract class AlertDialogFragment : AppCompatDialogFragment(), DialogInterface.OnClickListener { companion object { private const val KEY_RESULT = "result" private const val KEY_ARG = "arg" private const val KEY_RET = "ret" private const val KEY_WHICH = "which" fun setResultListener(fragment: Fragment, requestKey: String, listener: (Int, Ret?) -> Unit) { fragment.setFragmentResultListener(requestKey) { _, bundle -> listener(bundle.getInt(KEY_WHICH, Activity.RESULT_CANCELED), bundle.getParcelable(KEY_RET)) } } inline fun , Ret : Parcelable?> setResultListener( fragment: Fragment, noinline listener: (Int, Ret?) -> Unit) = setResultListener(fragment, T::class.java.name, listener) } protected abstract fun AlertDialog.Builder.prepare(listener: DialogInterface.OnClickListener) private val resultKey get() = requireArguments().getString(KEY_RESULT) protected val arg by lazy { requireArguments().getParcelable(KEY_ARG)!! } protected open fun ret(which: Int): Ret? = null private fun args() = arguments ?: Bundle().also { arguments = it } fun arg(arg: Arg) = args().putParcelable(KEY_ARG, arg) fun key(resultKey: String = javaClass.name) = args().putString(KEY_RESULT, resultKey) override fun onCreateDialog(savedInstanceState: Bundle?): AlertDialog = MaterialAlertDialogBuilder(requireContext()).also { it.prepare(this) }.create() override fun onClick(dialog: DialogInterface?, which: Int) { setFragmentResult(resultKey ?: return, Bundle().apply { putInt(KEY_WHICH, which) putParcelable(KEY_RET, ret(which) ?: return@apply) }) } override fun onDismiss(dialog: DialogInterface) { super.onDismiss(dialog) onClick(null, Activity.RESULT_CANCELED) } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/BootReceiver.kt ================================================ package io.nekohasekai.sagernet import android.content.BroadcastReceiver import android.content.ComponentName import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.os.Build import io.nekohasekai.sagernet.bg.SubscriptionUpdater import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.ktx.app import io.nekohasekai.sagernet.ktx.runOnDefaultDispatcher class BootReceiver : BroadcastReceiver() { companion object { private val componentName by lazy { ComponentName(app, BootReceiver::class.java) } var enabled: Boolean get() = app.packageManager.getComponentEnabledSetting(componentName) == PackageManager.COMPONENT_ENABLED_STATE_ENABLED set(value) = app.packageManager.setComponentEnabledSetting( componentName, if (value) PackageManager.COMPONENT_ENABLED_STATE_ENABLED else PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP ) } override fun onReceive(context: Context, intent: Intent) { runOnDefaultDispatcher { SubscriptionUpdater.reconfigureUpdater() } if (!DataStore.persistAcrossReboot) { // sanity check enabled = false return } val doStart = when (intent.action) { Intent.ACTION_LOCKED_BOOT_COMPLETED -> false // DataStore.directBootAware else -> Build.VERSION.SDK_INT < 24 || SagerNet.user.isUserUnlocked } && DataStore.selectedProxy > 0 if (doStart) SagerNet.startService() } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/Constants.kt ================================================ package io.nekohasekai.sagernet const val CONNECTION_TEST_URL = "http://cp.cloudflare.com/" object Key { const val DB_PUBLIC = "configuration.db" const val DB_PROFILE = "sager_net.db" const val PERSIST_ACROSS_REBOOT = "isAutoConnect" const val APP_EXPERT = "isExpert" const val APP_THEME = "appTheme" const val NIGHT_THEME = "nightTheme" const val SERVICE_MODE = "serviceMode" const val MODE_VPN = "vpn" const val MODE_PROXY = "proxy" const val GLOBAL_CUSTOM_CONFIG = "globalCustomConfig" const val REMOTE_DNS = "remoteDns" const val DIRECT_DNS = "directDns" const val ENABLE_DNS_ROUTING = "enableDnsRouting" const val ENABLE_FAKEDNS = "enableFakeDns" const val IPV6_MODE = "ipv6Mode" const val PROXY_APPS = "proxyApps" const val BYPASS_MODE = "bypassMode" const val INDIVIDUAL = "individual" const val METERED_NETWORK = "meteredNetwork" const val TRAFFIC_SNIFFING = "trafficSniffing" const val RESOLVE_DESTINATION = "resolveDestination" const val BYPASS_LAN = "bypassLan" const val BYPASS_LAN_IN_CORE = "bypassLanInCore" const val MIXED_PORT = "mixedPort" const val ALLOW_ACCESS = "allowAccess" const val SPEED_INTERVAL = "speedInterval" const val SHOW_DIRECT_SPEED = "showDirectSpeed" const val APPEND_HTTP_PROXY = "appendHttpProxy" const val CONNECTION_TEST_URL = "connectionTestURL" const val NETWORK_CHANGE_RESET_CONNECTIONS = "networkChangeResetConnections" const val WAKE_RESET_CONNECTIONS = "wakeResetConnections" const val RULES_PROVIDER = "rulesProvider" const val LOG_LEVEL = "logLevel" const val LOG_BUF_SIZE = "logBufSize" const val MTU = "mtu" const val ALWAYS_SHOW_ADDRESS = "alwaysShowAddress" // Protocol Settings const val GLOBAL_ALLOW_INSECURE = "globalAllowInsecure" const val ACQUIRE_WAKE_LOCK = "acquireWakeLock" const val SHOW_BOTTOM_BAR = "showBottomBar" const val ALLOW_INSECURE_ON_REQUEST = "allowInsecureOnRequest" const val TUN_IMPLEMENTATION = "tunImplementation" const val PROFILE_TRAFFIC_STATISTICS = "profileTrafficStatistics" const val PROFILE_DIRTY = "profileDirty" const val PROFILE_ID = "profileId" const val PROFILE_NAME = "profileName" const val PROFILE_GROUP = "profileGroup" const val PROFILE_CURRENT = "profileCurrent" const val SERVER_ADDRESS = "serverAddress" const val SERVER_PORT = "serverPort" const val SERVER_USERNAME = "serverUsername" const val SERVER_PASSWORD = "serverPassword" const val SERVER_METHOD = "serverMethod" const val SERVER_PASSWORD1 = "serverPassword1" const val PROTOCOL_VERSION = "protocolVersion" const val SERVER_PROTOCOL = "serverProtocol" const val SERVER_OBFS = "serverObfs" const val SERVER_NETWORK = "serverNetwork" const val SERVER_HOST = "serverHost" const val SERVER_PATH = "serverPath" const val SERVER_SNI = "serverSNI" const val SERVER_ENCRYPTION = "serverEncryption" const val SERVER_ALPN = "serverALPN" const val SERVER_CERTIFICATES = "serverCertificates" const val SERVER_MTU = "serverMTU" const val SERVER_CONFIG = "serverConfig" const val SERVER_CUSTOM = "serverCustom" const val SERVER_CUSTOM_OUTBOUND = "serverCustomOutbound" const val SERVER_SECURITY_CATEGORY = "serverSecurityCategory" const val SERVER_TLS_CAMOUFLAGE_CATEGORY = "serverTlsCamouflageCategory" const val SERVER_ECH_CATEORY = "serverECHCategory" const val SERVER_WS_CATEGORY = "serverWsCategory" const val SERVER_SS_CATEGORY = "serverSsCategory" const val SERVER_HEADERS = "serverHeaders" const val SERVER_ALLOW_INSECURE = "serverAllowInsecure" const val SERVER_AUTH_TYPE = "serverAuthType" const val SERVER_UPLOAD_SPEED = "serverUploadSpeed" const val SERVER_DOWNLOAD_SPEED = "serverDownloadSpeed" const val SERVER_STREAM_RECEIVE_WINDOW = "serverStreamReceiveWindow" const val SERVER_CONNECTION_RECEIVE_WINDOW = "serverConnectionReceiveWindow" const val SERVER_DISABLE_MTU_DISCOVERY = "serverDisableMtuDiscovery" const val SERVER_HOP_INTERVAL = "hopInterval" const val SERVER_PRIVATE_KEY = "serverPrivateKey" const val SERVER_INSECURE_CONCURRENCY = "serverInsecureConcurrency" const val SERVER_UDP_RELAY_MODE = "serverUDPRelayMode" const val SERVER_CONGESTION_CONTROLLER = "serverCongestionController" const val SERVER_DISABLE_SNI = "serverDisableSNI" const val SERVER_REDUCE_RTT = "serverReduceRTT" const val ROUTE_NAME = "routeName" const val ROUTE_DOMAIN = "routeDomain" const val ROUTE_IP = "routeIP" const val ROUTE_PORT = "routePort" const val ROUTE_SOURCE_PORT = "routeSourcePort" const val ROUTE_NETWORK = "routeNetwork" const val ROUTE_SOURCE = "routeSource" const val ROUTE_PROTOCOL = "routeProtocol" const val ROUTE_OUTBOUND = "routeOutbound" const val ROUTE_PACKAGES = "routePackages" const val GROUP_NAME = "groupName" const val GROUP_TYPE = "groupType" const val GROUP_ORDER = "groupOrder" const val GROUP_IS_SELECTOR = "groupIsSelector" const val GROUP_FRONT_PROXY = "groupFrontProxy" const val GROUP_LANDING_PROXY = "groupLandingProxy" const val GROUP_SUBSCRIPTION = "groupSubscription" const val SUBSCRIPTION_LINK = "subscriptionLink" const val SUBSCRIPTION_FORCE_RESOLVE = "subscriptionForceResolve" const val SUBSCRIPTION_DEDUPLICATION = "subscriptionDeduplication" const val SUBSCRIPTION_UPDATE = "subscriptionUpdate" const val SUBSCRIPTION_UPDATE_WHEN_CONNECTED_ONLY = "subscriptionUpdateWhenConnectedOnly" const val SUBSCRIPTION_USER_AGENT = "subscriptionUserAgent" const val SUBSCRIPTION_AUTO_UPDATE = "subscriptionAutoUpdate" const val SUBSCRIPTION_AUTO_UPDATE_DELAY = "subscriptionAutoUpdateDelay" // const val APP_TLS_VERSION = "appTLSVersion" const val ENABLE_CLASH_API = "enableClashAPI" } object TunImplementation { const val GVISOR = 0 const val SYSTEM = 1 const val MIXED = 2 } object IPv6Mode { const val DISABLE = 0 const val ENABLE = 1 const val PREFER = 2 const val ONLY = 3 } object GroupType { const val BASIC = 0 const val SUBSCRIPTION = 1 } object GroupOrder { const val ORIGIN = 0 const val BY_NAME = 1 const val BY_DELAY = 2 } object Action { const val SERVICE = "io.nekohasekai.sagernet.SERVICE" const val CLOSE = "io.nekohasekai.sagernet.CLOSE" const val RELOAD = "io.nekohasekai.sagernet.RELOAD" // const val SWITCH_WAKE_LOCK = "io.nekohasekai.sagernet.SWITCH_WAKELOCK" const val RESET_UPSTREAM_CONNECTIONS = "moe.nb4a.RESET_UPSTREAM_CONNECTIONS" } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/QuickToggleShortcut.kt ================================================ /******************************************************************************* * * * Copyright (C) 2017 by Max Lv * * Copyright (C) 2017 by Mygod Studio * * * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . * * * *******************************************************************************/ package io.nekohasekai.sagernet import android.app.Activity 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.sagernet.aidl.ISagerNetService import io.nekohasekai.sagernet.bg.BaseService import io.nekohasekai.sagernet.bg.SagerConnection import io.nekohasekai.sagernet.database.DataStore @Suppress("DEPRECATION") class QuickToggleShortcut : Activity(), SagerConnection.Callback { private val connection = SagerConnection(SagerConnection.CONNECTION_ID_SHORTCUT) private var profileId = -1L 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, QuickToggleShortcut::class.java).setAction(Intent.ACTION_MAIN)) .setIcon(IconCompat.createWithResource(this, R.drawable.ic_qu_shadowsocks_launcher)) .setShortLabel(getString(R.string.quick_toggle)) .build())) finish() } else { profileId = intent.getLongExtra("profile", -1L) connection.connect(this, this) if (Build.VERSION.SDK_INT >= 25) { getSystemService()!!.reportShortcutUsed(if (profileId >= 0) "shortcut-profile-$profileId" else "toggle") } } } override fun onServiceConnected(service: ISagerNetService) { val state = BaseService.State.values()[service.state] when { state.canStop -> { if (profileId == DataStore.selectedProxy || profileId == -1L) { SagerNet.stopService() } else { DataStore.selectedProxy = profileId SagerNet.reloadService() } } state == BaseService.State.Stopped -> { if (profileId >= 0L) DataStore.selectedProxy = profileId SagerNet.startService() } } finish() } override fun stateChanged(state: BaseService.State, profileName: String?, msg: String?) {} override fun onDestroy() { connection.disconnect(this) super.onDestroy() } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/SagerNet.kt ================================================ package io.nekohasekai.sagernet import android.annotation.SuppressLint import android.app.* import android.content.ClipData import android.content.ClipboardManager import android.content.Context import android.content.Intent import android.content.res.Configuration import android.net.ConnectivityManager import android.net.Network import android.os.Build import android.os.PowerManager import android.os.StrictMode import android.os.UserManager import androidx.annotation.RequiresApi import androidx.core.content.ContextCompat import androidx.core.content.getSystemService import go.Seq import io.nekohasekai.sagernet.bg.SagerConnection import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.ktx.Logs import io.nekohasekai.sagernet.ktx.isOss import io.nekohasekai.sagernet.ktx.isPreview import io.nekohasekai.sagernet.ktx.runOnDefaultDispatcher import io.nekohasekai.sagernet.ui.MainActivity import io.nekohasekai.sagernet.utils.* import kotlinx.coroutines.DEBUG_PROPERTY_NAME import kotlinx.coroutines.DEBUG_PROPERTY_VALUE_ON import libcore.Libcore import moe.matsuri.nb4a.NativeInterface import moe.matsuri.nb4a.net.LocalResolverImpl import moe.matsuri.nb4a.utils.JavaUtil import moe.matsuri.nb4a.utils.cleanWebview import java.io.File import androidx.work.Configuration as WorkConfiguration class SagerNet : Application(), WorkConfiguration.Provider { override fun attachBaseContext(base: Context) { super.attachBaseContext(base) application = this } private val nativeInterface = NativeInterface() val externalAssets: File by lazy { getExternalFilesDir(null) ?: filesDir } val process: String = JavaUtil.getProcessName() private val isMainProcess = process == BuildConfig.APPLICATION_ID val isBgProcess = process.endsWith(":bg") override fun onCreate() { super.onCreate() Thread.setDefaultUncaughtExceptionHandler(CrashHandler) if (isMainProcess || isBgProcess) { externalAssets.mkdirs() Seq.setContext(this) Libcore.initCore( process, cacheDir.absolutePath + "/", filesDir.absolutePath + "/", externalAssets.absolutePath + "/", DataStore.logBufSize, DataStore.logLevel > 0, nativeInterface, nativeInterface, LocalResolverImpl ) // fix multi process issue in Android 9+ JavaUtil.handleWebviewDir(this) runOnDefaultDispatcher { PackageCache.register() cleanWebview() } } if (isMainProcess) { Theme.apply(this) Theme.applyNightTheme() runOnDefaultDispatcher { DefaultNetworkListener.start(this) { underlyingNetwork = it } updateNotificationChannels() } } if (BuildConfig.DEBUG) { System.setProperty(DEBUG_PROPERTY_NAME, DEBUG_PROPERTY_VALUE_ON) StrictMode.setVmPolicy( StrictMode.VmPolicy.Builder() .detectLeakedSqlLiteObjects() .detectLeakedClosableObjects() .detectLeakedRegistrationObjects() .penaltyLog() .build() ) } } override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) updateNotificationChannels() } override fun getWorkManagerConfiguration(): WorkConfiguration { return WorkConfiguration.Builder() .setDefaultProcessName("${BuildConfig.APPLICATION_ID}:bg") .build() } override fun onTrimMemory(level: Int) { super.onTrimMemory(level) Libcore.forceGc() } @SuppressLint("InlinedApi") companion object { lateinit var application: SagerNet val isTv by lazy { uiMode.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION } val configureIntent: (Context) -> PendingIntent by lazy { { PendingIntent.getActivity( it, 0, Intent( application, MainActivity::class.java ).setFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT), if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0 ) } } val activity by lazy { application.getSystemService()!! } val clipboard by lazy { application.getSystemService()!! } val connectivity by lazy { application.getSystemService()!! } val notification by lazy { application.getSystemService()!! } val user by lazy { application.getSystemService()!! } val uiMode by lazy { application.getSystemService()!! } val power by lazy { application.getSystemService()!! } fun getClipboardText(): String { return clipboard.primaryClip?.takeIf { it.itemCount > 0 } ?.getItemAt(0)?.text?.toString() ?: "" } fun trySetPrimaryClip(clip: String) = try { clipboard.setPrimaryClip(ClipData.newPlainText(null, clip)) true } catch (e: RuntimeException) { Logs.w(e) false } fun updateNotificationChannels() { if (Build.VERSION.SDK_INT >= 26) @RequiresApi(26) { notification.createNotificationChannels( listOf( NotificationChannel( "service-vpn", application.getText(R.string.service_vpn), if (Build.VERSION.SDK_INT >= 28) NotificationManager.IMPORTANCE_MIN else NotificationManager.IMPORTANCE_LOW ), // #1355 NotificationChannel( "service-proxy", application.getText(R.string.service_proxy), NotificationManager.IMPORTANCE_LOW ), NotificationChannel( "service-subscription", application.getText(R.string.service_subscription), NotificationManager.IMPORTANCE_DEFAULT ), NotificationChannel( "connection-test", application.getText(R.string.connection_test), NotificationManager.IMPORTANCE_DEFAULT ) ) ) } } fun startService() = ContextCompat.startForegroundService( application, Intent(application, SagerConnection.serviceClass) ) fun reloadService() = application.sendBroadcast(Intent(Action.RELOAD).setPackage(application.packageName)) fun stopService() = application.sendBroadcast(Intent(Action.CLOSE).setPackage(application.packageName)) var underlyingNetwork: Network? = null var appVersionNameForDisplay = { var n = BuildConfig.VERSION_NAME if (isPreview) { n += " " + BuildConfig.PRE_VERSION_NAME } else if (!isOss) { n += " ${BuildConfig.FLAVOR}" } if (BuildConfig.DEBUG) { n += " DEBUG" } n }() } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/aidl/SpeedDisplayData.kt ================================================ package io.nekohasekai.sagernet.aidl import android.os.Parcelable import kotlinx.parcelize.Parcelize @Parcelize data class SpeedDisplayData( // Bytes per second var txRateProxy: Long = 0L, var rxRateProxy: Long = 0L, var txRateDirect: Long = 0L, var rxRateDirect: Long = 0L, // Bytes for the current session // Outbound "bypass" usage is not counted var txTotal: Long = 0L, var rxTotal: Long = 0L, ) : Parcelable ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/aidl/TrafficData.kt ================================================ package io.nekohasekai.sagernet.aidl import android.os.Parcelable import kotlinx.parcelize.Parcelize @Parcelize data class TrafficData( var id: Long = 0L, var tx: Long = 0L, var rx: Long = 0L, ) : Parcelable ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/bg/AbstractInstance.kt ================================================ package io.nekohasekai.sagernet.bg import java.io.Closeable interface AbstractInstance : Closeable { fun launch() } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/bg/BaseService.kt ================================================ package io.nekohasekai.sagernet.bg import android.app.Service import android.content.Context import android.content.Intent import android.content.IntentFilter import android.os.* import android.widget.Toast import io.nekohasekai.sagernet.Action import io.nekohasekai.sagernet.BootReceiver import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.SagerNet import io.nekohasekai.sagernet.aidl.ISagerNetService import io.nekohasekai.sagernet.aidl.ISagerNetServiceCallback import io.nekohasekai.sagernet.bg.proto.ProxyInstance import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.database.SagerDatabase import io.nekohasekai.sagernet.ktx.* import io.nekohasekai.sagernet.plugin.PluginManager import io.nekohasekai.sagernet.utils.DefaultNetworkListener import kotlinx.coroutines.* import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import libcore.Libcore import moe.matsuri.nb4a.Protocols import moe.matsuri.nb4a.utils.Util import java.net.UnknownHostException class BaseService { enum class State( val canStop: Boolean = false, val started: Boolean = false, val connected: Boolean = false, ) { /** * Idle state is only used by UI and will never be returned by BaseService. */ Idle, Connecting(true, true, false), Connected(true, true, true), Stopping, Stopped, } interface ExpectedException class Data internal constructor(private val service: Interface) { var state = State.Stopped var proxy: ProxyInstance? = null var notification: ServiceNotification? = null val receiver = broadcastReceiver { ctx, intent -> when (intent.action) { Intent.ACTION_SHUTDOWN -> service.persistStats() Action.RELOAD -> service.reload() // Action.SWITCH_WAKE_LOCK -> runOnDefaultDispatcher { service.switchWakeLock() } PowerManager.ACTION_DEVICE_IDLE_MODE_CHANGED -> { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (SagerNet.power.isDeviceIdleMode) { proxy?.box?.sleep() } else { proxy?.box?.wake() if (DataStore.wakeResetConnections) { Libcore.resetAllConnections(true) } } } } Action.RESET_UPSTREAM_CONNECTIONS -> runOnDefaultDispatcher { Libcore.resetAllConnections(true) runOnMainDispatcher { Util.collapseStatusBar(ctx) Toast.makeText(ctx, "Reset upstream connections done", Toast.LENGTH_SHORT) .show() } } else -> service.stopRunner() } } var closeReceiverRegistered = false val binder = Binder(this) var connectingJob: Job? = null fun changeState(s: State, msg: String? = null) { if (state == s && msg == null) return state = s DataStore.serviceState = s binder.stateChanged(s, msg) } } class Binder(private var data: Data? = null) : ISagerNetService.Stub(), CoroutineScope, AutoCloseable { private val callbacks = object : RemoteCallbackList() { override fun onCallbackDied(callback: ISagerNetServiceCallback?, cookie: Any?) { super.onCallbackDied(callback, cookie) } } val callbackIdMap = mutableMapOf() override val coroutineContext = Dispatchers.Main.immediate + Job() override fun getState(): Int = (data?.state ?: State.Idle).ordinal override fun getProfileName(): String = data?.proxy?.displayProfileName ?: "Idle" override fun registerCallback(cb: ISagerNetServiceCallback, id: Int) { if (id == SagerConnection.CONNECTION_ID_RESTART_BG) { Runtime.getRuntime().exit(0) return } if (!callbackIdMap.contains(cb)) { callbacks.register(cb) } callbackIdMap[cb] = id } private val broadcastMutex = Mutex() suspend fun broadcast(work: (ISagerNetServiceCallback) -> Unit) { broadcastMutex.withLock { val count = callbacks.beginBroadcast() try { repeat(count) { try { work(callbacks.getBroadcastItem(it)) } catch (_: RemoteException) { } catch (_: Exception) { } } } finally { callbacks.finishBroadcast() } } } override fun unregisterCallback(cb: ISagerNetServiceCallback) { callbackIdMap.remove(cb) callbacks.unregister(cb) } override fun urlTest(): Int { if (data?.proxy?.box == null) { error("core not started") } try { return Libcore.urlTest( data!!.proxy!!.box, DataStore.connectionTestURL, 3000 ) } catch (e: Exception) { error(Protocols.genFriendlyMsg(e.readableMessage)) } } fun stateChanged(s: State, msg: String?) = launch { val profileName = profileName broadcast { it.stateChanged(s.ordinal, profileName, msg) } } fun missingPlugin(pluginName: String) = launch { val profileName = profileName broadcast { it.missingPlugin(profileName, pluginName) } } override fun close() { callbacks.kill() cancel() data = null } } interface Interface { val data: Data val tag: String fun createNotification(profileName: String): ServiceNotification fun onBind(intent: Intent): IBinder? = if (intent.action == Action.SERVICE) data.binder else null fun reload() { if (DataStore.selectedProxy == 0L) { stopRunner(false, (this as Context).getString(R.string.profile_empty)) } if (canReloadSelector()) { val ent = SagerDatabase.proxyDao.getById(DataStore.selectedProxy) val tag = data.proxy!!.config.profileTagMap[ent?.id] ?: "" if (tag.isNotBlank() && ent != null) { // select from GUI data.proxy!!.box.selectOutbound(tag) // or select from webui // => selector_OnProxySelected } return } val s = data.state when { s == State.Stopped -> startRunner() s.canStop -> stopRunner(true) else -> Logs.w("Illegal state $s when invoking use") } } fun canReloadSelector(): Boolean { if ((data.proxy?.config?.selectorGroupId ?: -1L) < 0) return false val ent = SagerDatabase.proxyDao.getById(DataStore.selectedProxy) ?: return false val tmpBox = ProxyInstance(ent) tmpBox.buildConfigTmp() if (tmpBox.lastSelectorGroupId == data.proxy?.lastSelectorGroupId) { return true } return false } suspend fun startProcesses() { data.proxy!!.launch() } fun startRunner() { this as Context if (Build.VERSION.SDK_INT >= 26) startForegroundService(Intent(this, javaClass)) else startService(Intent(this, javaClass)) } fun killProcesses() { data.proxy?.close() wakeLock?.apply { release() wakeLock = null } runOnDefaultDispatcher { DefaultNetworkListener.stop(this) } } fun stopRunner(restart: Boolean = false, msg: String? = null) { DataStore.baseService = null DataStore.vpnService = null if (data.state == State.Stopping) return data.notification?.destroy() data.notification = null this as Service data.changeState(State.Stopping) runOnMainDispatcher { data.connectingJob?.cancelAndJoin() // ensure stop connecting first // we use a coroutineScope here to allow clean-up in parallel coroutineScope { killProcesses() val data = data if (data.closeReceiverRegistered) { unregisterReceiver(data.receiver) data.closeReceiverRegistered = false } data.proxy = null } // change the state data.changeState(State.Stopped, msg) // stop the service if nothing has bound to it if (restart) startRunner() else { stopSelf() } } } fun persistStats() { // TODO NEW save app stats? } // networks var upstreamInterfaceName: String? suspend fun preInit() { DefaultNetworkListener.start(this) { SagerNet.connectivity.getLinkProperties(it)?.also { link -> SagerNet.underlyingNetwork = it DataStore.vpnService?.updateUnderlyingNetwork() // val oldName = upstreamInterfaceName if (oldName != link.interfaceName) { upstreamInterfaceName = link.interfaceName } if (oldName != null && upstreamInterfaceName != null && oldName != upstreamInterfaceName) { Logs.d("Network changed: $oldName -> $upstreamInterfaceName") if (DataStore.networkChangeResetConnections) { Libcore.resetAllConnections(true) } } } } } var wakeLock: PowerManager.WakeLock? fun acquireWakeLock() suspend fun lateInit() { wakeLock?.apply { release() wakeLock = null } if (DataStore.acquireWakeLock) { acquireWakeLock() data.notification?.postNotificationWakeLockStatus(true) } else { data.notification?.postNotificationWakeLockStatus(false) } } fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { DataStore.baseService = this val data = data if (data.state != State.Stopped) return Service.START_NOT_STICKY val profile = SagerDatabase.proxyDao.getById(DataStore.selectedProxy) this as Context if (profile == null) { // gracefully shutdown: https://stackoverflow.com/q/47337857/2245107 data.notification = createNotification("") stopRunner(false, getString(R.string.profile_empty)) return Service.START_NOT_STICKY } val proxy = ProxyInstance(profile, this) data.proxy = proxy BootReceiver.enabled = DataStore.persistAcrossReboot if (!data.closeReceiverRegistered) { val filter = IntentFilter().apply { addAction(Action.RELOAD) addAction(Intent.ACTION_SHUTDOWN) addAction(Action.CLOSE) // addAction(Action.SWITCH_WAKE_LOCK) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { addAction(PowerManager.ACTION_DEVICE_IDLE_MODE_CHANGED) } addAction(Action.RESET_UPSTREAM_CONNECTIONS) } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { registerReceiver( data.receiver, filter, "$packageName.SERVICE", null, Context.RECEIVER_EXPORTED ) } else { registerReceiver( data.receiver, filter, "$packageName.SERVICE", null ) } data.closeReceiverRegistered = true } data.changeState(State.Connecting) runOnMainDispatcher { try { data.notification = createNotification(ServiceNotification.genTitle(profile)) Executable.killAll() // clean up old processes preInit() proxy.init() DataStore.currentProfile = profile.id proxy.processes = GuardedProcessPool { Logs.w(it) stopRunner(false, it.readableMessage) } startProcesses() data.changeState(State.Connected) lateInit() } catch (_: CancellationException) { // if the job was cancelled, it is canceller's responsibility to call stopRunner } catch (_: UnknownHostException) { stopRunner(false, getString(R.string.invalid_server)) } catch (e: PluginManager.PluginNotFoundException) { Toast.makeText(this@Interface, e.readableMessage, Toast.LENGTH_SHORT).show() Logs.w(e) data.binder.missingPlugin(e.plugin) stopRunner(false, null) } catch (exc: Throwable) { if (exc.javaClass.name.endsWith("proxyerror")) { // error from golang Logs.w(exc.readableMessage) } else { Logs.w(exc) } stopRunner( false, "${getString(R.string.service_failed)}: ${exc.readableMessage}" ) } finally { data.connectingJob = null } } return Service.START_NOT_STICKY } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/bg/Executable.kt ================================================ package io.nekohasekai.sagernet.bg import android.system.ErrnoException import android.system.Os import android.system.OsConstants import io.nekohasekai.sagernet.ktx.Logs import java.io.File import java.io.IOException import androidx.core.text.isDigitsOnly object Executable { private val EXECUTABLES = setOf( "libtrojan.so", "libtrojan-go.so", "libnaive.so", "libtuic.so", "libhysteria.so" ) fun killAll(alsoKillBg: Boolean = false) { // kill bg may fail for (process in File("/proc").listFiles { _, name -> name.isDigitsOnly() } ?: return) { val exe = File( try { File(process, "cmdline").inputStream().bufferedReader().use { it.readText() } } catch (_: IOException) { continue }.split(Character.MIN_VALUE, limit = 2).first()) if (EXECUTABLES.contains(exe.name) || (alsoKillBg && exe.name.endsWith(":bg"))) try { Os.kill(process.name.toInt(), OsConstants.SIGKILL) Logs.w("SIGKILL ${exe.name} (${process.name}) succeed") } catch (e: ErrnoException) { if (e.errno != OsConstants.ESRCH) { Logs.w("SIGKILL ${exe.absolutePath} (${process.name}) failed") Logs.w(e) } } } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/bg/GuardedProcessPool.kt ================================================ package io.nekohasekai.sagernet.bg import android.os.Build import android.os.SystemClock import android.system.ErrnoException import android.system.Os import android.system.OsConstants import androidx.annotation.MainThread import io.nekohasekai.sagernet.SagerNet import io.nekohasekai.sagernet.ktx.Logs import io.nekohasekai.sagernet.utils.Commandline import kotlinx.coroutines.* import kotlinx.coroutines.channels.Channel import libcore.Libcore import java.io.File import java.io.IOException import java.io.InputStream import kotlin.concurrent.thread class GuardedProcessPool(private val onFatal: suspend (IOException) -> Unit) : CoroutineScope { companion object { private val pid by lazy { Class.forName("java.lang.ProcessManager\$ProcessImpl").getDeclaredField("pid") .apply { isAccessible = true } } } private inner class Guard( private val cmd: List, private val env: Map = mapOf() ) { private lateinit var process: Process private fun streamLogger(input: InputStream, logger: (String) -> Unit) = try { input.bufferedReader().forEachLine(logger) } catch (_: IOException) { } // ignore fun start() { process = ProcessBuilder(cmd).directory(SagerNet.application.noBackupFilesDir).apply { environment().putAll(env) }.start() } @DelicateCoroutinesApi suspend fun looper(onRestartCallback: (suspend () -> Unit)?) { var running = true val cmdName = File(cmd.first()).nameWithoutExtension val exitChannel = Channel() try { while (true) { thread(name = "stderr-$cmdName") { streamLogger(process.errorStream) { Libcore.nekoLogPrintln("[$cmdName] $it") } } thread(name = "stdout-$cmdName") { streamLogger(process.inputStream) { Libcore.nekoLogPrintln("[$cmdName] $it") } // this thread also acts as a daemon thread for waitFor runBlocking { exitChannel.send(process.waitFor()) } } val startTime = SystemClock.elapsedRealtime() val exitCode = exitChannel.receive() running = false when { SystemClock.elapsedRealtime() - startTime < 1000 -> throw IOException( "$cmdName exits too fast (exit code: $exitCode)" ) exitCode == 128 + OsConstants.SIGKILL -> Logs.w("$cmdName was killed") else -> Logs.w(IOException("$cmdName unexpectedly exits with code $exitCode")) } Logs.i("restart process: ${Commandline.toString(cmd)} (last exit code: $exitCode)") start() running = true onRestartCallback?.invoke() } } catch (e: IOException) { Logs.w("error occurred. stop guard: ${Commandline.toString(cmd)}") GlobalScope.launch(Dispatchers.Main) { onFatal(e) } } finally { if (running) withContext(NonCancellable) { // clean-up cannot be cancelled if (Build.VERSION.SDK_INT < 24) { try { Os.kill(pid.get(process) as Int, OsConstants.SIGTERM) } catch (e: ErrnoException) { if (e.errno != OsConstants.ESRCH) Logs.w(e) } catch (e: ReflectiveOperationException) { Logs.w(e) } if (withTimeoutOrNull(500) { exitChannel.receive() } != null) return@withContext } process.destroy() // kill the process if (Build.VERSION.SDK_INT >= 26) { if (withTimeoutOrNull(1000) { exitChannel.receive() } != null) return@withContext process.destroyForcibly() // Force to kill the process if it's still alive } exitChannel.receive() } // otherwise process already exited, nothing to be done } } } override val coroutineContext = Dispatchers.Main.immediate + Job() var processCount = 0 @MainThread fun start( cmd: List, env: MutableMap = mutableMapOf(), onRestartCallback: (suspend () -> Unit)? = null ) { Logs.i("start process: ${Commandline.toString(cmd)}") Guard(cmd, env).apply { start() // if start fails, IOException will be thrown directly launch { looper(onRestartCallback) } } processCount += 1 } @MainThread fun close(scope: CoroutineScope) { cancel() coroutineContext[Job]!!.also { job -> scope.launch { job.cancelAndJoin() } } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/bg/ProxyService.kt ================================================ package io.nekohasekai.sagernet.bg import android.annotation.SuppressLint import android.app.Service import android.content.Intent import android.os.PowerManager import io.nekohasekai.sagernet.SagerNet class ProxyService : Service(), BaseService.Interface { override val data = BaseService.Data(this) override val tag: String get() = "SagerNetProxyService" override fun createNotification(profileName: String): ServiceNotification = ServiceNotification(this, profileName, "service-proxy", true) override var wakeLock: PowerManager.WakeLock? = null override var upstreamInterfaceName: String? = null @SuppressLint("WakelockTimeout") override fun acquireWakeLock() { wakeLock = SagerNet.power.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "sagernet:proxy") .apply { acquire() } } override fun onBind(intent: Intent) = super.onBind(intent) override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int = super.onStartCommand(intent, flags, startId) } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/bg/SagerConnection.kt ================================================ package io.nekohasekai.sagernet.bg import android.content.ComponentName import android.content.Context import android.content.Intent import android.content.ServiceConnection import android.os.IBinder import android.os.RemoteException import io.nekohasekai.sagernet.Action import io.nekohasekai.sagernet.Key import io.nekohasekai.sagernet.aidl.ISagerNetService import io.nekohasekai.sagernet.aidl.ISagerNetServiceCallback import io.nekohasekai.sagernet.aidl.SpeedDisplayData import io.nekohasekai.sagernet.aidl.TrafficData import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.ktx.runOnMainDispatcher class SagerConnection( private var connectionId: Int, private var listenForDeath: Boolean = false ) : ServiceConnection, IBinder.DeathRecipient { companion object { val serviceClass get() = when (DataStore.serviceMode) { Key.MODE_PROXY -> ProxyService::class Key.MODE_VPN -> VpnService::class else -> throw UnknownError() }.java const val CONNECTION_ID_SHORTCUT = 0 const val CONNECTION_ID_TILE = 1 const val CONNECTION_ID_MAIN_ACTIVITY_FOREGROUND = 2 const val CONNECTION_ID_MAIN_ACTIVITY_BACKGROUND = 3 const val CONNECTION_ID_RESTART_BG = 4 var restartingApp = false } interface Callback { // smaller ISagerNetServiceCallback fun cbSpeedUpdate(stats: SpeedDisplayData) {} fun cbTrafficUpdate(data: TrafficData) {} fun cbSelectorUpdate(id: Long) {} fun stateChanged(state: BaseService.State, profileName: String?, msg: String?) fun missingPlugin(profileName: String, pluginName: String) {} fun onServiceConnected(service: ISagerNetService) /** * Different from Android framework, this method will be called even when you call `detachService`. */ fun onServiceDisconnected() {} fun onBinderDied() {} } private var connectionActive = false private var callbackRegistered = false private var callback: Callback? = null private val serviceCallback = object : ISagerNetServiceCallback.Stub() { override fun stateChanged(state: Int, profileName: String?, msg: String?) { if (state < 0) return // skip private val s = BaseService.State.values()[state] DataStore.serviceState = s val callback = callback ?: return runOnMainDispatcher { callback.stateChanged(s, profileName, msg) } } override fun cbSpeedUpdate(stats: SpeedDisplayData) { val callback = callback ?: return runOnMainDispatcher { callback.cbSpeedUpdate(stats) } } override fun cbTrafficUpdate(stats: TrafficData) { val callback = callback ?: return runOnMainDispatcher { callback.cbTrafficUpdate(stats) } } override fun cbSelectorUpdate(id: Long) { val callback = callback ?: return runOnMainDispatcher { callback.cbSelectorUpdate(id) } } override fun missingPlugin(profileName: String, pluginName: String) { val callback = callback ?: return runOnMainDispatcher { callback.missingPlugin(profileName, pluginName) } } } private var binder: IBinder? = null var service: ISagerNetService? = null fun updateConnectionId(id: Int) { connectionId = id try { service?.registerCallback(serviceCallback, id) } catch (e: Exception) { e.printStackTrace() } } override fun onServiceConnected(name: ComponentName?, binder: IBinder) { this.binder = binder val service = ISagerNetService.Stub.asInterface(binder)!! this.service = service try { if (listenForDeath) binder.linkToDeath(this, 0) check(!callbackRegistered) service.registerCallback(serviceCallback, connectionId) callbackRegistered = true } catch (e: RemoteException) { e.printStackTrace() } callback?.onServiceConnected(service) } override fun onServiceDisconnected(name: ComponentName?) { unregisterCallback() callback?.onServiceDisconnected() service = null binder = null } override fun binderDied() { service = null callbackRegistered = false if (!restartingApp) { callback?.also { runOnMainDispatcher { it.onBinderDied() } } } } private fun unregisterCallback() { val service = service if (service != null && callbackRegistered) try { service.unregisterCallback(serviceCallback) } catch (_: RemoteException) { } callbackRegistered = false } fun connect(context: Context, callback: Callback?) { if (connectionActive) return connectionActive = true check(this.callback == null) this.callback = callback val intent = Intent(context, serviceClass).setAction(Action.SERVICE) context.bindService(intent, this, Context.BIND_AUTO_CREATE) } fun disconnect(context: Context) { unregisterCallback() if (connectionActive) try { context.unbindService(this) } catch (_: IllegalArgumentException) { } // ignore connectionActive = false if (listenForDeath) try { binder?.unlinkToDeath(this, 0) } catch (_: NoSuchElementException) { } binder = null service = null callback = null } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/bg/ServiceNotification.kt ================================================ package io.nekohasekai.sagernet.bg import android.app.PendingIntent import android.app.Service import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_SYSTEM_EXEMPTED import android.os.Build import android.text.format.Formatter import android.widget.Toast import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import io.nekohasekai.sagernet.Action import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.SagerNet import io.nekohasekai.sagernet.aidl.SpeedDisplayData import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.database.ProxyEntity import io.nekohasekai.sagernet.database.SagerDatabase import io.nekohasekai.sagernet.ktx.app import io.nekohasekai.sagernet.ktx.getColorAttr import io.nekohasekai.sagernet.ktx.runOnMainDispatcher import io.nekohasekai.sagernet.ui.SwitchActivity import io.nekohasekai.sagernet.utils.Theme import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock /** * User can customize visibility of notification since Android 8. * The default visibility: * * Android 8.x: always visible due to system limitations * VPN: always invisible because of VPN notification/icon * Other: always visible * * See also: https://github.com/aosp-mirror/platform_frameworks_base/commit/070d142993403cc2c42eca808ff3fafcee220ac4 */ class ServiceNotification( private val service: BaseService.Interface, title: String, channel: String, visible: Boolean = false, ) : BroadcastReceiver() { companion object { const val notificationId = 1 val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0 fun genTitle(ent: ProxyEntity): String { val gn = if (DataStore.showGroupInNotification) SagerDatabase.groupDao.getById(ent.groupId)?.displayName() else null return if (gn == null) ent.displayName() else "[$gn] ${ent.displayName()}" } } var listenPostSpeed = true suspend fun postNotificationSpeedUpdate(stats: SpeedDisplayData) { useBuilder { if (showDirectSpeed) { val speedDetail = (service as Context).getString( R.string.speed_detail, service.getString( R.string.speed, Formatter.formatFileSize(service, stats.txRateProxy) ), service.getString( R.string.speed, Formatter.formatFileSize(service, stats.rxRateProxy) ), service.getString( R.string.speed, Formatter.formatFileSize(service, stats.txRateDirect) ), service.getString( R.string.speed, Formatter.formatFileSize(service, stats.rxRateDirect) ) ) it.setStyle(NotificationCompat.BigTextStyle().bigText(speedDetail)) it.setContentText(speedDetail) } else { val speedSimple = (service as Context).getString( R.string.traffic, service.getString( R.string.speed, Formatter.formatFileSize(service, stats.txRateProxy) ), service.getString( R.string.speed, Formatter.formatFileSize(service, stats.rxRateProxy) ) ) it.setContentText(speedSimple) } it.setSubText( service.getString( R.string.traffic, Formatter.formatFileSize(service, stats.txTotal), Formatter.formatFileSize(service, stats.rxTotal) ) ) } update() } suspend fun postNotificationTitle(newTitle: String) { useBuilder { it.setContentTitle(newTitle) } update() } suspend fun postNotificationWakeLockStatus(acquired: Boolean) { updateActions() useBuilder { it.priority = if (acquired) NotificationCompat.PRIORITY_HIGH else NotificationCompat.PRIORITY_LOW } update() } private val showDirectSpeed = DataStore.showDirectSpeed private val builder = NotificationCompat.Builder(service as Context, channel) .setWhen(0) .setTicker(service.getString(R.string.forward_success)) .setContentTitle(title) .setOnlyAlertOnce(true) .setContentIntent(SagerNet.configureIntent(service)) .setSmallIcon(R.drawable.ic_service_active) .setCategory(NotificationCompat.CATEGORY_SERVICE) .setPriority(if (visible) NotificationCompat.PRIORITY_LOW else NotificationCompat.PRIORITY_MIN) private val buildLock = Mutex() private suspend fun useBuilder(f: (NotificationCompat.Builder) -> Unit) { buildLock.withLock { f(builder) } } init { service as Context Theme.apply(app) Theme.apply(service) builder.color = service.getColorAttr(R.attr.colorPrimary) service.registerReceiver(this, IntentFilter().apply { addAction(Intent.ACTION_SCREEN_ON) addAction(Intent.ACTION_SCREEN_OFF) }) runOnMainDispatcher { updateActions() show() } } private suspend fun updateActions() { service as Context useBuilder { it.clearActions() val closeAction = NotificationCompat.Action.Builder( 0, service.getText(R.string.stop), PendingIntent.getBroadcast( service, 0, Intent(Action.CLOSE).setPackage(service.packageName), flags ) ).setShowsUserInterface(false).build() it.addAction(closeAction) val switchAction = NotificationCompat.Action.Builder( 0, service.getString(R.string.action_switch), PendingIntent.getActivity( service, 0, Intent(service, SwitchActivity::class.java), flags ) ).setShowsUserInterface(false).build() it.addAction(switchAction) val resetUpstreamAction = NotificationCompat.Action.Builder( 0, service.getString(R.string.reset_connections), PendingIntent.getBroadcast( service, 0, Intent(Action.RESET_UPSTREAM_CONNECTIONS), flags ) ).setShowsUserInterface(false).build() it.addAction(resetUpstreamAction) } } override fun onReceive(context: Context, intent: Intent) { if (service.data.state == BaseService.State.Connected) { listenPostSpeed = intent.action == Intent.ACTION_SCREEN_ON } } private suspend fun show() = useBuilder { try { if (Build.VERSION.SDK_INT >= 34) { (service as Service).startForeground( notificationId, it.build(), FOREGROUND_SERVICE_TYPE_SYSTEM_EXEMPTED ) } else { (service as Service).startForeground(notificationId, it.build()) } } catch (e: Exception) { Toast.makeText( SagerNet.application, "startForeground: $e", Toast.LENGTH_LONG ).show() } } private suspend fun update() = useBuilder { NotificationManagerCompat.from(service as Service).notify(notificationId, it.build()) } fun destroy() { listenPostSpeed = false if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { (service as Service).stopForeground(Service.STOP_FOREGROUND_REMOVE) } else { (service as Service).stopForeground(true) } service.unregisterReceiver(this) } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/bg/SubscriptionUpdater.kt ================================================ package io.nekohasekai.sagernet.bg import android.content.Context import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.work.CoroutineWorker import androidx.work.ExistingPeriodicWorkPolicy.UPDATE import androidx.work.PeriodicWorkRequest import androidx.work.WorkerParameters import androidx.work.multiprocess.RemoteWorkManager import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.database.SagerDatabase import io.nekohasekai.sagernet.group.GroupUpdater import io.nekohasekai.sagernet.ktx.Logs import io.nekohasekai.sagernet.ktx.app import java.util.concurrent.TimeUnit object SubscriptionUpdater { private const val WORK_NAME = "SubscriptionUpdater" suspend fun reconfigureUpdater() { RemoteWorkManager.getInstance(app).cancelUniqueWork(WORK_NAME) val subscriptions = SagerDatabase.groupDao.subscriptions() .filter { it.subscription!!.autoUpdate } if (subscriptions.isEmpty()) return // PeriodicWorkRequest.MIN_PERIODIC_INTERVAL_MILLIS var minDelay = subscriptions.minByOrNull { it.subscription!!.autoUpdateDelay }!!.subscription!!.autoUpdateDelay.toLong() val now = System.currentTimeMillis() / 1000L var minInitDelay = subscriptions.minOf { now - it.subscription!!.lastUpdated - (minDelay * 60) } if (minDelay < 15) minDelay = 15 if (minInitDelay > 60) minInitDelay = 60 // main process RemoteWorkManager.getInstance(app).enqueueUniquePeriodicWork( WORK_NAME, UPDATE, PeriodicWorkRequest.Builder(UpdateTask::class.java, minDelay, TimeUnit.MINUTES) .apply { if (minInitDelay > 0) setInitialDelay(minInitDelay, TimeUnit.SECONDS) } .build() ) } class UpdateTask( appContext: Context, params: WorkerParameters ) : CoroutineWorker(appContext, params) { val nm = NotificationManagerCompat.from(applicationContext) val notification = NotificationCompat.Builder(applicationContext, "service-subscription") .setWhen(0) .setTicker(applicationContext.getString(R.string.forward_success)) .setContentTitle(applicationContext.getString(R.string.subscription_update)) .setSmallIcon(R.drawable.ic_service_active) .setCategory(NotificationCompat.CATEGORY_SERVICE) override suspend fun doWork(): Result { var subscriptions = SagerDatabase.groupDao.subscriptions().filter { it.subscription!!.autoUpdate } if (!DataStore.serviceState.connected) { Logs.d("work: not connected") subscriptions = subscriptions.filter { !it.subscription!!.updateWhenConnectedOnly } } if (subscriptions.isNotEmpty()) for (profile in subscriptions) { val subscription = profile.subscription!! if (((System.currentTimeMillis() / 1000).toInt() - subscription.lastUpdated) < subscription.autoUpdateDelay * 60) { Logs.d("work: not updating " + profile.displayName()) continue } Logs.d("work: updating " + profile.displayName()) notification.setContentText( applicationContext.getString( R.string.subscription_update_message, profile.displayName() ) ) nm.notify(2, notification.build()) GroupUpdater.executeUpdate(profile, false) } nm.cancel(2) return Result.success() } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/bg/TileService.kt ================================================ package io.nekohasekai.sagernet.bg import android.graphics.drawable.Icon import android.service.quicksettings.Tile import androidx.annotation.RequiresApi import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.SagerNet import io.nekohasekai.sagernet.aidl.ISagerNetService import io.nekohasekai.sagernet.database.SagerDatabase import android.service.quicksettings.TileService as BaseTileService @RequiresApi(24) class TileService : BaseTileService(), SagerConnection.Callback { private val iconIdle by lazy { Icon.createWithResource(this, R.drawable.ic_service_idle) } private val iconBusy by lazy { Icon.createWithResource(this, R.drawable.ic_service_busy) } private val iconConnected by lazy { Icon.createWithResource(this, R.drawable.ic_service_active) } private var tapPending = false private val connection = SagerConnection(SagerConnection.CONNECTION_ID_TILE) override fun stateChanged(state: BaseService.State, profileName: String?, msg: String?) = updateTile(state, profileName) override fun onServiceConnected(service: ISagerNetService) { updateTile(BaseService.State.values()[service.state], service.profileName) if (tapPending) { tapPending = false onClick() } } override fun cbSelectorUpdate(id: Long) { val profile = SagerDatabase.proxyDao.getById(id) ?: return updateTile(BaseService.State.Connected, profile.displayName()) } override fun onStartListening() { super.onStartListening() connection.connect(this, this) } override fun onStopListening() { connection.disconnect(this) super.onStopListening() } override fun onClick() { if (isLocked) unlockAndRun(this::toggle) else toggle() } private fun updateTile(serviceState: BaseService.State, profileName: String?) { qsTile?.apply { label = null when (serviceState) { BaseService.State.Idle -> error("serviceState") BaseService.State.Connecting -> { icon = iconBusy state = Tile.STATE_ACTIVE } BaseService.State.Connected -> { icon = iconConnected label = profileName state = Tile.STATE_ACTIVE } BaseService.State.Stopping -> { icon = iconBusy state = Tile.STATE_UNAVAILABLE } BaseService.State.Stopped -> { icon = iconIdle state = Tile.STATE_INACTIVE } } label = label ?: getString(R.string.app_name) updateTile() } } private fun toggle() { val service = connection.service if (service == null) tapPending = true else BaseService.State.values()[service.state].let { state -> when { state.canStop -> SagerNet.stopService() state == BaseService.State.Stopped -> SagerNet.startService() } } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/bg/VpnService.kt ================================================ package io.nekohasekai.sagernet.bg import android.Manifest import android.annotation.SuppressLint import android.app.Service import android.content.Intent import android.content.pm.PackageManager import android.net.ProxyInfo import android.os.Build import android.os.ParcelFileDescriptor import android.os.PowerManager import io.nekohasekai.sagernet.* import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.fmt.LOCALHOST import io.nekohasekai.sagernet.fmt.hysteria.HysteriaBean import io.nekohasekai.sagernet.ktx.* import io.nekohasekai.sagernet.ui.VpnRequestActivity import io.nekohasekai.sagernet.utils.Subnet import android.net.VpnService as BaseVpnService class VpnService : BaseVpnService(), BaseService.Interface { companion object { const val PRIVATE_VLAN4_CLIENT = "172.19.0.1" const val PRIVATE_VLAN4_ROUTER = "172.19.0.2" const val FAKEDNS_VLAN4_CLIENT = "198.18.0.0" const val PRIVATE_VLAN6_CLIENT = "fdfe:dcba:9876::1" const val PRIVATE_VLAN6_ROUTER = "fdfe:dcba:9876::2" } var conn: ParcelFileDescriptor? = null private var metered = false override var upstreamInterfaceName: String? = null override suspend fun startProcesses() { DataStore.vpnService = this super.startProcesses() // launch proxy instance } override var wakeLock: PowerManager.WakeLock? = null @SuppressLint("WakelockTimeout") override fun acquireWakeLock() { wakeLock = SagerNet.power.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "sagernet:vpn") .apply { acquire() } } @Suppress("EXPERIMENTAL_API_USAGE") override fun killProcesses() { conn?.close() conn = null super.killProcesses() } override fun onBind(intent: Intent) = when (intent.action) { SERVICE_INTERFACE -> super.onBind(intent) else -> super.onBind(intent) } override val data = BaseService.Data(this) override val tag = "SagerNetVpnService" override fun createNotification(profileName: String) = ServiceNotification(this, profileName, "service-vpn") override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { if (DataStore.serviceMode == Key.MODE_VPN) { if (prepare(this) != null) { startActivity( Intent( this, VpnRequestActivity::class.java ).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) ) } else return super.onStartCommand(intent, flags, startId) } stopRunner() return Service.START_NOT_STICKY } inner class NullConnectionException : NullPointerException(), BaseService.ExpectedException { override fun getLocalizedMessage() = getString(R.string.reboot_required) } fun startVpn(tunOptionsJson: String, tunPlatformOptionsJson: String): Int { // Logs.d(tunOptionsJson) // Logs.d(tunPlatformOptionsJson) // val tunOptions = JSONObject(tunOptionsJson) // address & route & MTU ...... use NB4A GUI config val builder = Builder().setConfigureIntent(SagerNet.configureIntent(this)) .setSession(getString(R.string.app_name)) .setMtu(DataStore.mtu) val ipv6Mode = DataStore.ipv6Mode // address builder.addAddress(PRIVATE_VLAN4_CLIENT, 30) if (ipv6Mode != IPv6Mode.DISABLE) { builder.addAddress(PRIVATE_VLAN6_CLIENT, 126) } builder.addDnsServer(PRIVATE_VLAN4_ROUTER) // route if (DataStore.bypassLan) { resources.getStringArray(R.array.bypass_private_route).forEach { val subnet = Subnet.fromString(it)!! builder.addRoute(subnet.address.hostAddress!!, subnet.prefixSize) } builder.addRoute(PRIVATE_VLAN4_ROUTER, 32) builder.addRoute(FAKEDNS_VLAN4_CLIENT, 15) // https://issuetracker.google.com/issues/149636790 if (ipv6Mode != IPv6Mode.DISABLE) { builder.addRoute("2000::", 3) } } else { builder.addRoute("0.0.0.0", 0) if (ipv6Mode != IPv6Mode.DISABLE) { builder.addRoute("::", 0) } } updateUnderlyingNetwork(builder) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) builder.setMetered(metered) // app route val packageName = packageName val proxyApps = DataStore.proxyApps var bypass = DataStore.bypass val workaroundSYSTEM = false /* DataStore.tunImplementation == TunImplementation.SYSTEM */ val needBypassRootUid = workaroundSYSTEM || data.proxy!!.config.trafficMap.values.any { it[0].hysteriaBean?.protocol == HysteriaBean.PROTOCOL_FAKETCP } if (proxyApps || needBypassRootUid) { val individual = mutableSetOf() val allApps by lazy { packageManager.getInstalledPackages(PackageManager.GET_PERMISSIONS).filter { when (it.packageName) { packageName -> false "android" -> true else -> it.requestedPermissions?.contains(Manifest.permission.INTERNET) == true } }.map { it.packageName } } if (proxyApps) { individual.addAll(DataStore.individual.split('\n').filter { it.isNotBlank() }) if (bypass && needBypassRootUid) { val individualNew = allApps.toMutableList() individualNew.removeAll(individual) individual.clear() individual.addAll(individualNew) bypass = false } } else { individual.addAll(allApps) bypass = false } val added = mutableListOf() individual.apply { // Allow Matsuri itself using VPN. remove(packageName) if (!bypass) add(packageName) }.forEach { try { if (bypass) { builder.addDisallowedApplication(it) } else { builder.addAllowedApplication(it) } added.add(it) } catch (ex: PackageManager.NameNotFoundException) { Logs.w(ex) } } if (bypass) { Logs.d("Add bypass: ${added.joinToString(", ")}") } else { Logs.d("Add allow: ${added.joinToString(", ")}") } } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && DataStore.appendHttpProxy) { builder.setHttpProxy(ProxyInfo.buildDirectProxy(LOCALHOST, DataStore.mixedPort)) } metered = DataStore.meteredNetwork if (Build.VERSION.SDK_INT >= 29) builder.setMetered(metered) conn = builder.establish() ?: throw NullConnectionException() return conn!!.fd } fun updateUnderlyingNetwork(builder: Builder? = null) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) { SagerNet.underlyingNetwork?.let { builder?.setUnderlyingNetworks(arrayOf(SagerNet.underlyingNetwork)) ?: setUnderlyingNetworks(arrayOf(SagerNet.underlyingNetwork)) } } } override fun onRevoke() = stopRunner() override fun onDestroy() { DataStore.vpnService = null super.onDestroy() data.binder.close() } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/bg/proto/BoxInstance.kt ================================================ package io.nekohasekai.sagernet.bg.proto import android.os.SystemClock import io.nekohasekai.sagernet.SagerNet import io.nekohasekai.sagernet.bg.AbstractInstance import io.nekohasekai.sagernet.bg.GuardedProcessPool import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.database.ProxyEntity import io.nekohasekai.sagernet.fmt.ConfigBuildResult import io.nekohasekai.sagernet.fmt.buildConfig import io.nekohasekai.sagernet.fmt.hysteria.HysteriaBean import io.nekohasekai.sagernet.fmt.hysteria.buildHysteria1Config import io.nekohasekai.sagernet.fmt.mieru.MieruBean import io.nekohasekai.sagernet.fmt.mieru.buildMieruConfig import io.nekohasekai.sagernet.fmt.naive.NaiveBean import io.nekohasekai.sagernet.fmt.naive.buildNaiveConfig import io.nekohasekai.sagernet.fmt.trojan_go.TrojanGoBean import io.nekohasekai.sagernet.fmt.trojan_go.buildTrojanGoConfig import io.nekohasekai.sagernet.ktx.* import io.nekohasekai.sagernet.plugin.PluginManager import kotlinx.coroutines.* import libcore.BoxInstance import libcore.Libcore import moe.matsuri.nb4a.net.LocalResolverImpl import java.io.File abstract class BoxInstance( val profile: ProxyEntity ) : AbstractInstance { lateinit var config: ConfigBuildResult lateinit var box: BoxInstance val pluginPath = hashMapOf() val pluginConfigs = hashMapOf>() val externalInstances = hashMapOf() open lateinit var processes: GuardedProcessPool private var cacheFiles = ArrayList() fun isInitialized(): Boolean { return ::config.isInitialized && ::box.isInitialized } protected fun initPlugin(name: String): PluginManager.InitResult { return pluginPath.getOrPut(name) { PluginManager.init(name)!! } } protected open fun buildConfig() { config = buildConfig(profile) } protected open suspend fun loadConfig() { box = Libcore.newSingBoxInstance(config.config, LocalResolverImpl) } open suspend fun init() { buildConfig() for ((chain) in config.externalIndex) { chain.entries.forEachIndexed { index, (port, profile) -> when (val bean = profile.requireBean()) { is TrojanGoBean -> { initPlugin("trojan-go-plugin") pluginConfigs[port] = profile.type to bean.buildTrojanGoConfig(port) } is MieruBean -> { initPlugin("mieru-plugin") pluginConfigs[port] = profile.type to bean.buildMieruConfig(port) } is NaiveBean -> { initPlugin("naive-plugin") pluginConfigs[port] = profile.type to bean.buildNaiveConfig(port) } is HysteriaBean -> { initPlugin("hysteria-plugin") pluginConfigs[port] = profile.type to bean.buildHysteria1Config(port) { File( app.cacheDir, "hysteria_" + SystemClock.elapsedRealtime() + ".ca" ).apply { parentFile?.mkdirs() cacheFiles.add(this) } } } } } } loadConfig() } override fun launch() { // TODO move, this is not box val cacheDir = File(SagerNet.application.cacheDir, "tmpcfg") cacheDir.mkdirs() for ((chain) in config.externalIndex) { chain.entries.forEachIndexed { index, (port, profile) -> val bean = profile.requireBean() val needChain = index != chain.size - 1 val (profileType, config) = pluginConfigs[port] ?: (0 to "") when { externalInstances.containsKey(port) -> { externalInstances[port]!!.launch() } bean is TrojanGoBean -> { val configFile = File( cacheDir, "trojan_go_" + SystemClock.elapsedRealtime() + ".json" ) configFile.parentFile?.mkdirs() configFile.writeText(config) cacheFiles.add(configFile) val commands = mutableListOf( initPlugin("trojan-go-plugin").path, "-config", configFile.absolutePath ) processes.start(commands) } bean is MieruBean -> { val configFile = File( cacheDir, "mieru_" + SystemClock.elapsedRealtime() + ".json" ) configFile.parentFile?.mkdirs() configFile.writeText(config) cacheFiles.add(configFile) val envMap = mutableMapOf() envMap["MIERU_CONFIG_JSON_FILE"] = configFile.absolutePath envMap["MIERU_PROTECT_PATH"] = "protect_path" val commands = mutableListOf( initPlugin("mieru-plugin").path, "run", ) processes.start(commands, envMap) } bean is NaiveBean -> { val configFile = File( cacheDir, "naive_" + SystemClock.elapsedRealtime() + ".json" ) configFile.parentFile?.mkdirs() configFile.writeText(config) cacheFiles.add(configFile) val envMap = mutableMapOf() if (bean.certificates.isNotBlank()) { val certFile = File( cacheDir, "naive_" + SystemClock.elapsedRealtime() + ".crt" ) certFile.parentFile?.mkdirs() certFile.writeText(bean.certificates) cacheFiles.add(certFile) envMap["SSL_CERT_FILE"] = certFile.absolutePath } val commands = mutableListOf( initPlugin("naive-plugin").path, configFile.absolutePath ) processes.start(commands, envMap) } bean is HysteriaBean -> { val configFile = File( cacheDir, "hysteria_" + SystemClock.elapsedRealtime() + ".json" ) configFile.parentFile?.mkdirs() configFile.writeText(config) cacheFiles.add(configFile) val commands = mutableListOf( initPlugin("hysteria-plugin").path, "--no-check", "--config", configFile.absolutePath, "--log-level", if (DataStore.logLevel > 0) "trace" else "warn", "client" ) if (bean.protocol == HysteriaBean.PROTOCOL_FAKETCP) { commands.addAll(0, listOf("su", "-c")) } processes.start(commands) } } } } box.start() } @Suppress("EXPERIMENTAL_API_USAGE") override fun close() { for (instance in externalInstances.values) { runCatching { instance.close() } } cacheFiles.removeAll { it.delete(); true } if (::processes.isInitialized) processes.close(GlobalScope + Dispatchers.IO) if (::box.isInitialized) { box.close() } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/bg/proto/ProxyInstance.kt ================================================ package io.nekohasekai.sagernet.bg.proto import io.nekohasekai.sagernet.BuildConfig import io.nekohasekai.sagernet.bg.BaseService import io.nekohasekai.sagernet.bg.ServiceNotification import io.nekohasekai.sagernet.database.ProxyEntity import io.nekohasekai.sagernet.ktx.Logs import io.nekohasekai.sagernet.ktx.runOnDefaultDispatcher import kotlinx.coroutines.runBlocking import moe.matsuri.nb4a.utils.JavaUtil class ProxyInstance(profile: ProxyEntity, var service: BaseService.Interface? = null) : BoxInstance(profile) { var notTmp = true var lastSelectorGroupId = -1L var displayProfileName = ServiceNotification.genTitle(profile) // for TrafficLooper var looper: TrafficLooper? = null override fun buildConfig() { super.buildConfig() lastSelectorGroupId = super.config.selectorGroupId // if (notTmp) Logs.d(config.config) if (notTmp && BuildConfig.DEBUG) Logs.d(JavaUtil.gson.toJson(config.trafficMap)) } // only use this in temporary instance fun buildConfigTmp() { notTmp = false buildConfig() } override suspend fun init() { super.init() pluginConfigs.forEach { (_, plugin) -> val (_, content) = plugin Logs.d(content) } } override suspend fun loadConfig() { super.loadConfig() } override fun launch() { box.setAsMain() super.launch() // start box runOnDefaultDispatcher { looper = service?.let { TrafficLooper(it.data, this) } looper?.start() } } override fun close() { super.close() runBlocking { looper?.stop() looper = null } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/bg/proto/TestInstance.kt ================================================ package io.nekohasekai.sagernet.bg.proto import io.nekohasekai.sagernet.BuildConfig import io.nekohasekai.sagernet.bg.GuardedProcessPool import io.nekohasekai.sagernet.database.ProxyEntity import io.nekohasekai.sagernet.fmt.buildConfig import io.nekohasekai.sagernet.ktx.Logs import io.nekohasekai.sagernet.ktx.runOnDefaultDispatcher import io.nekohasekai.sagernet.ktx.tryResume import io.nekohasekai.sagernet.ktx.tryResumeWithException import kotlinx.coroutines.delay import libcore.Libcore import moe.matsuri.nb4a.net.LocalResolverImpl import kotlin.coroutines.suspendCoroutine class TestInstance(profile: ProxyEntity, val link: String, private val timeout: Int) : BoxInstance(profile) { suspend fun doTest(): Int { return suspendCoroutine { c -> processes = GuardedProcessPool { Logs.w(it) c.tryResumeWithException(it) } runOnDefaultDispatcher { use { try { init() launch() if (processes.processCount > 0) { // wait for plugin start delay(500) } c.tryResume(Libcore.urlTest(box, link, timeout)) } catch (e: Exception) { c.tryResumeWithException(e) } } } } } override fun buildConfig() { config = buildConfig(profile, true) } override suspend fun loadConfig() { // don't call destroyAllJsi here if (BuildConfig.DEBUG) Logs.d(config.config) box = Libcore.newSingBoxInstance(config.config, LocalResolverImpl) } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/bg/proto/TrafficLooper.kt ================================================ package io.nekohasekai.sagernet.bg.proto import io.nekohasekai.sagernet.aidl.SpeedDisplayData import io.nekohasekai.sagernet.aidl.TrafficData import io.nekohasekai.sagernet.bg.BaseService import io.nekohasekai.sagernet.bg.SagerConnection import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.database.ProfileManager import io.nekohasekai.sagernet.fmt.TAG_BYPASS import io.nekohasekai.sagernet.fmt.TAG_PROXY import io.nekohasekai.sagernet.ktx.Logs import io.nekohasekai.sagernet.ktx.runOnDefaultDispatcher import kotlinx.coroutines.* class TrafficLooper ( val data: BaseService.Data, private val sc: CoroutineScope ) { private var job: Job? = null private val idMap = mutableMapOf() // id to 1 data private val tagMap = mutableMapOf() // tag to 1 data suspend fun stop() { job?.cancel() // finally traffic post if (!DataStore.profileTrafficStatistics) return val traffic = mutableMapOf() data.proxy?.config?.trafficMap?.forEach { (_, ents) -> for (ent in ents) { val item = idMap[ent.id] ?: return@forEach ent.rx = item.rx ent.tx = item.tx ProfileManager.updateProfile(ent) // update DB traffic[ent.id] = TrafficData( id = ent.id, rx = ent.rx, tx = ent.tx, ) } } data.binder.broadcast { b -> for (t in traffic) { b.cbTrafficUpdate(t.value) } } Logs.d("finally traffic post done") } fun start() { job = sc.launch { loop() } } var selectorNowId = -114514L var selectorNowFakeTag = "" fun selectMain(id: Long) { Logs.d("select traffic count $TAG_PROXY to $id, old id is $selectorNowId") val oldData = idMap[selectorNowId] val newData = idMap[id] ?: return oldData?.apply { tag = selectorNowFakeTag ignore = true // post traffic when switch if (DataStore.profileTrafficStatistics) { data.proxy?.config?.trafficMap?.get(tag)?.firstOrNull()?.let { it.rx = rx it.tx = tx runOnDefaultDispatcher { ProfileManager.updateProfile(it) // update DB } } } } selectorNowFakeTag = newData.tag selectorNowId = id newData.apply { tag = TAG_PROXY ignore = false } } private suspend fun loop() { val delayMs = DataStore.speedInterval.toLong() val showDirectSpeed = DataStore.showDirectSpeed val profileTrafficStatistics = DataStore.profileTrafficStatistics if (delayMs == 0L) return var trafficUpdater: TrafficUpdater? = null var proxy: ProxyInstance? // for display val itemBypass = TrafficUpdater.TrafficLooperData(tag = TAG_BYPASS) while (sc.isActive) { proxy = data.proxy if (proxy == null) { delay(delayMs) continue } if (trafficUpdater == null) { if (!proxy.isInitialized()) continue idMap.clear() idMap[-1] = itemBypass // val tags = hashSetOf(TAG_PROXY, TAG_BYPASS) proxy.config.trafficMap.forEach { (tag, ents) -> tags.add(tag) for (ent in ents) { val item = TrafficUpdater.TrafficLooperData( tag = tag, rx = ent.rx, tx = ent.tx, rxBase = ent.rx, txBase = ent.tx, ignore = proxy.config.selectorGroupId >= 0L, ) idMap[ent.id] = item tagMap[tag] = item Logs.d("traffic count $tag to ${ent.id}") } } if (proxy.config.selectorGroupId >= 0L) { selectMain(proxy.config.mainEntId) } // trafficUpdater = TrafficUpdater( box = proxy.box, items = idMap.values.toList() ) proxy.box.setV2rayStats(tags.joinToString("\n")) } trafficUpdater.updateAll() if (!sc.isActive) return // add all non-bypass to "main" var mainTxRate = 0L var mainRxRate = 0L var mainTx = 0L var mainRx = 0L tagMap.forEach { (_, it) -> if (!it.ignore) { mainTxRate += it.txRate mainRxRate += it.rxRate } mainTx += it.tx - it.txBase mainRx += it.rx - it.rxBase } // speed val speed = SpeedDisplayData( mainTxRate, mainRxRate, if (showDirectSpeed) itemBypass.txRate else 0L, if (showDirectSpeed) itemBypass.rxRate else 0L, mainTx, mainRx ) // broadcast (MainActivity) if (data.state == BaseService.State.Connected && data.binder.callbackIdMap.containsValue(SagerConnection.CONNECTION_ID_MAIN_ACTIVITY_FOREGROUND) ) { data.binder.broadcast { b -> if (data.binder.callbackIdMap[b] == SagerConnection.CONNECTION_ID_MAIN_ACTIVITY_FOREGROUND) { b.cbSpeedUpdate(speed) if (profileTrafficStatistics) { idMap.forEach { (id, item) -> b.cbTrafficUpdate( TrafficData(id = id, rx = item.rx, tx = item.tx) // display ) } } } } } // ServiceNotification data.notification?.apply { if (listenPostSpeed) postNotificationSpeedUpdate(speed) } delay(delayMs) } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/bg/proto/TrafficUpdater.kt ================================================ package io.nekohasekai.sagernet.bg.proto class TrafficUpdater( private val box: libcore.BoxInstance, val items: List, // contain "bypass" ) { class TrafficLooperData( // Don't associate proxyEntity var tag: String, var tx: Long = 0, var rx: Long = 0, var txBase: Long = 0, var rxBase: Long = 0, var txRate: Long = 0, var rxRate: Long = 0, var lastUpdate: Long = 0, var ignore: Boolean = false, ) private fun updateOne(item: TrafficLooperData): TrafficLooperData { // last update val now = System.currentTimeMillis() val interval = now - item.lastUpdate item.lastUpdate = now if (interval <= 0) return item.apply { rxRate = 0 txRate = 0 } // query val tx = box.queryStats(item.tag, "uplink") val rx = box.queryStats(item.tag, "downlink") // add diff item.rx += rx item.tx += tx item.rxRate = rx * 1000 / interval item.txRate = tx * 1000 / interval // return diff return TrafficLooperData( tag = item.tag, rx = rx, tx = tx, rxRate = item.rxRate, txRate = item.txRate, ) } suspend fun updateAll() { val updated = mutableMapOf() // diffs items.forEach { item -> if (item.ignore) return@forEach var diff = updated[item.tag] // query a tag only once if (diff == null) { diff = updateOne(item) updated[item.tag] = diff } else { item.rx += diff.rx item.tx += diff.tx item.rxRate = diff.rxRate item.txRate = diff.txRate } } // Logs.d(JavaUtil.gson.toJson(items)) // Logs.d(JavaUtil.gson.toJson(updated)) } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/bg/proto/UrlTest.kt ================================================ package io.nekohasekai.sagernet.bg.proto import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.database.ProxyEntity class UrlTest { val link = DataStore.connectionTestURL private val timeout = 5000 suspend fun doTest(profile: ProxyEntity): Int { return TestInstance(profile, link, timeout).doTest() } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/database/DataStore.kt ================================================ package io.nekohasekai.sagernet.database import android.os.Binder import androidx.preference.PreferenceDataStore import io.nekohasekai.sagernet.CONNECTION_TEST_URL import io.nekohasekai.sagernet.GroupType import io.nekohasekai.sagernet.IPv6Mode import io.nekohasekai.sagernet.Key import io.nekohasekai.sagernet.TunImplementation import io.nekohasekai.sagernet.bg.BaseService import io.nekohasekai.sagernet.bg.VpnService import io.nekohasekai.sagernet.database.preference.OnPreferenceDataStoreChangeListener import io.nekohasekai.sagernet.database.preference.PublicDatabase import io.nekohasekai.sagernet.database.preference.RoomPreferenceDataStore import io.nekohasekai.sagernet.ktx.boolean import io.nekohasekai.sagernet.ktx.int import io.nekohasekai.sagernet.ktx.long import io.nekohasekai.sagernet.ktx.parsePort import io.nekohasekai.sagernet.ktx.string import io.nekohasekai.sagernet.ktx.stringToInt import io.nekohasekai.sagernet.ktx.stringToIntIfExists import moe.matsuri.nb4a.TempDatabase object DataStore : OnPreferenceDataStoreChangeListener { // share service state in main & bg process @Volatile var serviceState = BaseService.State.Idle val configurationStore = RoomPreferenceDataStore(PublicDatabase.kvPairDao) val profileCacheStore = RoomPreferenceDataStore(TempDatabase.profileCacheDao) // last used, but may not be running var currentProfile by configurationStore.long(Key.PROFILE_CURRENT) var selectedProxy by configurationStore.long(Key.PROFILE_ID) var selectedGroup by configurationStore.long(Key.PROFILE_GROUP) { currentGroupId() } // "ungrouped" group id = 1 // only in bg process var vpnService: VpnService? = null var baseService: BaseService.Interface? = null // main var runningTest = false fun currentGroupId(): Long { val currentSelected = configurationStore.getLong(Key.PROFILE_GROUP, -1) if (currentSelected > 0L) return currentSelected val groups = SagerDatabase.groupDao.allGroups() if (groups.isNotEmpty()) { val groupId = groups[0].id selectedGroup = groupId return groupId } val groupId = SagerDatabase.groupDao.createGroup(ProxyGroup(ungrouped = true)) selectedGroup = groupId return groupId } fun currentGroup(): ProxyGroup { var group: ProxyGroup? = null val currentSelected = configurationStore.getLong(Key.PROFILE_GROUP, -1) if (currentSelected > 0L) { group = SagerDatabase.groupDao.getById(currentSelected) } if (group != null) return group val groups = SagerDatabase.groupDao.allGroups() if (groups.isEmpty()) { group = ProxyGroup(ungrouped = true).apply { id = SagerDatabase.groupDao.createGroup(this) } } else { group = groups[0] } selectedGroup = group.id return group } fun selectedGroupForImport(): Long { val current = currentGroup() if (current.type == GroupType.BASIC) return current.id val groups = SagerDatabase.groupDao.allGroups() return groups.find { it.type == GroupType.BASIC }!!.id } var appTLSVersion by configurationStore.string(Key.APP_TLS_VERSION) var enableClashAPI by configurationStore.boolean(Key.ENABLE_CLASH_API) var showBottomBar by configurationStore.boolean(Key.SHOW_BOTTOM_BAR) var allowInsecureOnRequest by configurationStore.boolean(Key.ALLOW_INSECURE_ON_REQUEST) var networkChangeResetConnections by configurationStore.boolean(Key.NETWORK_CHANGE_RESET_CONNECTIONS) { true } var wakeResetConnections by configurationStore.boolean(Key.WAKE_RESET_CONNECTIONS) // var isExpert by configurationStore.boolean(Key.APP_EXPERT) var appTheme by configurationStore.int(Key.APP_THEME) var nightTheme by configurationStore.stringToInt(Key.NIGHT_THEME) var serviceMode by configurationStore.string(Key.SERVICE_MODE) { Key.MODE_VPN } var trafficSniffing by configurationStore.stringToInt(Key.TRAFFIC_SNIFFING) { 1 } var resolveDestination by configurationStore.boolean(Key.RESOLVE_DESTINATION) var mtu by configurationStore.stringToInt(Key.MTU) { 9000 } var bypassLan by configurationStore.boolean(Key.BYPASS_LAN) var bypassLanInCore by configurationStore.boolean(Key.BYPASS_LAN_IN_CORE) var allowAccess by configurationStore.boolean(Key.ALLOW_ACCESS) var speedInterval by configurationStore.stringToInt(Key.SPEED_INTERVAL) var showGroupInNotification by configurationStore.boolean("showGroupInNotification") var globalCustomConfig by configurationStore.string(Key.GLOBAL_CUSTOM_CONFIG) { "" } var remoteDns by configurationStore.string(Key.REMOTE_DNS) { "https://dns.google/dns-query" } var directDns by configurationStore.string(Key.DIRECT_DNS) { "https://223.5.5.5/dns-query" } var enableDnsRouting by configurationStore.boolean(Key.ENABLE_DNS_ROUTING) { true } var enableFakeDns by configurationStore.boolean(Key.ENABLE_FAKEDNS) { true } var rulesProvider by configurationStore.stringToInt(Key.RULES_PROVIDER) var logLevel by configurationStore.stringToInt(Key.LOG_LEVEL) var logBufSize by configurationStore.int(Key.LOG_BUF_SIZE) { 0 } var acquireWakeLock by configurationStore.boolean(Key.ACQUIRE_WAKE_LOCK) // hopefully hashCode = mHandle doesn't change, currently this is true from KitKat to Nougat private val userIndex by lazy { Binder.getCallingUserHandle().hashCode() } var mixedPort: Int get() = getLocalPort(Key.MIXED_PORT, 2080) set(value) = saveLocalPort(Key.MIXED_PORT, value) fun initGlobal() { if (configurationStore.getString(Key.MIXED_PORT) == null) { mixedPort = mixedPort } } private fun getLocalPort(key: String, default: Int): Int { return parsePort(configurationStore.getString(key), default + userIndex) } private fun saveLocalPort(key: String, value: Int) { configurationStore.putString(key, "$value") } var ipv6Mode by configurationStore.stringToInt(Key.IPV6_MODE) { IPv6Mode.DISABLE } var meteredNetwork by configurationStore.boolean(Key.METERED_NETWORK) var proxyApps by configurationStore.boolean(Key.PROXY_APPS) var bypass by configurationStore.boolean(Key.BYPASS_MODE) { true } var individual by configurationStore.string(Key.INDIVIDUAL) var showDirectSpeed by configurationStore.boolean(Key.SHOW_DIRECT_SPEED) { true } val persistAcrossReboot by configurationStore.boolean(Key.PERSIST_ACROSS_REBOOT) { false } var appendHttpProxy by configurationStore.boolean(Key.APPEND_HTTP_PROXY) var connectionTestURL by configurationStore.string(Key.CONNECTION_TEST_URL) { CONNECTION_TEST_URL } var connectionTestConcurrent by configurationStore.int("connectionTestConcurrent") { 5 } var alwaysShowAddress by configurationStore.boolean(Key.ALWAYS_SHOW_ADDRESS) var tunImplementation by configurationStore.stringToInt(Key.TUN_IMPLEMENTATION) { TunImplementation.GVISOR } var profileTrafficStatistics by configurationStore.boolean(Key.PROFILE_TRAFFIC_STATISTICS) { true } var yacdURL by configurationStore.string("yacdURL") { "http://127.0.0.1:9090/ui" } // protocol var globalAllowInsecure by configurationStore.boolean(Key.GLOBAL_ALLOW_INSECURE) { false } // old cache, DO NOT ADD var dirty by profileCacheStore.boolean(Key.PROFILE_DIRTY) var editingId by profileCacheStore.long(Key.PROFILE_ID) var editingGroup by profileCacheStore.long(Key.PROFILE_GROUP) var profileName by profileCacheStore.string(Key.PROFILE_NAME) var serverAddress by profileCacheStore.string(Key.SERVER_ADDRESS) var serverPort by profileCacheStore.stringToInt(Key.SERVER_PORT) var serverPorts by profileCacheStore.string("serverPorts") var serverUsername by profileCacheStore.string(Key.SERVER_USERNAME) var serverPassword by profileCacheStore.string(Key.SERVER_PASSWORD) var serverPassword1 by profileCacheStore.string(Key.SERVER_PASSWORD1) var serverMethod by profileCacheStore.string(Key.SERVER_METHOD) var sharedStorage by profileCacheStore.string("sharedStorage") var serverProtocol by profileCacheStore.string(Key.SERVER_PROTOCOL) var serverObfs by profileCacheStore.string(Key.SERVER_OBFS) var serverNetwork by profileCacheStore.string(Key.SERVER_NETWORK) var serverHost by profileCacheStore.string(Key.SERVER_HOST) var serverPath by profileCacheStore.string(Key.SERVER_PATH) var serverSNI by profileCacheStore.string(Key.SERVER_SNI) var serverEncryption by profileCacheStore.string(Key.SERVER_ENCRYPTION) var serverALPN by profileCacheStore.string(Key.SERVER_ALPN) var serverCertificates by profileCacheStore.string(Key.SERVER_CERTIFICATES) var serverMTU by profileCacheStore.stringToInt(Key.SERVER_MTU) var serverHeaders by profileCacheStore.string(Key.SERVER_HEADERS) var serverAllowInsecure by profileCacheStore.boolean(Key.SERVER_ALLOW_INSECURE) var serverAuthType by profileCacheStore.stringToInt(Key.SERVER_AUTH_TYPE) var serverUploadSpeed by profileCacheStore.stringToInt(Key.SERVER_UPLOAD_SPEED) var serverDownloadSpeed by profileCacheStore.stringToInt(Key.SERVER_DOWNLOAD_SPEED) var serverStreamReceiveWindow by profileCacheStore.stringToIntIfExists(Key.SERVER_STREAM_RECEIVE_WINDOW) var serverConnectionReceiveWindow by profileCacheStore.stringToIntIfExists(Key.SERVER_CONNECTION_RECEIVE_WINDOW) var serverDisableMtuDiscovery by profileCacheStore.boolean(Key.SERVER_DISABLE_MTU_DISCOVERY) var serverHopInterval by profileCacheStore.stringToInt(Key.SERVER_HOP_INTERVAL) { 10 } var protocolVersion by profileCacheStore.stringToInt(Key.PROTOCOL_VERSION) { 2 } // default is SOCKS5 var serverProtocolInt by profileCacheStore.stringToInt(Key.SERVER_PROTOCOL) var serverPrivateKey by profileCacheStore.string(Key.SERVER_PRIVATE_KEY) var serverInsecureConcurrency by profileCacheStore.stringToInt(Key.SERVER_INSECURE_CONCURRENCY) var serverUDPRelayMode by profileCacheStore.string(Key.SERVER_UDP_RELAY_MODE) var serverCongestionController by profileCacheStore.string(Key.SERVER_CONGESTION_CONTROLLER) var serverDisableSNI by profileCacheStore.boolean(Key.SERVER_DISABLE_SNI) var serverReduceRTT by profileCacheStore.boolean(Key.SERVER_REDUCE_RTT) var routeName by profileCacheStore.string(Key.ROUTE_NAME) var routeDomain by profileCacheStore.string(Key.ROUTE_DOMAIN) var routeIP by profileCacheStore.string(Key.ROUTE_IP) var routePort by profileCacheStore.string(Key.ROUTE_PORT) var routeSourcePort by profileCacheStore.string(Key.ROUTE_SOURCE_PORT) var routeNetwork by profileCacheStore.string(Key.ROUTE_NETWORK) var routeSource by profileCacheStore.string(Key.ROUTE_SOURCE) var routeProtocol by profileCacheStore.string(Key.ROUTE_PROTOCOL) var routeOutbound by profileCacheStore.stringToInt(Key.ROUTE_OUTBOUND) var routeOutboundRule by profileCacheStore.long(Key.ROUTE_OUTBOUND + "Long") var routePackages by profileCacheStore.string(Key.ROUTE_PACKAGES) var frontProxy by profileCacheStore.long(Key.GROUP_FRONT_PROXY + "Long") var landingProxy by profileCacheStore.long(Key.GROUP_LANDING_PROXY + "Long") var frontProxyTmp by profileCacheStore.stringToInt(Key.GROUP_FRONT_PROXY) var landingProxyTmp by profileCacheStore.stringToInt(Key.GROUP_LANDING_PROXY) var serverConfig by profileCacheStore.string(Key.SERVER_CONFIG) var serverCustom by profileCacheStore.string(Key.SERVER_CUSTOM) var serverCustomOutbound by profileCacheStore.string(Key.SERVER_CUSTOM_OUTBOUND) var groupName by profileCacheStore.string(Key.GROUP_NAME) var groupType by profileCacheStore.stringToInt(Key.GROUP_TYPE) var groupOrder by profileCacheStore.stringToInt(Key.GROUP_ORDER) var groupIsSelector by profileCacheStore.boolean(Key.GROUP_IS_SELECTOR) var subscriptionLink by profileCacheStore.string(Key.SUBSCRIPTION_LINK) var subscriptionForceResolve by profileCacheStore.boolean(Key.SUBSCRIPTION_FORCE_RESOLVE) var subscriptionDeduplication by profileCacheStore.boolean(Key.SUBSCRIPTION_DEDUPLICATION) var subscriptionUpdateWhenConnectedOnly by profileCacheStore.boolean(Key.SUBSCRIPTION_UPDATE_WHEN_CONNECTED_ONLY) var subscriptionUserAgent by profileCacheStore.string(Key.SUBSCRIPTION_USER_AGENT) var subscriptionAutoUpdate by profileCacheStore.boolean(Key.SUBSCRIPTION_AUTO_UPDATE) var subscriptionAutoUpdateDelay by profileCacheStore.stringToInt(Key.SUBSCRIPTION_AUTO_UPDATE_DELAY) { 360 } var rulesFirstCreate by profileCacheStore.boolean("rulesFirstCreate") override fun onPreferenceDataStoreChanged(store: PreferenceDataStore, key: String) { } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/database/GroupManager.kt ================================================ package io.nekohasekai.sagernet.database import io.nekohasekai.sagernet.GroupType import io.nekohasekai.sagernet.bg.SubscriptionUpdater import io.nekohasekai.sagernet.ktx.applyDefaultValues object GroupManager { interface Listener { suspend fun groupAdd(group: ProxyGroup) suspend fun groupUpdated(group: ProxyGroup) suspend fun groupRemoved(groupId: Long) suspend fun groupUpdated(groupId: Long) } interface Interface { suspend fun confirm(message: String): Boolean suspend fun alert(message: String) suspend fun onUpdateSuccess( group: ProxyGroup, changed: Int, added: List, updated: Map, deleted: List, duplicate: List, byUser: Boolean ) suspend fun onUpdateFailure(group: ProxyGroup, message: String) } private val listeners = ArrayList() var userInterface: Interface? = null suspend fun iterator(what: suspend Listener.() -> Unit) { synchronized(listeners) { listeners.toList() }.forEach { listener -> what(listener) } } fun addListener(listener: Listener) { synchronized(listeners) { listeners.add(listener) } } fun removeListener(listener: Listener) { synchronized(listeners) { listeners.remove(listener) } } suspend fun clearGroup(groupId: Long) { DataStore.selectedProxy = 0L SagerDatabase.proxyDao.deleteAll(groupId) iterator { groupUpdated(groupId) } } fun rearrange(groupId: Long) { val entities = SagerDatabase.proxyDao.getByGroup(groupId) for (index in entities.indices) { entities[index].userOrder = (index + 1).toLong() } SagerDatabase.proxyDao.updateProxy(entities) } suspend fun postUpdate(group: ProxyGroup) { iterator { groupUpdated(group) } } suspend fun postUpdate(groupId: Long) { postUpdate(SagerDatabase.groupDao.getById(groupId) ?: return) } suspend fun postReload(groupId: Long) { iterator { groupUpdated(groupId) } } suspend fun createGroup(group: ProxyGroup): ProxyGroup { group.userOrder = SagerDatabase.groupDao.nextOrder() ?: 1 group.id = SagerDatabase.groupDao.createGroup(group.applyDefaultValues()) iterator { groupAdd(group) } if (group.type == GroupType.SUBSCRIPTION) { SubscriptionUpdater.reconfigureUpdater() } return group } suspend fun updateGroup(group: ProxyGroup) { SagerDatabase.groupDao.updateGroup(group) iterator { groupUpdated(group) } if (group.type == GroupType.SUBSCRIPTION) { SubscriptionUpdater.reconfigureUpdater() } } suspend fun deleteGroup(groupId: Long) { SagerDatabase.groupDao.deleteById(groupId) SagerDatabase.proxyDao.deleteByGroup(groupId) iterator { groupRemoved(groupId) } SubscriptionUpdater.reconfigureUpdater() } suspend fun deleteGroup(group: List) { SagerDatabase.groupDao.deleteGroup(group) SagerDatabase.proxyDao.deleteByGroup(group.map { it.id }.toLongArray()) for (proxyGroup in group) iterator { groupRemoved(proxyGroup.id) } SubscriptionUpdater.reconfigureUpdater() } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/database/ParcelizeBridge.java ================================================ package io.nekohasekai.sagernet.database; import android.os.Parcel; /** * see: https://youtrack.jetbrains.com/issue/KT-19853 */ public class ParcelizeBridge { public static RuleEntity createRule(Parcel parcel) { return (RuleEntity) RuleEntity.CREATOR.createFromParcel(parcel); } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/database/ProfileManager.kt ================================================ package io.nekohasekai.sagernet.database import android.database.sqlite.SQLiteCantOpenDatabaseException import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.aidl.TrafficData import io.nekohasekai.sagernet.fmt.AbstractBean import io.nekohasekai.sagernet.ktx.Logs import io.nekohasekai.sagernet.ktx.app import io.nekohasekai.sagernet.ktx.applyDefaultValues import java.io.IOException import java.sql.SQLException import java.util.* object ProfileManager { interface Listener { suspend fun onAdd(profile: ProxyEntity) suspend fun onUpdated(data: TrafficData) suspend fun onUpdated(profile: ProxyEntity, noTraffic: Boolean) suspend fun onRemoved(groupId: Long, profileId: Long) } interface RuleListener { suspend fun onAdd(rule: RuleEntity) suspend fun onUpdated(rule: RuleEntity) suspend fun onRemoved(ruleId: Long) suspend fun onCleared() } private val listeners = ArrayList() private val ruleListeners = ArrayList() suspend fun iterator(what: suspend Listener.() -> Unit) { synchronized(listeners) { listeners.toList() }.forEach { listener -> what(listener) } } suspend fun ruleIterator(what: suspend RuleListener.() -> Unit) { val ruleListeners = synchronized(ruleListeners) { ruleListeners.toList() } for (listener in ruleListeners) { what(listener) } } fun addListener(listener: Listener) { synchronized(listeners) { listeners.add(listener) } } fun removeListener(listener: Listener) { synchronized(listeners) { listeners.remove(listener) } } fun addListener(listener: RuleListener) { synchronized(ruleListeners) { ruleListeners.add(listener) } } fun removeListener(listener: RuleListener) { synchronized(ruleListeners) { ruleListeners.remove(listener) } } suspend fun createProfile(groupId: Long, bean: AbstractBean): ProxyEntity { bean.applyDefaultValues() val profile = ProxyEntity(groupId = groupId).apply { id = 0 putBean(bean) userOrder = SagerDatabase.proxyDao.nextOrder(groupId) ?: 1 } profile.id = SagerDatabase.proxyDao.addProxy(profile) iterator { onAdd(profile) } return profile } suspend fun updateProfile(profile: ProxyEntity) { SagerDatabase.proxyDao.updateProxy(profile) iterator { onUpdated(profile, false) } } suspend fun updateProfile(profiles: List) { SagerDatabase.proxyDao.updateProxy(profiles) profiles.forEach { iterator { onUpdated(it, false) } } } suspend fun deleteProfile2(groupId: Long, profileId: Long) { if (SagerDatabase.proxyDao.deleteById(profileId) == 0) return if (DataStore.selectedProxy == profileId) { DataStore.selectedProxy = 0L } } suspend fun deleteProfile(groupId: Long, profileId: Long) { if (SagerDatabase.proxyDao.deleteById(profileId) == 0) return if (DataStore.selectedProxy == profileId) { DataStore.selectedProxy = 0L } iterator { onRemoved(groupId, profileId) } if (SagerDatabase.proxyDao.countByGroup(groupId) > 1) { GroupManager.rearrange(groupId) } } fun getProfile(profileId: Long): ProxyEntity? { if (profileId == 0L) return null return try { SagerDatabase.proxyDao.getById(profileId) } catch (ex: SQLiteCantOpenDatabaseException) { throw IOException(ex) } catch (ex: SQLException) { Logs.w(ex) null } } fun getProfiles(profileIds: List): List { if (profileIds.isEmpty()) return listOf() return try { SagerDatabase.proxyDao.getEntities(profileIds) } catch (ex: SQLiteCantOpenDatabaseException) { throw IOException(ex) } catch (ex: SQLException) { Logs.w(ex) listOf() } } // postUpdate: post to listeners, don't change the DB suspend fun postUpdate(profileId: Long, noTraffic: Boolean = false) { postUpdate(getProfile(profileId) ?: return, noTraffic) } suspend fun postUpdate(profile: ProxyEntity, noTraffic: Boolean = false) { iterator { onUpdated(profile, noTraffic) } } suspend fun postUpdate(data: TrafficData) { iterator { onUpdated(data) } } suspend fun createRule(rule: RuleEntity, post: Boolean = true): RuleEntity { rule.userOrder = SagerDatabase.rulesDao.nextOrder() ?: 1 rule.id = SagerDatabase.rulesDao.createRule(rule) if (post) { ruleIterator { onAdd(rule) } } return rule } suspend fun updateRule(rule: RuleEntity) { SagerDatabase.rulesDao.updateRule(rule) ruleIterator { onUpdated(rule) } } suspend fun deleteRule(ruleId: Long) { SagerDatabase.rulesDao.deleteById(ruleId) ruleIterator { onRemoved(ruleId) } } suspend fun deleteRules(rules: List) { SagerDatabase.rulesDao.deleteRules(rules) ruleIterator { rules.forEach { onRemoved(it.id) } } } suspend fun getRules(): List { var rules = SagerDatabase.rulesDao.allRules() if (rules.isEmpty() && !DataStore.rulesFirstCreate) { DataStore.rulesFirstCreate = true createRule( RuleEntity( name = app.getString(R.string.route_opt_block_quic), port = "443", network = "udp", outbound = -2 ) ) createRule( RuleEntity( name = app.getString(R.string.route_opt_block_ads), domains = "geosite:category-ads-all", outbound = -2 ) ) val fuckedCountry = mutableListOf("cn:中国") if (Locale.getDefault().country != Locale.CHINA.country) { // 非中文用户 fuckedCountry += "ir:Iran" fuckedCountry += "ru:Russia" } for (c in fuckedCountry) { val country = c.substringBefore(":") val displayCountry = c.substringAfter(":") // if (country == "cn") createRule( RuleEntity( name = app.getString(R.string.route_play_store, displayCountry), domains = "googleapis.cn", ), false ) createRule( RuleEntity( name = app.getString(R.string.route_bypass_domain, displayCountry), domains = "geosite:$country", outbound = -1 ), false ) createRule( RuleEntity( name = app.getString(R.string.route_bypass_ip, displayCountry), ip = "geoip:$country", outbound = -1 ), false ) } rules = SagerDatabase.rulesDao.allRules() } return rules } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/database/ProxyEntity.kt ================================================ package io.nekohasekai.sagernet.database import android.content.Context import android.content.Intent import androidx.room.* import com.esotericsoftware.kryo.io.ByteBufferInput import com.esotericsoftware.kryo.io.ByteBufferOutput import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.fmt.* import io.nekohasekai.sagernet.fmt.http.HttpBean import io.nekohasekai.sagernet.fmt.http.toUri import io.nekohasekai.sagernet.fmt.hysteria.* import io.nekohasekai.sagernet.fmt.internal.ChainBean import io.nekohasekai.sagernet.fmt.mieru.MieruBean import io.nekohasekai.sagernet.fmt.mieru.buildMieruConfig import io.nekohasekai.sagernet.fmt.naive.NaiveBean import io.nekohasekai.sagernet.fmt.naive.buildNaiveConfig import io.nekohasekai.sagernet.fmt.naive.toUri import io.nekohasekai.sagernet.fmt.shadowsocks.* import moe.matsuri.nb4a.proxy.shadowtls.ShadowTLSBean import io.nekohasekai.sagernet.fmt.socks.SOCKSBean import io.nekohasekai.sagernet.fmt.socks.toUri import io.nekohasekai.sagernet.fmt.ssh.SSHBean import io.nekohasekai.sagernet.fmt.trojan.TrojanBean import io.nekohasekai.sagernet.fmt.trojan_go.TrojanGoBean import io.nekohasekai.sagernet.fmt.trojan_go.buildTrojanGoConfig import io.nekohasekai.sagernet.fmt.trojan_go.toUri import io.nekohasekai.sagernet.fmt.tuic.TuicBean import io.nekohasekai.sagernet.fmt.tuic.toUri import io.nekohasekai.sagernet.fmt.v2ray.* import io.nekohasekai.sagernet.fmt.wireguard.WireGuardBean import io.nekohasekai.sagernet.ktx.app import io.nekohasekai.sagernet.ui.profile.* import moe.matsuri.nb4a.SingBoxOptions.MultiplexOptions import moe.matsuri.nb4a.proxy.anytls.AnyTLSBean import moe.matsuri.nb4a.proxy.anytls.AnyTLSSettingsActivity import moe.matsuri.nb4a.proxy.anytls.toUri import moe.matsuri.nb4a.proxy.config.ConfigBean import moe.matsuri.nb4a.proxy.config.ConfigSettingActivity import moe.matsuri.nb4a.proxy.neko.* import moe.matsuri.nb4a.proxy.shadowtls.ShadowTLSSettingsActivity @Entity( tableName = "proxy_entities", indices = [Index("groupId", name = "groupId")] ) data class ProxyEntity( @PrimaryKey(autoGenerate = true) var id: Long = 0L, var groupId: Long = 0L, var type: Int = 0, var userOrder: Long = 0L, var tx: Long = 0L, var rx: Long = 0L, var status: Int = 0, var ping: Int = 0, var uuid: String = "", var error: String? = null, var socksBean: SOCKSBean? = null, var httpBean: HttpBean? = null, var ssBean: ShadowsocksBean? = null, var vmessBean: VMessBean? = null, var trojanBean: TrojanBean? = null, var trojanGoBean: TrojanGoBean? = null, var mieruBean: MieruBean? = null, var naiveBean: NaiveBean? = null, var hysteriaBean: HysteriaBean? = null, var tuicBean: TuicBean? = null, var sshBean: SSHBean? = null, var wgBean: WireGuardBean? = null, var shadowTLSBean: ShadowTLSBean? = null, var anyTLSBean: AnyTLSBean? = null, var chainBean: ChainBean? = null, var nekoBean: NekoBean? = null, var configBean: ConfigBean? = null, ) : Serializable() { companion object { const val TYPE_SOCKS = 0 const val TYPE_HTTP = 1 const val TYPE_SS = 2 const val TYPE_VMESS = 4 const val TYPE_TROJAN = 6 const val TYPE_SSH = 17 const val TYPE_WG = 18 const val TYPE_TROJAN_GO = 7 const val TYPE_NAIVE = 9 const val TYPE_HYSTERIA = 15 const val TYPE_SHADOWTLS = 19 const val TYPE_TUIC = 20 const val TYPE_MIERU = 21 const val TYPE_ANYTLS = 22 const val TYPE_CONFIG = 998 const val TYPE_NEKO = 999 const val TYPE_CHAIN = 8 val chainName by lazy { app.getString(R.string.proxy_chain) } @JvmField val CREATOR = object : CREATOR() { override fun newInstance(): ProxyEntity { return ProxyEntity() } override fun newArray(size: Int): Array { return arrayOfNulls(size) } } } @Ignore @Transient var dirty: Boolean = false override fun initializeDefaultValues() { } override fun serializeToBuffer(output: ByteBufferOutput) { output.writeInt(0) output.writeLong(id) output.writeLong(groupId) output.writeInt(type) output.writeLong(userOrder) output.writeLong(tx) output.writeLong(rx) output.writeInt(status) output.writeInt(ping) output.writeString(uuid) output.writeString(error) val data = KryoConverters.serialize(requireBean()) output.writeVarInt(data.size, true) output.writeBytes(data) output.writeBoolean(dirty) } override fun deserializeFromBuffer(input: ByteBufferInput) { val version = input.readInt() id = input.readLong() groupId = input.readLong() type = input.readInt() userOrder = input.readLong() tx = input.readLong() rx = input.readLong() status = input.readInt() ping = input.readInt() uuid = input.readString() error = input.readString() putByteArray(input.readBytes(input.readVarInt(true))) dirty = input.readBoolean() } fun putByteArray(byteArray: ByteArray) { when (type) { TYPE_SOCKS -> socksBean = KryoConverters.socksDeserialize(byteArray) TYPE_HTTP -> httpBean = KryoConverters.httpDeserialize(byteArray) TYPE_SS -> ssBean = KryoConverters.shadowsocksDeserialize(byteArray) TYPE_VMESS -> vmessBean = KryoConverters.vmessDeserialize(byteArray) TYPE_TROJAN -> trojanBean = KryoConverters.trojanDeserialize(byteArray) TYPE_TROJAN_GO -> trojanGoBean = KryoConverters.trojanGoDeserialize(byteArray) TYPE_MIERU -> mieruBean = KryoConverters.mieruDeserialize(byteArray) TYPE_NAIVE -> naiveBean = KryoConverters.naiveDeserialize(byteArray) TYPE_HYSTERIA -> hysteriaBean = KryoConverters.hysteriaDeserialize(byteArray) TYPE_SSH -> sshBean = KryoConverters.sshDeserialize(byteArray) TYPE_WG -> wgBean = KryoConverters.wireguardDeserialize(byteArray) TYPE_TUIC -> tuicBean = KryoConverters.tuicDeserialize(byteArray) TYPE_SHADOWTLS -> shadowTLSBean = KryoConverters.shadowTLSDeserialize(byteArray) TYPE_ANYTLS -> anyTLSBean = KryoConverters.anyTLSDeserialize(byteArray) TYPE_CHAIN -> chainBean = KryoConverters.chainDeserialize(byteArray) TYPE_NEKO -> nekoBean = KryoConverters.nekoDeserialize(byteArray) TYPE_CONFIG -> configBean = KryoConverters.configDeserialize(byteArray) } } fun displayType(): String = when (type) { TYPE_SOCKS -> socksBean!!.protocolName() TYPE_HTTP -> if (httpBean!!.isTLS()) "HTTPS" else "HTTP" TYPE_SS -> "Shadowsocks" TYPE_VMESS -> if (vmessBean!!.isVLESS) "VLESS" else "VMess" TYPE_TROJAN -> "Trojan" TYPE_TROJAN_GO -> "Trojan-Go" TYPE_MIERU -> "Mieru" TYPE_NAIVE -> "Naïve" TYPE_HYSTERIA -> "Hysteria" + hysteriaBean!!.protocolVersion TYPE_SSH -> "SSH" TYPE_WG -> "WireGuard" TYPE_TUIC -> "TUIC" TYPE_SHADOWTLS -> "ShadowTLS" TYPE_ANYTLS -> "AnyTLS" TYPE_CHAIN -> chainName TYPE_NEKO -> nekoBean!!.displayType() TYPE_CONFIG -> configBean!!.displayType() else -> "Undefined type $type" } fun displayName() = requireBean().displayName() fun displayAddress() = requireBean().displayAddress() fun requireBean(): AbstractBean { return when (type) { TYPE_SOCKS -> socksBean TYPE_HTTP -> httpBean TYPE_SS -> ssBean TYPE_VMESS -> vmessBean TYPE_TROJAN -> trojanBean TYPE_TROJAN_GO -> trojanGoBean TYPE_MIERU -> mieruBean TYPE_NAIVE -> naiveBean TYPE_HYSTERIA -> hysteriaBean TYPE_SSH -> sshBean TYPE_WG -> wgBean TYPE_TUIC -> tuicBean TYPE_SHADOWTLS -> shadowTLSBean TYPE_ANYTLS -> anyTLSBean TYPE_CHAIN -> chainBean TYPE_NEKO -> nekoBean TYPE_CONFIG -> configBean else -> error("Undefined type $type") } ?: error("Null ${displayType()} profile") } fun haveLink(): Boolean { return when (type) { TYPE_CHAIN -> false else -> true } } fun haveStandardLink(): Boolean { return when (requireBean()) { is SSHBean -> false is WireGuardBean -> false is ShadowTLSBean -> false is NekoBean -> false is ConfigBean -> false else -> true } } fun toStdLink(compact: Boolean = false): String = with(requireBean()) { when (this) { is SOCKSBean -> toUri() is HttpBean -> toUri() is ShadowsocksBean -> toUri() is VMessBean -> toUriVMessVLESSTrojan(false) is TrojanBean -> toUriVMessVLESSTrojan(true) is TrojanGoBean -> toUri() is NaiveBean -> toUri() is HysteriaBean -> toUri() is TuicBean -> toUri() is AnyTLSBean -> toUri() is NekoBean -> "" else -> toUniversalLink() } } fun exportConfig(): Pair { var name = "${requireBean().displayName()}.json" return with(requireBean()) { StringBuilder().apply { val config = buildConfig(this@ProxyEntity, forExport = true) append(config.config) if (!config.externalIndex.all { it.chain.isEmpty() }) { name = "profiles.txt" } for ((chain) in config.externalIndex) { chain.entries.forEachIndexed { index, (port, profile) -> when (val bean = profile.requireBean()) { is TrojanGoBean -> { append("\n\n") append(bean.buildTrojanGoConfig(port)) } is MieruBean -> { append("\n\n") append(bean.buildMieruConfig(port)) } is NaiveBean -> { append("\n\n") append(bean.buildNaiveConfig(port)) } is HysteriaBean -> { append("\n\n") append(bean.buildHysteria1Config(port, null)) } } } } }.toString() } to name } fun needExternal(): Boolean { return when (type) { TYPE_TROJAN_GO -> true TYPE_MIERU -> true TYPE_NAIVE -> true TYPE_HYSTERIA -> !hysteriaBean!!.canUseSingBox() TYPE_NEKO -> true else -> false } } fun singMux(): MultiplexOptions? { return when (type) { TYPE_VMESS -> MultiplexOptions().apply { enabled = vmessBean!!.enableMux padding = vmessBean!!.muxPadding max_streams = vmessBean!!.muxConcurrency protocol = when (vmessBean!!.muxType) { 1 -> "smux" 2 -> "yamux" else -> "h2mux" } } TYPE_TROJAN -> MultiplexOptions().apply { enabled = trojanBean!!.enableMux padding = trojanBean!!.muxPadding max_streams = trojanBean!!.muxConcurrency protocol = when (trojanBean!!.muxType) { 1 -> "smux" 2 -> "yamux" else -> "h2mux" } } else -> null } } fun putBean(bean: AbstractBean): ProxyEntity { socksBean = null httpBean = null ssBean = null vmessBean = null trojanBean = null trojanGoBean = null mieruBean = null naiveBean = null hysteriaBean = null sshBean = null wgBean = null tuicBean = null shadowTLSBean = null anyTLSBean = null chainBean = null configBean = null nekoBean = null when (bean) { is SOCKSBean -> { type = TYPE_SOCKS socksBean = bean } is HttpBean -> { type = TYPE_HTTP httpBean = bean } is ShadowsocksBean -> { type = TYPE_SS ssBean = bean } is VMessBean -> { type = TYPE_VMESS vmessBean = bean } is TrojanBean -> { type = TYPE_TROJAN trojanBean = bean } is TrojanGoBean -> { type = TYPE_TROJAN_GO trojanGoBean = bean } is MieruBean -> { type = TYPE_MIERU mieruBean = bean } is NaiveBean -> { type = TYPE_NAIVE naiveBean = bean } is HysteriaBean -> { type = TYPE_HYSTERIA hysteriaBean = bean } is SSHBean -> { type = TYPE_SSH sshBean = bean } is WireGuardBean -> { type = TYPE_WG wgBean = bean } is TuicBean -> { type = TYPE_TUIC tuicBean = bean } is ShadowTLSBean -> { type = TYPE_SHADOWTLS shadowTLSBean = bean } is AnyTLSBean -> { type = TYPE_ANYTLS anyTLSBean = bean } is ChainBean -> { type = TYPE_CHAIN chainBean = bean } is NekoBean -> { type = TYPE_NEKO nekoBean = bean } is ConfigBean -> { type = TYPE_CONFIG configBean = bean } else -> error("Undefined type $type") } return this } fun settingIntent(ctx: Context, isSubscription: Boolean): Intent { return Intent( ctx, when (type) { TYPE_SOCKS -> SocksSettingsActivity::class.java TYPE_HTTP -> HttpSettingsActivity::class.java TYPE_SS -> ShadowsocksSettingsActivity::class.java TYPE_VMESS -> VMessSettingsActivity::class.java TYPE_TROJAN -> TrojanSettingsActivity::class.java TYPE_TROJAN_GO -> TrojanGoSettingsActivity::class.java TYPE_MIERU -> MieruSettingsActivity::class.java TYPE_NAIVE -> NaiveSettingsActivity::class.java TYPE_HYSTERIA -> HysteriaSettingsActivity::class.java TYPE_SSH -> SSHSettingsActivity::class.java TYPE_WG -> WireGuardSettingsActivity::class.java TYPE_TUIC -> TuicSettingsActivity::class.java TYPE_SHADOWTLS -> ShadowTLSSettingsActivity::class.java TYPE_ANYTLS -> AnyTLSSettingsActivity::class.java TYPE_CHAIN -> ChainSettingsActivity::class.java TYPE_CONFIG -> ConfigSettingActivity::class.java else -> throw IllegalArgumentException() } ).apply { putExtra(ProfileSettingsActivity.EXTRA_PROFILE_ID, id) putExtra(ProfileSettingsActivity.EXTRA_IS_SUBSCRIPTION, isSubscription) } } @androidx.room.Dao interface Dao { @Query("select * from proxy_entities") fun getAll(): List @Query("SELECT id FROM proxy_entities WHERE groupId = :groupId ORDER BY userOrder") fun getIdsByGroup(groupId: Long): List @Query("SELECT * FROM proxy_entities WHERE groupId = :groupId ORDER BY userOrder") fun getByGroup(groupId: Long): List @Query("SELECT * FROM proxy_entities WHERE id in (:proxyIds)") fun getEntities(proxyIds: List): List @Query("SELECT COUNT(*) FROM proxy_entities WHERE groupId = :groupId") fun countByGroup(groupId: Long): Long @Query("SELECT MAX(userOrder) + 1 FROM proxy_entities WHERE groupId = :groupId") fun nextOrder(groupId: Long): Long? @Query("SELECT * FROM proxy_entities WHERE id = :proxyId") fun getById(proxyId: Long): ProxyEntity? @Query("DELETE FROM proxy_entities WHERE id IN (:proxyId)") fun deleteById(proxyId: Long): Int @Query("DELETE FROM proxy_entities WHERE groupId = :groupId") fun deleteByGroup(groupId: Long) @Query("DELETE FROM proxy_entities WHERE groupId in (:groupId)") fun deleteByGroup(groupId: LongArray) @Delete fun deleteProxy(proxy: ProxyEntity): Int @Delete fun deleteProxy(proxies: List): Int @Update fun updateProxy(proxy: ProxyEntity): Int @Update fun updateProxy(proxies: List): Int @Insert fun addProxy(proxy: ProxyEntity): Long @Insert fun insert(proxies: List) @Query("DELETE FROM proxy_entities WHERE groupId = :groupId") fun deleteAll(groupId: Long): Int @Query("DELETE FROM proxy_entities") fun reset() } override fun describeContents(): Int { return 0 } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/database/ProxyGroup.kt ================================================ package io.nekohasekai.sagernet.database import androidx.room.* import com.esotericsoftware.kryo.io.ByteBufferInput import com.esotericsoftware.kryo.io.ByteBufferOutput import io.nekohasekai.sagernet.GroupOrder import io.nekohasekai.sagernet.GroupType import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.fmt.Serializable import io.nekohasekai.sagernet.ktx.app import io.nekohasekai.sagernet.ktx.applyDefaultValues @Entity(tableName = "proxy_groups") data class ProxyGroup( @PrimaryKey(autoGenerate = true) var id: Long = 0L, var userOrder: Long = 0L, var ungrouped: Boolean = false, var name: String? = null, var type: Int = GroupType.BASIC, var subscription: SubscriptionBean? = null, var order: Int = GroupOrder.ORIGIN, var isSelector: Boolean = false, var frontProxy: Long = -1L, var landingProxy: Long = -1L ) : Serializable() { @Transient var export = false override fun initializeDefaultValues() { subscription?.applyDefaultValues() } override fun serializeToBuffer(output: ByteBufferOutput) { if (export) { output.writeInt(0) output.writeString(name) output.writeInt(type) val subscription = subscription!! subscription.serializeForShare(output) } else { output.writeInt(0) output.writeLong(id) output.writeLong(userOrder) output.writeBoolean(ungrouped) output.writeString(name) output.writeInt(type) if (type == GroupType.SUBSCRIPTION) { subscription?.serializeToBuffer(output) } output.writeInt(order) } } override fun deserializeFromBuffer(input: ByteBufferInput) { if (export) { val version = input.readInt() name = input.readString() type = input.readInt() val subscription = SubscriptionBean() this.subscription = subscription subscription.deserializeFromShare(input) } else { val version = input.readInt() id = input.readLong() userOrder = input.readLong() ungrouped = input.readBoolean() name = input.readString() type = input.readInt() if (type == GroupType.SUBSCRIPTION) { val subscription = SubscriptionBean() this.subscription = subscription subscription.deserializeFromBuffer(input) } order = input.readInt() } } fun displayName(): String { return name.takeIf { !it.isNullOrBlank() } ?: app.getString(R.string.group_default) } @androidx.room.Dao interface Dao { @Query("SELECT * FROM proxy_groups ORDER BY userOrder") fun allGroups(): List @Query("SELECT * FROM proxy_groups WHERE type = ${GroupType.SUBSCRIPTION}") suspend fun subscriptions(): List @Query("SELECT MAX(userOrder) + 1 FROM proxy_groups") fun nextOrder(): Long? @Query("SELECT * FROM proxy_groups WHERE id = :groupId") fun getById(groupId: Long): ProxyGroup? @Query("DELETE FROM proxy_groups WHERE id = :groupId") fun deleteById(groupId: Long): Int @Delete fun deleteGroup(group: ProxyGroup) @Delete fun deleteGroup(groupList: List) @Insert fun createGroup(group: ProxyGroup): Long @Update fun updateGroup(group: ProxyGroup) @Query("DELETE FROM proxy_groups") fun reset() @Insert fun insert(groupList: List) } companion object { @JvmField val CREATOR = object : Serializable.CREATOR() { override fun newInstance(): ProxyGroup { return ProxyGroup() } override fun newArray(size: Int): Array { return arrayOfNulls(size) } } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/database/RuleEntity.kt ================================================ package io.nekohasekai.sagernet.database import android.os.Parcelable import androidx.room.* import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.ktx.app import kotlinx.parcelize.Parcelize @Entity(tableName = "rules") @Parcelize @TypeConverters(StringCollectionConverter::class) data class RuleEntity( @PrimaryKey(autoGenerate = true) var id: Long = 0L, var name: String = "", @ColumnInfo(defaultValue = "") var config: String = "", var userOrder: Long = 0L, var enabled: Boolean = false, var domains: String = "", var ip: String = "", var port: String = "", var sourcePort: String = "", var network: String = "", var source: String = "", var protocol: String = "", var outbound: Long = 0, var packages: Set = emptySet(), ) : Parcelable { fun displayName(): String { return name.takeIf { it.isNotBlank() } ?: "Rule $id" } fun mkSummary(): String { var summary = "" if (config.isNotBlank()) summary += "[config]\n" if (domains.isNotBlank()) summary += "$domains\n" if (ip.isNotBlank()) summary += "$ip\n" if (source.isNotBlank()) summary += "src ip: $source\n" if (sourcePort.isNotBlank()) summary += "src port: $sourcePort\n" if (port.isNotBlank()) summary += "dst port: $port\n" if (network.isNotBlank()) summary += "network: $network\n" if (protocol.isNotBlank()) summary += "protocol: $protocol\n" if (packages.isNotEmpty()) summary += app.getString( R.string.apps_message, packages.size ) + "\n" val lines = summary.trim().split("\n") return if (lines.size > 3) { lines.subList(0, 3).joinToString("\n", postfix = "\n...") } else { summary.trim() } } fun displayOutbound(): String { return when (outbound) { 0L -> app.getString(R.string.route_proxy) -1L -> app.getString(R.string.route_bypass) -2L -> app.getString(R.string.route_block) else -> ProfileManager.getProfile(outbound)?.displayName() ?: app.getString(R.string.error_title) } } @androidx.room.Dao interface Dao { @Query("SELECT * from rules WHERE (packages != '') AND enabled = 1") fun checkVpnNeeded(): List @Query("SELECT * FROM rules ORDER BY userOrder") fun allRules(): List @Query("SELECT * FROM rules WHERE enabled = :enabled ORDER BY userOrder") fun enabledRules(enabled: Boolean = true): List @Query("SELECT MAX(userOrder) + 1 FROM rules") fun nextOrder(): Long? @Query("SELECT * FROM rules WHERE id = :ruleId") fun getById(ruleId: Long): RuleEntity? @Query("DELETE FROM rules WHERE id = :ruleId") fun deleteById(ruleId: Long): Int @Delete fun deleteRule(rule: RuleEntity) @Delete fun deleteRules(rules: List) @Insert fun createRule(rule: RuleEntity): Long @Update fun updateRule(rule: RuleEntity) @Update fun updateRules(rules: List) @Query("DELETE FROM rules") fun reset() @Insert fun insert(rules: List) } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/database/SagerDatabase.kt ================================================ package io.nekohasekai.sagernet.database import androidx.room.AutoMigration import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase import androidx.room.TypeConverters import dev.matrix.roomigrant.GenerateRoomMigrations import io.nekohasekai.sagernet.Key import io.nekohasekai.sagernet.SagerNet import io.nekohasekai.sagernet.fmt.KryoConverters import io.nekohasekai.sagernet.fmt.gson.GsonConverters import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch @Database( entities = [ProxyGroup::class, ProxyEntity::class, RuleEntity::class], version = 6, autoMigrations = [ AutoMigration(from = 3, to = 4), AutoMigration(from = 4, to = 5), AutoMigration(from = 5, to = 6) ] ) @TypeConverters(value = [KryoConverters::class, GsonConverters::class]) @GenerateRoomMigrations abstract class SagerDatabase : RoomDatabase() { companion object { @OptIn(DelicateCoroutinesApi::class) @Suppress("EXPERIMENTAL_API_USAGE") val instance by lazy { SagerNet.application.getDatabasePath(Key.DB_PROFILE).parentFile?.mkdirs() Room.databaseBuilder(SagerNet.application, SagerDatabase::class.java, Key.DB_PROFILE) // .addMigrations(*SagerDatabase_Migrations.build()) .setJournalMode(JournalMode.TRUNCATE) .allowMainThreadQueries() .enableMultiInstanceInvalidation() .fallbackToDestructiveMigration() .setQueryExecutor { GlobalScope.launch { it.run() } } .build() } val groupDao get() = instance.groupDao() val proxyDao get() = instance.proxyDao() val rulesDao get() = instance.rulesDao() } abstract fun groupDao(): ProxyGroup.Dao abstract fun proxyDao(): ProxyEntity.Dao abstract fun rulesDao(): RuleEntity.Dao } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/database/StringCollectionConverter.kt ================================================ package io.nekohasekai.sagernet.database import androidx.room.TypeConverter class StringCollectionConverter { companion object { const val SPLIT_FLAG = "," /* @TypeConverter @JvmStatic fun fromList(list: List): String = if (list.isEmpty()) { "" } else { list.joinToString(SPLIT_FLAG) } @TypeConverter @JvmStatic fun toList(str: String): List = if (str.isBlank()) { emptyList() } else { str.split(SPLIT_FLAG) } */ @TypeConverter @JvmStatic fun fromSet(set: Set): String = if (set.isEmpty()) { "" } else { set.joinToString(SPLIT_FLAG) } @TypeConverter @JvmStatic fun toSet(str: String): Set = if (str.isBlank()) { emptySet() } else { str.split(",").toSet() } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/database/SubscriptionBean.java ================================================ package io.nekohasekai.sagernet.database; import androidx.annotation.NonNull; import com.esotericsoftware.kryo.io.ByteBufferInput; import com.esotericsoftware.kryo.io.ByteBufferOutput; import java.util.ArrayList; import java.util.List; import io.nekohasekai.sagernet.fmt.Serializable; public class SubscriptionBean extends Serializable { public Integer type; public String link; public String token; public Boolean forceResolve; public Boolean deduplication; public Boolean updateWhenConnectedOnly; public String customUserAgent; public Boolean autoUpdate; public Integer autoUpdateDelay; public Integer lastUpdated; // SIP008 public Long bytesUsed; public Long bytesRemaining; // Open Online Config public String username; public Integer expiryDate; public List protocols; // https://github.com/crossutility/Quantumult/blob/master/extra-subscription-feature.md public String subscriptionUserinfo; public SubscriptionBean() { } @Override public void serializeToBuffer(ByteBufferOutput output) { output.writeInt(1); output.writeInt(type); output.writeString(link); output.writeBoolean(forceResolve); output.writeBoolean(deduplication); output.writeBoolean(updateWhenConnectedOnly); output.writeString(customUserAgent); output.writeBoolean(autoUpdate); output.writeInt(autoUpdateDelay); output.writeInt(lastUpdated); output.writeString(subscriptionUserinfo); } public void serializeForShare(ByteBufferOutput output) { output.writeInt(0); output.writeInt(type); output.writeString(link); output.writeBoolean(forceResolve); output.writeBoolean(deduplication); output.writeBoolean(updateWhenConnectedOnly); output.writeString(customUserAgent); } @Override public void deserializeFromBuffer(ByteBufferInput input) { int version = input.readInt(); type = input.readInt(); link = input.readString(); forceResolve = input.readBoolean(); deduplication = input.readBoolean(); updateWhenConnectedOnly = input.readBoolean(); customUserAgent = input.readString(); autoUpdate = input.readBoolean(); autoUpdateDelay = input.readInt(); lastUpdated = input.readInt(); subscriptionUserinfo = input.readString(); } public void deserializeFromShare(ByteBufferInput input) { int version = input.readInt(); type = input.readInt(); link = input.readString(); forceResolve = input.readBoolean(); deduplication = input.readBoolean(); updateWhenConnectedOnly = input.readBoolean(); customUserAgent = input.readString(); } @Override public void initializeDefaultValues() { if (type == null) type = 0; if (link == null) link = ""; if (token == null) token = ""; if (forceResolve == null) forceResolve = false; if (deduplication == null) deduplication = false; if (updateWhenConnectedOnly == null) updateWhenConnectedOnly = false; if (customUserAgent == null) customUserAgent = ""; if (autoUpdate == null) autoUpdate = false; if (autoUpdateDelay == null) autoUpdateDelay = 1440; if (lastUpdated == null) lastUpdated = 0; if (bytesUsed == null) bytesUsed = 0L; if (bytesRemaining == null) bytesRemaining = 0L; if (username == null) username = ""; if (expiryDate == null) expiryDate = 0; if (protocols == null) protocols = new ArrayList<>(); } public static final Creator CREATOR = new CREATOR() { @NonNull @Override public SubscriptionBean newInstance() { return new SubscriptionBean(); } @Override public SubscriptionBean[] newArray(int size) { return new SubscriptionBean[size]; } }; } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/database/preference/EditTextPreferenceModifiers.kt ================================================ package io.nekohasekai.sagernet.database.preference import android.graphics.Typeface import android.text.InputFilter import android.view.inputmethod.EditorInfo import android.widget.EditText import androidx.preference.EditTextPreference object EditTextPreferenceModifiers { object Monospace : EditTextPreference.OnBindEditTextListener { override fun onBindEditText(editText: EditText) { editText.typeface = Typeface.MONOSPACE } } object Hosts : EditTextPreference.OnBindEditTextListener { override fun onBindEditText(editText: EditText) { editText.setHorizontallyScrolling(true) editText.setSelection(editText.text.length) } } object Port : EditTextPreference.OnBindEditTextListener { private val portLengthFilter = arrayOf(InputFilter.LengthFilter(5)) override fun onBindEditText(editText: EditText) { editText.inputType = EditorInfo.TYPE_CLASS_NUMBER editText.filters = portLengthFilter editText.setSingleLine() editText.setSelection(editText.text.length) } } object Number : EditTextPreference.OnBindEditTextListener { override fun onBindEditText(editText: EditText) { editText.inputType = EditorInfo.TYPE_CLASS_NUMBER editText.setSingleLine() editText.setSelection(editText.text.length) } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/database/preference/KeyValuePair.kt ================================================ package io.nekohasekai.sagernet.database.preference import android.os.Parcel import android.os.Parcelable import androidx.room.* import java.io.ByteArrayOutputStream import java.nio.ByteBuffer @Entity class KeyValuePair() : Parcelable { companion object { const val TYPE_UNINITIALIZED = 0 const val TYPE_BOOLEAN = 1 const val TYPE_FLOAT = 2 @Deprecated("Use TYPE_LONG.") const val TYPE_INT = 3 const val TYPE_LONG = 4 const val TYPE_STRING = 5 const val TYPE_STRING_SET = 6 @JvmField val CREATOR = object : Parcelable.Creator { override fun createFromParcel(parcel: Parcel): KeyValuePair { return KeyValuePair(parcel) } override fun newArray(size: Int): Array { return arrayOfNulls(size) } } } @androidx.room.Dao interface Dao { @Query("SELECT * FROM `KeyValuePair`") fun all(): List @Query("SELECT * FROM `KeyValuePair` WHERE `key` = :key") operator fun get(key: String): KeyValuePair? @Insert(onConflict = OnConflictStrategy.REPLACE) fun put(value: KeyValuePair): Long @Query("DELETE FROM `KeyValuePair` WHERE `key` = :key") fun delete(key: String): Int @Query("DELETE FROM `KeyValuePair`") fun reset(): Int @Insert fun insert(list: List) } @PrimaryKey var key: String = "" var valueType: Int = TYPE_UNINITIALIZED var value: ByteArray = ByteArray(0) val boolean: Boolean? get() = if (valueType == TYPE_BOOLEAN) ByteBuffer.wrap(value).get() != 0.toByte() else null val float: Float? get() = if (valueType == TYPE_FLOAT) ByteBuffer.wrap(value).float else null @Suppress("DEPRECATION") @Deprecated("Use long.", ReplaceWith("long")) val int: Int? get() = if (valueType == TYPE_INT) ByteBuffer.wrap(value).int else null val long: Long? get() = when (valueType) { @Suppress("DEPRECATION") TYPE_INT, -> ByteBuffer.wrap(value).int.toLong() TYPE_LONG -> ByteBuffer.wrap(value).long else -> null } 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 @Ignore constructor(key: String) : this() { this.key = key } // putting null requires using DataStore fun put(value: Boolean): KeyValuePair { valueType = TYPE_BOOLEAN this.value = ByteBuffer.allocate(1).put((if (value) 1 else 0).toByte()).array() return this } fun put(value: Float): KeyValuePair { valueType = TYPE_FLOAT this.value = ByteBuffer.allocate(4).putFloat(value).array() return this } @Suppress("DEPRECATION") @Deprecated("Use long.") fun put(value: Int): KeyValuePair { valueType = TYPE_INT this.value = ByteBuffer.allocate(4).putInt(value).array() return this } fun put(value: Long): KeyValuePair { valueType = TYPE_LONG this.value = ByteBuffer.allocate(8).putLong(value).array() return this } fun put(value: String): KeyValuePair { valueType = TYPE_STRING this.value = value.toByteArray() return this } fun put(value: Set): KeyValuePair { valueType = TYPE_STRING_SET val stream = ByteArrayOutputStream() val intBuffer = ByteBuffer.allocate(4) for (v in value) { intBuffer.rewind() stream.write(intBuffer.putInt(v.length).array()) stream.write(v.toByteArray()) } this.value = stream.toByteArray() return this } @Suppress("IMPLICIT_CAST_TO_ANY") 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()!! valueType = parcel.readInt() value = parcel.createByteArray()!! } override fun writeToParcel(parcel: Parcel, flags: Int) { parcel.writeString(key) parcel.writeInt(valueType) parcel.writeByteArray(value) } override fun describeContents(): Int { return 0 } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/database/preference/OnPreferenceDataStoreChangeListener.kt ================================================ package io.nekohasekai.sagernet.database.preference import androidx.preference.PreferenceDataStore interface OnPreferenceDataStoreChangeListener { fun onPreferenceDataStoreChanged(store: PreferenceDataStore, key: String) } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/database/preference/PublicDatabase.kt ================================================ package io.nekohasekai.sagernet.database.preference import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase import dev.matrix.roomigrant.GenerateRoomMigrations import io.nekohasekai.sagernet.Key import io.nekohasekai.sagernet.SagerNet import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch @Database(entities = [KeyValuePair::class], version = 1) @GenerateRoomMigrations abstract class PublicDatabase : RoomDatabase() { companion object { val instance by lazy { SagerNet.application.getDatabasePath(Key.DB_PROFILE).parentFile?.mkdirs() Room.databaseBuilder(SagerNet.application, PublicDatabase::class.java, Key.DB_PUBLIC) .setJournalMode(JournalMode.TRUNCATE) .allowMainThreadQueries() .enableMultiInstanceInvalidation() .fallbackToDestructiveMigration() .setQueryExecutor { GlobalScope.launch { it.run() } } .build() } val kvPairDao get() = instance.keyValuePairDao() } abstract fun keyValuePairDao(): KeyValuePair.Dao } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/database/preference/RoomPreferenceDataStore.kt ================================================ package io.nekohasekai.sagernet.database.preference import androidx.preference.PreferenceDataStore @Suppress("MemberVisibilityCanBePrivate", "unused") open class RoomPreferenceDataStore(private val kvPairDao: KeyValuePair.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 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 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(KeyValuePair(key).put(value)) fireChangeListener(key) } override fun putFloat(key: String, value: Float) { kvPairDao.put(KeyValuePair(key).put(value)) fireChangeListener(key) } override fun putInt(key: String, value: Int) { kvPairDao.put(KeyValuePair(key).put(value.toLong())) fireChangeListener(key) } override fun putLong(key: String, value: Long) { kvPairDao.put(KeyValuePair(key).put(value)) fireChangeListener(key) } override fun putString(key: String, value: String?) = if (value == null) remove(key) else { kvPairDao.put(KeyValuePair(key).put(value)) fireChangeListener(key) } override fun putStringSet(key: String, values: MutableSet?) = if (values == null) remove(key) else { kvPairDao.put(KeyValuePair(key).put(values)) fireChangeListener(key) } fun remove(key: String) { kvPairDao.delete(key) fireChangeListener(key) } private val listeners = HashSet() private fun fireChangeListener(key: String) { val listeners = synchronized(listeners) { listeners.toList() } listeners.forEach { it.onPreferenceDataStoreChanged(this, key) } } fun registerChangeListener(listener: OnPreferenceDataStoreChangeListener) { synchronized(listeners) { listeners.add(listener) } } fun unregisterChangeListener(listener: OnPreferenceDataStoreChangeListener) { synchronized(listeners) { listeners.remove(listener) } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/fmt/AbstractBean.java ================================================ package io.nekohasekai.sagernet.fmt; import androidx.annotation.NonNull; import com.esotericsoftware.kryo.io.ByteBufferInput; import com.esotericsoftware.kryo.io.ByteBufferOutput; import org.jetbrains.annotations.NotNull; import java.util.Arrays; import io.nekohasekai.sagernet.ktx.NetsKt; import moe.matsuri.nb4a.utils.JavaUtil; public abstract class AbstractBean extends Serializable { public String serverAddress; public Integer serverPort; public String name; // public String customOutboundJson; public String customConfigJson; // public transient String finalAddress; public transient int finalPort; public String displayName() { if (JavaUtil.isNotBlank(name)) { return name; } else { return displayAddress(); } } public String displayAddress() { return NetsKt.wrapIPV6Host(serverAddress) + ":" + serverPort; } public String network() { return "tcp,udp"; } public boolean canICMPing() { return true; } public boolean canTCPing() { return true; } public boolean canMapping() { return true; } @Override public void initializeDefaultValues() { if (JavaUtil.isNullOrBlank(serverAddress)) { serverAddress = "127.0.0.1"; } else if (serverAddress.startsWith("[") && serverAddress.endsWith("]")) { serverAddress = NetsKt.unwrapIPV6Host(serverAddress); } if (serverPort == null) { serverPort = 1080; } if (name == null) name = ""; finalAddress = serverAddress; finalPort = serverPort; if (customOutboundJson == null) customOutboundJson = ""; if (customConfigJson == null) customConfigJson = ""; } private transient boolean serializeWithoutName; @Override public void serializeToBuffer(@NonNull ByteBufferOutput output) { serialize(output); output.writeInt(1); if (!serializeWithoutName) { output.writeString(name); } output.writeString(customOutboundJson); output.writeString(customConfigJson); } @Override public void deserializeFromBuffer(@NonNull ByteBufferInput input) { deserialize(input); int extraVersion = input.readInt(); name = input.readString(); customOutboundJson = input.readString(); customConfigJson = input.readString(); } public void serialize(ByteBufferOutput output) { output.writeString(serverAddress); output.writeInt(serverPort); } public void deserialize(ByteBufferInput input) { serverAddress = input.readString(); serverPort = input.readInt(); } @NotNull @Override public abstract AbstractBean clone(); @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; try { serializeWithoutName = true; ((AbstractBean) o).serializeWithoutName = true; return Arrays.equals(KryoConverters.serialize(this), KryoConverters.serialize((AbstractBean) o)); } finally { serializeWithoutName = false; ((AbstractBean) o).serializeWithoutName = false; } } @Override public int hashCode() { try { serializeWithoutName = true; return Arrays.hashCode(KryoConverters.serialize(this)); } finally { serializeWithoutName = false; } } @NotNull @Override public String toString() { return getClass().getSimpleName() + " " + JavaUtil.gson.toJson(this); } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt ================================================ package io.nekohasekai.sagernet.fmt import android.widget.Toast import io.nekohasekai.sagernet.* import io.nekohasekai.sagernet.bg.VpnService import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.database.ProxyEntity import io.nekohasekai.sagernet.database.ProxyEntity.Companion.TYPE_CONFIG import io.nekohasekai.sagernet.database.SagerDatabase import io.nekohasekai.sagernet.fmt.ConfigBuildResult.IndexEntity import io.nekohasekai.sagernet.fmt.hysteria.HysteriaBean import io.nekohasekai.sagernet.fmt.hysteria.buildSingBoxOutboundHysteriaBean import io.nekohasekai.sagernet.fmt.internal.ChainBean import io.nekohasekai.sagernet.fmt.shadowsocks.ShadowsocksBean import io.nekohasekai.sagernet.fmt.shadowsocks.buildSingBoxOutboundShadowsocksBean import io.nekohasekai.sagernet.fmt.socks.SOCKSBean import io.nekohasekai.sagernet.fmt.socks.buildSingBoxOutboundSocksBean import io.nekohasekai.sagernet.fmt.ssh.SSHBean import io.nekohasekai.sagernet.fmt.ssh.buildSingBoxOutboundSSHBean import io.nekohasekai.sagernet.fmt.tuic.TuicBean import io.nekohasekai.sagernet.fmt.tuic.buildSingBoxOutboundTuicBean import io.nekohasekai.sagernet.fmt.v2ray.StandardV2RayBean import io.nekohasekai.sagernet.fmt.v2ray.buildSingBoxOutboundStandardV2RayBean import io.nekohasekai.sagernet.fmt.wireguard.WireGuardBean import io.nekohasekai.sagernet.fmt.wireguard.buildSingBoxOutboundWireguardBean import io.nekohasekai.sagernet.ktx.isIpAddress import io.nekohasekai.sagernet.ktx.mkPort import io.nekohasekai.sagernet.utils.PackageCache import moe.matsuri.nb4a.* import moe.matsuri.nb4a.SingBoxOptions.* import moe.matsuri.nb4a.plugin.Plugins import moe.matsuri.nb4a.proxy.anytls.AnyTLSBean import moe.matsuri.nb4a.proxy.anytls.buildSingBoxOutboundAnyTLSBean import moe.matsuri.nb4a.proxy.config.ConfigBean import moe.matsuri.nb4a.proxy.shadowtls.ShadowTLSBean import moe.matsuri.nb4a.proxy.shadowtls.buildSingBoxOutboundShadowTLSBean import moe.matsuri.nb4a.utils.JavaUtil.gson import moe.matsuri.nb4a.utils.Util import moe.matsuri.nb4a.utils.listByLineOrComma import okhttp3.HttpUrl.Companion.toHttpUrlOrNull const val TAG_MIXED = "mixed-in" const val TAG_PROXY = "proxy" const val TAG_DIRECT = "direct" const val TAG_BYPASS = "bypass" const val TAG_BLOCK = "block" const val LOCALHOST = "127.0.0.1" class ConfigBuildResult( var config: String, var externalIndex: List, var mainEntId: Long, var trafficMap: Map>, var profileTagMap: Map, val selectorGroupId: Long, ) { data class IndexEntity(var chain: LinkedHashMap) } fun buildConfig( proxy: ProxyEntity, forTest: Boolean = false, forExport: Boolean = false ): ConfigBuildResult { if (proxy.type == TYPE_CONFIG) { val bean = proxy.requireBean() as ConfigBean if (bean.type == 0) { return ConfigBuildResult( bean.config, listOf(), proxy.id, // mapOf(TAG_PROXY to listOf(proxy)), // mapOf(proxy.id to TAG_PROXY), // -1L ) } } val trafficMap = HashMap>() val tagMap = HashMap() val globalOutbounds = HashMap() val selectorNames = ArrayList() val group = SagerDatabase.groupDao.getById(proxy.groupId) fun ProxyEntity.resolveChainInternal(): MutableList { val bean = requireBean() if (bean is ChainBean) { val beans = SagerDatabase.proxyDao.getEntities(bean.proxies) val beansMap = beans.associateBy { it.id } val beanList = ArrayList() for (proxyId in bean.proxies) { val item = beansMap[proxyId] ?: continue beanList.addAll(item.resolveChainInternal()) } return beanList.asReversed() } return mutableListOf(this) } fun selectorName(name_: String): String { var name = name_ var count = 0 while (selectorNames.contains(name)) { count++ name = "$name_-$count" } selectorNames.add(name) return name } fun ProxyEntity.resolveChain(): MutableList { val thisGroup = SagerDatabase.groupDao.getById(groupId) val frontProxy = thisGroup?.frontProxy?.let { SagerDatabase.proxyDao.getById(it) } val landingProxy = thisGroup?.landingProxy?.let { SagerDatabase.proxyDao.getById(it) } val list = resolveChainInternal() if (frontProxy != null) { list.add(frontProxy) } if (landingProxy != null) { list.add(0, landingProxy) } return list } val extraRules = if (forTest) listOf() else SagerDatabase.rulesDao.enabledRules() val extraProxies = if (forTest) mapOf() else SagerDatabase.proxyDao.getEntities(extraRules.mapNotNull { rule -> rule.outbound.takeIf { it > 0 && it != proxy.id } }.toHashSet().toList()).associateBy { it.id } val buildSelector = !forTest && group?.isSelector == true && !forExport val userDNSRuleList = mutableListOf() val domainListDNSDirectForce = mutableListOf() val bypassDNSBeans = hashSetOf() val isVPN = DataStore.serviceMode == Key.MODE_VPN val bind = if (!forTest && DataStore.allowAccess) "0.0.0.0" else LOCALHOST val remoteDns = DataStore.remoteDns.split("\n") .mapNotNull { dns -> dns.trim().takeIf { it.isNotBlank() && !it.startsWith("#") } } val directDNS = DataStore.directDns.split("\n") .mapNotNull { dns -> dns.trim().takeIf { it.isNotBlank() && !it.startsWith("#") } } val enableDnsRouting = DataStore.enableDnsRouting val useFakeDns = DataStore.enableFakeDns && !forTest val needSniff = DataStore.trafficSniffing > 0 val needSniffOverride = DataStore.trafficSniffing == 2 val externalIndexMap = ArrayList() val ipv6Mode = if (forTest) IPv6Mode.ENABLE else DataStore.ipv6Mode fun genDomainStrategy(noAsIs: Boolean): String { return when { !noAsIs -> "" ipv6Mode == IPv6Mode.DISABLE -> "ipv4_only" ipv6Mode == IPv6Mode.PREFER -> "prefer_ipv6" ipv6Mode == IPv6Mode.ONLY -> "ipv6_only" else -> "prefer_ipv4" } } return MyOptions().apply { if (!forTest && DataStore.enableClashAPI) experimental = ExperimentalOptions().apply { clash_api = ClashAPIOptions().apply { external_controller = "127.0.0.1:9090" external_ui = "../files/yacd" } } log = LogOptions().apply { level = when (DataStore.logLevel) { 0 -> "panic" 1 -> "warn" 2 -> "info" 3 -> "debug" 4 -> "trace" else -> "info" } } dns = DNSOptions().apply { servers = mutableListOf() rules = mutableListOf() independent_cache = true } fun autoDnsDomainStrategy(s: String): String? { if (s.isNotEmpty()) { return s } return when (ipv6Mode) { IPv6Mode.DISABLE -> "ipv4_only" IPv6Mode.ENABLE -> "prefer_ipv4" IPv6Mode.PREFER -> "prefer_ipv6" IPv6Mode.ONLY -> "ipv6_only" else -> null } } inbounds = mutableListOf() if (!forTest) { if (isVPN) inbounds.add(Inbound_TunOptions().apply { type = "tun" tag = "tun-in" stack = when (DataStore.tunImplementation) { TunImplementation.GVISOR -> "gvisor" TunImplementation.SYSTEM -> "system" else -> "mixed" } endpoint_independent_nat = true mtu = DataStore.mtu domain_strategy = genDomainStrategy(DataStore.resolveDestination) sniff = needSniff sniff_override_destination = needSniffOverride when (ipv6Mode) { IPv6Mode.DISABLE -> { inet4_address = listOf(VpnService.PRIVATE_VLAN4_CLIENT + "/28") } IPv6Mode.ONLY -> { inet6_address = listOf(VpnService.PRIVATE_VLAN6_CLIENT + "/126") } else -> { inet4_address = listOf(VpnService.PRIVATE_VLAN4_CLIENT + "/28") inet6_address = listOf(VpnService.PRIVATE_VLAN6_CLIENT + "/126") } } }) inbounds.add(Inbound_MixedOptions().apply { type = "mixed" tag = TAG_MIXED listen = bind listen_port = DataStore.mixedPort domain_strategy = genDomainStrategy(DataStore.resolveDestination) sniff = needSniff sniff_override_destination = needSniffOverride }) } outbounds = mutableListOf() // init routing object route = RouteOptions().apply { auto_detect_interface = true rules = mutableListOf() rule_set = mutableListOf() } // returns outbound tag fun buildChain( chainId: Long, entity: ProxyEntity ): String { val profileList = entity.resolveChain() val chainTrafficSet = HashSet().apply { plusAssign(profileList) add(entity) } var currentOutbound: SingBoxOption lateinit var pastOutbound: SingBoxOption lateinit var pastInboundTag: String var pastEntity: ProxyEntity? = null val externalChainMap = LinkedHashMap() externalIndexMap.add(IndexEntity(externalChainMap)) val chainOutbounds = ArrayList() // chainTagOut: v2ray outbound tag for this chain var chainTagOut = "" val chainTag = "c-$chainId" var muxApplied = false val defaultServerDomainStrategy = SingBoxOptionsUtil.domainStrategy("server") profileList.forEachIndexed { index, proxyEntity -> val bean = proxyEntity.requireBean() // tagOut: v2ray outbound tag for a profile // profile2 (in) (global) tag g-(id) // profile1 tag (chainTag)-(id) // profile0 (out) tag (chainTag)-(id) / single: "proxy" var tagOut = "$chainTag-${proxyEntity.id}" // needGlobal: can only contain one? var needGlobal = false // first profile set as global if (index == profileList.lastIndex) { needGlobal = true tagOut = "g-" + proxyEntity.id bypassDNSBeans += proxyEntity.requireBean() } // last profile set as "proxy" if (chainId == 0L && index == 0) { tagOut = TAG_PROXY } // selector human readable name if (buildSelector && index == 0) { tagOut = selectorName(bean.displayName()) } // chain rules if (index > 0) { // chain route/proxy rules if (pastEntity!!.needExternal()) { route.rules.add(Rule_DefaultOptions().apply { inbound = listOf(pastInboundTag) outbound = tagOut }) } else { pastOutbound._hack_config_map["detour"] = tagOut } } else { // index == 0 means last profile in chain / not chain chainTagOut = tagOut } // now tagOut is determined if (needGlobal) { globalOutbounds[proxyEntity.id]?.let { if (index == 0) chainTagOut = it // single, duplicate chain return@forEachIndexed } globalOutbounds[proxyEntity.id] = tagOut } if (proxyEntity.needExternal()) { // externel outbound val localPort = mkPort() externalChainMap[localPort] = proxyEntity currentOutbound = Outbound_SocksOptions().apply { type = "socks" server = LOCALHOST server_port = localPort } } else { // internal outbound currentOutbound = when (bean) { is ConfigBean -> CustomSingBoxOption(bean.config) is ShadowTLSBean -> // before StandardV2RayBean buildSingBoxOutboundShadowTLSBean(bean) is StandardV2RayBean -> // http/trojan/vmess/vless buildSingBoxOutboundStandardV2RayBean(bean) is HysteriaBean -> buildSingBoxOutboundHysteriaBean(bean) is TuicBean -> buildSingBoxOutboundTuicBean(bean) is SOCKSBean -> buildSingBoxOutboundSocksBean(bean) is ShadowsocksBean -> buildSingBoxOutboundShadowsocksBean(bean) is WireGuardBean -> buildSingBoxOutboundWireguardBean(bean) is SSHBean -> buildSingBoxOutboundSSHBean(bean) is AnyTLSBean -> buildSingBoxOutboundAnyTLSBean(bean) else -> throw IllegalStateException("can't reach") } // internal mux if (!muxApplied) { val muxObj = proxyEntity.singMux() if (muxObj != null && muxObj.enabled) { muxApplied = true currentOutbound._hack_config_map["multiplex"] = muxObj.asMap() } } } // internal & external currentOutbound.apply { // udp over tcp try { val sUoT = bean.javaClass.getField("sUoT").get(bean) if (sUoT is Boolean && sUoT) { _hack_config_map["udp_over_tcp"] = true } } catch (_: Exception) { } // domain_strategy pastEntity?.requireBean()?.apply { // don't loopback if (defaultServerDomainStrategy != "" && !serverAddress.isIpAddress()) { domainListDNSDirectForce.add("full:$serverAddress") } } _hack_config_map["domain_strategy"] = if (forTest) "" else defaultServerDomainStrategy _hack_config_map["tag"] = tagOut _hack_custom_config = bean.customOutboundJson } // External proxy need a dokodemo-door inbound to forward the traffic // For external proxy software, their traffic must goes to v2ray-core to use protected fd. bean.finalAddress = bean.serverAddress bean.finalPort = bean.serverPort if (bean.canMapping() && proxyEntity.needExternal()) { // With ss protect, don't use mapping var needExternal = true if (index == profileList.lastIndex) { val pluginId = when (bean) { is HysteriaBean -> if (bean.protocolVersion == 1) "hysteria-plugin" else "hysteria2-plugin" else -> "" } if (Plugins.isUsingMatsuriExe(pluginId)) { needExternal = false } else if (Plugins.getPluginExternal(pluginId) != null) { throw Exception("You are using an unsupported $pluginId, please download the correct plugin.") } } if (needExternal) { val mappingPort = mkPort() bean.finalAddress = LOCALHOST bean.finalPort = mappingPort inbounds.add(Inbound_DirectOptions().apply { type = "direct" listen = LOCALHOST listen_port = mappingPort tag = "$chainTag-mapping-${proxyEntity.id}" override_address = bean.serverAddress override_port = bean.serverPort pastInboundTag = tag // no chain rule and not outbound, so need to set to direct if (index == profileList.lastIndex) { route.rules.add(Rule_DefaultOptions().apply { inbound = listOf(tag) outbound = TAG_DIRECT }) } }) } } outbounds.add(currentOutbound) chainOutbounds.add(currentOutbound) pastOutbound = currentOutbound pastEntity = proxyEntity } trafficMap[chainTagOut] = chainTrafficSet.toList() return chainTagOut } // build outbounds if (buildSelector) { val list = group.id.let { SagerDatabase.proxyDao.getByGroup(it) } list.forEach { tagMap[it.id] = buildChain(it.id, it) } outbounds.add(0, Outbound_SelectorOptions().apply { type = "selector" tag = TAG_PROXY default_ = tagMap[proxy.id] outbounds = tagMap.values.toList() }) } else { buildChain(0, proxy) } // build outbounds from route item extraProxies.forEach { (key, p) -> tagMap[key] = buildChain(key, p) } // apply user rules for (rule in extraRules) { if (rule.packages.isNotEmpty()) { PackageCache.awaitLoadSync() } val uidList = rule.packages.map { if (!isVPN) { Toast.makeText( SagerNet.application, SagerNet.application.getString(R.string.route_need_vpn, rule.displayName()), Toast.LENGTH_SHORT ).show() } PackageCache[it]?.takeIf { uid -> uid >= 1000 } }.toHashSet().filterNotNull() val ruleSets = mutableListOf() val ruleObj = Rule_DefaultOptions().apply { if (uidList.isNotEmpty()) { PackageCache.awaitLoadSync() user_id = uidList } var domainList: List? = null if (rule.domains.isNotBlank()) { domainList = rule.domains.listByLineOrComma() makeSingBoxRule(domainList, false) } if (rule.ip.isNotBlank()) { makeSingBoxRule(rule.ip.listByLineOrComma(), true) } if (rule_set != null) generateRuleSet(rule_set, ruleSets) if (rule.port.isNotBlank()) { port = mutableListOf() port_range = mutableListOf() rule.port.listByLineOrComma().map { if (it.contains(":")) { port_range.add(it) } else { it.toIntOrNull()?.apply { port.add(this) } } } } if (rule.sourcePort.isNotBlank()) { source_port = mutableListOf() source_port_range = mutableListOf() rule.sourcePort.listByLineOrComma().map { if (it.contains(":")) { source_port_range.add(it) } else { it.toIntOrNull()?.apply { source_port.add(this) } } } } if (rule.network.isNotBlank()) { network = listOf(rule.network) } if (rule.source.isNotBlank()) { source_ip_cidr = rule.source.listByLineOrComma() } if (rule.protocol.isNotBlank()) { protocol = rule.protocol.listByLineOrComma() } fun makeDnsRuleObj(): DNSRule_DefaultOptions { return DNSRule_DefaultOptions().apply { if (uidList.isNotEmpty()) user_id = uidList domainList?.let { makeSingBoxRule(it) } } } when (rule.outbound) { -1L -> { userDNSRuleList += makeDnsRuleObj().apply { server = "dns-direct" } } 0L -> { if (useFakeDns) userDNSRuleList += makeDnsRuleObj().apply { server = "dns-fake" inbound = listOf("tun-in") } userDNSRuleList += makeDnsRuleObj().apply { server = "dns-remote" } } -2L -> { userDNSRuleList += makeDnsRuleObj().apply { server = "dns-block" disable_cache = true } } } outbound = when (val outId = rule.outbound) { 0L -> TAG_PROXY -1L -> TAG_BYPASS -2L -> TAG_BLOCK else -> if (outId == proxy.id) TAG_PROXY else tagMap[outId] ?: "" } _hack_custom_config = rule.config } if (!ruleObj.checkEmpty()) { if (ruleObj.outbound.isNullOrBlank()) { Toast.makeText( SagerNet.application, "Warning: " + rule.displayName() + ": A non-existent outbound was specified.", Toast.LENGTH_LONG ).show() } else { // block 改用新的写法 if (ruleObj.outbound == TAG_BLOCK) { ruleObj.outbound = null ruleObj.action = "reject" } route.rules.add(ruleObj) route.rule_set.addAll(ruleSets) } } } // 对 rule_set tag 去重 if (route.rule_set != null) { route.rule_set = route.rule_set.distinctBy { it.tag } } for (freedom in arrayOf(TAG_DIRECT, TAG_BYPASS)) outbounds.add(Outbound().apply { tag = freedom type = "direct" }) // Bypass Lookup for the first profile bypassDNSBeans.forEach { var serverAddr = it.serverAddress if (it is ConfigBean) { var config = mutableMapOf() config = gson.fromJson(it.config, config.javaClass) config["server"]?.apply { serverAddr = toString() } } if (!serverAddr.isIpAddress()) { domainListDNSDirectForce.add("full:${serverAddr}") } } remoteDns.forEach { var address = it if (address.contains("://")) { address = address.substringAfter("://") } "https://$address".toHttpUrlOrNull()?.apply { if (!host.isIpAddress()) { domainListDNSDirectForce.add("full:$host") } } } dns.servers.add(DNSServerOptions().apply { address = "rcode://success" tag = "dns-block" }) dns.servers.add(DNSServerOptions().apply { address = "local" tag = "dns-local" detour = TAG_DIRECT }) directDNS.firstOrNull().let { dns.servers.add(DNSServerOptions().apply { address = it ?: throw Exception("No direct DNS, check your settings!") tag = "dns-direct" detour = TAG_DIRECT address_resolver = "dns-local" strategy = autoDnsDomainStrategy(SingBoxOptionsUtil.domainStrategy(tag)) }) } remoteDns.firstOrNull().let { // Always use direct DNS for urlTest if (!forTest) dns.servers.add(DNSServerOptions().apply { address = it ?: throw Exception("No remote DNS, check your settings!") tag = "dns-remote" address_resolver = "dns-direct" strategy = autoDnsDomainStrategy(SingBoxOptionsUtil.domainStrategy(tag)) }) } dns.final_ = if (forTest) "dns-direct" else "dns-remote" // dns object user rules if (enableDnsRouting) { userDNSRuleList.forEach { if (!it.checkEmpty()) dns.rules.add(it) } } if (forTest) { dns.rules = listOf() } else { // built-in DNS rules route.rules.add(0, Rule_DefaultOptions().apply { protocol = listOf("dns") action = "hijack-dns" }) route.rules.add(0, Rule_DefaultOptions().apply { port = listOf(53) action = "hijack-dns" }) if (DataStore.bypassLanInCore) { route.rules.add(Rule_DefaultOptions().apply { outbound = TAG_BYPASS ip_is_private = true }) } // block mcast route.rules.add(Rule_DefaultOptions().apply { ip_cidr = listOf("224.0.0.0/3", "ff00::/8") source_ip_cidr = listOf("224.0.0.0/3", "ff00::/8") action = "reject" }) // FakeDNS obj if (useFakeDns) { dns.fakeip = DNSFakeIPOptions().apply { enabled = true inet4_range = "198.18.0.0/15" inet6_range = "fc00::/18" } dns.servers.add(DNSServerOptions().apply { address = "fakeip" tag = "dns-fake" strategy = "ipv4_only" }) dns.rules.add(DNSRule_DefaultOptions().apply { inbound = listOf("tun-in") server = "dns-fake" disable_cache = true }) } // avoid loopback dns.rules.add(0, DNSRule_DefaultOptions().apply { outbound = mutableListOf("any") server = "dns-direct" }) // force bypass (always top DNS rule) if (domainListDNSDirectForce.isNotEmpty()) { dns.rules.add(0, DNSRule_DefaultOptions().apply { makeSingBoxRule(domainListDNSDirectForce.toHashSet().toList()) server = "dns-direct" }) } } if (!forTest) _hack_custom_config = DataStore.globalCustomConfig }.let { val configMap = it.asMap() Util.mergeJSON(configMap, proxy.requireBean().customConfigJson) ConfigBuildResult( gson.toJson(configMap), externalIndexMap, proxy.id, trafficMap, tagMap, if (buildSelector) group.id else -1L ) } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/fmt/KryoConverters.java ================================================ package io.nekohasekai.sagernet.fmt; import androidx.room.TypeConverter; import com.esotericsoftware.kryo.KryoException; import com.esotericsoftware.kryo.io.ByteBufferInput; import com.esotericsoftware.kryo.io.ByteBufferOutput; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import io.nekohasekai.sagernet.database.SubscriptionBean; import io.nekohasekai.sagernet.fmt.http.HttpBean; import io.nekohasekai.sagernet.fmt.hysteria.HysteriaBean; import io.nekohasekai.sagernet.fmt.internal.ChainBean; import io.nekohasekai.sagernet.fmt.mieru.MieruBean; import io.nekohasekai.sagernet.fmt.naive.NaiveBean; import io.nekohasekai.sagernet.fmt.shadowsocks.ShadowsocksBean; import moe.matsuri.nb4a.proxy.anytls.AnyTLSBean; import moe.matsuri.nb4a.proxy.shadowtls.ShadowTLSBean; import io.nekohasekai.sagernet.fmt.socks.SOCKSBean; import io.nekohasekai.sagernet.fmt.ssh.SSHBean; import io.nekohasekai.sagernet.fmt.trojan.TrojanBean; import io.nekohasekai.sagernet.fmt.trojan_go.TrojanGoBean; import io.nekohasekai.sagernet.fmt.tuic.TuicBean; import io.nekohasekai.sagernet.fmt.v2ray.VMessBean; import io.nekohasekai.sagernet.fmt.wireguard.WireGuardBean; import io.nekohasekai.sagernet.ktx.KryosKt; import io.nekohasekai.sagernet.ktx.Logs; import moe.matsuri.nb4a.proxy.config.ConfigBean; import moe.matsuri.nb4a.proxy.neko.NekoBean; import moe.matsuri.nb4a.utils.JavaUtil; public class KryoConverters { private static final byte[] NULL = new byte[0]; @TypeConverter public static byte[] serialize(Serializable bean) { if (bean == null) return NULL; ByteArrayOutputStream out = new ByteArrayOutputStream(); ByteBufferOutput buffer = KryosKt.byteBuffer(out); bean.serializeToBuffer(buffer); buffer.flush(); buffer.close(); return out.toByteArray(); } public static T deserialize(T bean, byte[] bytes) { if (bytes == null) return bean; ByteArrayInputStream input = new ByteArrayInputStream(bytes); ByteBufferInput buffer = KryosKt.byteBuffer(input); try { bean.deserializeFromBuffer(buffer); } catch (KryoException e) { Logs.INSTANCE.w(e); } bean.initializeDefaultValues(); return bean; } @TypeConverter public static SOCKSBean socksDeserialize(byte[] bytes) { if (JavaUtil.isEmpty(bytes)) return null; return deserialize(new SOCKSBean(), bytes); } @TypeConverter public static HttpBean httpDeserialize(byte[] bytes) { if (JavaUtil.isEmpty(bytes)) return null; return deserialize(new HttpBean(), bytes); } @TypeConverter public static ShadowsocksBean shadowsocksDeserialize(byte[] bytes) { if (JavaUtil.isEmpty(bytes)) return null; return deserialize(new ShadowsocksBean(), bytes); } @TypeConverter public static ConfigBean configDeserialize(byte[] bytes) { if (JavaUtil.isEmpty(bytes)) return null; return deserialize(new ConfigBean(), bytes); } @TypeConverter public static VMessBean vmessDeserialize(byte[] bytes) { if (JavaUtil.isEmpty(bytes)) return null; return deserialize(new VMessBean(), bytes); } @TypeConverter public static TrojanBean trojanDeserialize(byte[] bytes) { if (JavaUtil.isEmpty(bytes)) return null; return deserialize(new TrojanBean(), bytes); } @TypeConverter public static TrojanGoBean trojanGoDeserialize(byte[] bytes) { if (JavaUtil.isEmpty(bytes)) return null; return deserialize(new TrojanGoBean(), bytes); } @TypeConverter public static MieruBean mieruDeserialize(byte[] bytes) { if (JavaUtil.isEmpty(bytes)) return null; return deserialize(new MieruBean(), bytes); } @TypeConverter public static NaiveBean naiveDeserialize(byte[] bytes) { if (JavaUtil.isEmpty(bytes)) return null; return deserialize(new NaiveBean(), bytes); } @TypeConverter public static HysteriaBean hysteriaDeserialize(byte[] bytes) { if (JavaUtil.isEmpty(bytes)) return null; return deserialize(new HysteriaBean(), bytes); } @TypeConverter public static SSHBean sshDeserialize(byte[] bytes) { if (JavaUtil.isEmpty(bytes)) return null; return deserialize(new SSHBean(), bytes); } @TypeConverter public static WireGuardBean wireguardDeserialize(byte[] bytes) { if (JavaUtil.isEmpty(bytes)) return null; return deserialize(new WireGuardBean(), bytes); } @TypeConverter public static TuicBean tuicDeserialize(byte[] bytes) { if (JavaUtil.isEmpty(bytes)) return null; return deserialize(new TuicBean(), bytes); } @TypeConverter public static ShadowTLSBean shadowTLSDeserialize(byte[] bytes) { if (JavaUtil.isEmpty(bytes)) return null; return deserialize(new ShadowTLSBean(), bytes); } @TypeConverter public static AnyTLSBean anyTLSDeserialize(byte[] bytes) { if (JavaUtil.isEmpty(bytes)) return null; return deserialize(new AnyTLSBean(), bytes); } @TypeConverter public static ChainBean chainDeserialize(byte[] bytes) { if (JavaUtil.isEmpty(bytes)) return null; return deserialize(new ChainBean(), bytes); } @TypeConverter public static NekoBean nekoDeserialize(byte[] bytes) { if (JavaUtil.isEmpty(bytes)) return null; return deserialize(new NekoBean(), bytes); } @TypeConverter public static SubscriptionBean subscriptionDeserialize(byte[] bytes) { if (JavaUtil.isEmpty(bytes)) return null; return deserialize(new SubscriptionBean(), bytes); } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/fmt/PluginEntry.kt ================================================ package io.nekohasekai.sagernet.fmt import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.SagerNet enum class PluginEntry( val pluginId: String, val displayName: String, val packageName: String, // for play and f-droid page val downloadSource: DownloadSource = DownloadSource() ) { TrojanGo( "trojan-go-plugin", SagerNet.application.getString(R.string.action_trojan_go), "io.nekohasekai.sagernet.plugin.trojan_go" ), MieruProxy( "mieru-plugin", SagerNet.application.getString(R.string.action_mieru), "moe.matsuri.exe.mieru", DownloadSource( playStore = false, fdroid = false, downloadLink = "https://github.com/MatsuriDayo/plugins/releases?q=mieru" ) ), NaiveProxy( "naive-plugin", SagerNet.application.getString(R.string.action_naive), "moe.matsuri.exe.naive", DownloadSource( playStore = false, fdroid = false, downloadLink = "https://github.com/MatsuriDayo/plugins/releases?q=naive" ) ), Hysteria( "hysteria-plugin", SagerNet.application.getString(R.string.action_hysteria), "moe.matsuri.exe.hysteria", DownloadSource( playStore = false, fdroid = false, downloadLink = "https://github.com/MatsuriDayo/plugins/releases?q=Hysteria" ) ), ; data class DownloadSource( val playStore: Boolean = true, val fdroid: Boolean = true, val downloadLink: String = "https://matsuridayo.github.io/" ) companion object { fun find(name: String): PluginEntry? { for (pluginEntry in enumValues()) { if (name == pluginEntry.pluginId) { return pluginEntry } } return null } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/fmt/Serializable.kt ================================================ package io.nekohasekai.sagernet.fmt import android.os.Parcel import android.os.Parcelable import com.esotericsoftware.kryo.io.ByteBufferInput import com.esotericsoftware.kryo.io.ByteBufferOutput abstract class Serializable : Parcelable { abstract fun initializeDefaultValues() abstract fun serializeToBuffer(output: ByteBufferOutput) abstract fun deserializeFromBuffer(input: ByteBufferInput) override fun describeContents() = 0 override fun writeToParcel(dest: Parcel, flags: Int) { dest.writeByteArray(KryoConverters.serialize(this)) } abstract class CREATOR : Parcelable.Creator { abstract fun newInstance(): T override fun createFromParcel(source: Parcel): T { return KryoConverters.deserialize(newInstance(), source.createByteArray()) } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/fmt/TypeMap.kt ================================================ package io.nekohasekai.sagernet.fmt import io.nekohasekai.sagernet.database.ProxyEntity object TypeMap : HashMap() { init { this["socks"] = ProxyEntity.TYPE_SOCKS this["http"] = ProxyEntity.TYPE_HTTP this["ss"] = ProxyEntity.TYPE_SS this["vmess"] = ProxyEntity.TYPE_VMESS this["trojan"] = ProxyEntity.TYPE_TROJAN this["trojan-go"] = ProxyEntity.TYPE_TROJAN_GO this["mieru"] = ProxyEntity.TYPE_MIERU this["naive"] = ProxyEntity.TYPE_NAIVE this["hysteria"] = ProxyEntity.TYPE_HYSTERIA this["ssh"] = ProxyEntity.TYPE_SSH this["wg"] = ProxyEntity.TYPE_WG this["tuic"] = ProxyEntity.TYPE_TUIC this["anytls"] = ProxyEntity.TYPE_ANYTLS this["neko"] = ProxyEntity.TYPE_NEKO this["config"] = ProxyEntity.TYPE_CONFIG } val reversed = HashMap() init { TypeMap.forEach { (key, type) -> reversed[type] = key } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/fmt/UniversalFmt.kt ================================================ package io.nekohasekai.sagernet.fmt import io.nekohasekai.sagernet.database.ProxyEntity import io.nekohasekai.sagernet.database.ProxyGroup import moe.matsuri.nb4a.utils.Util fun parseUniversal(link: String): AbstractBean { return if (link.contains("?")) { val type = link.substringAfter("sn://").substringBefore("?") ProxyEntity(type = TypeMap[type] ?: error("Type $type not found")).apply { putByteArray(Util.zlibDecompress(Util.b64Decode(link.substringAfter("?")))) }.requireBean() } else { val type = link.substringAfter("sn://").substringBefore(":") ProxyEntity(type = TypeMap[type] ?: error("Type $type not found")).apply { putByteArray(Util.b64Decode(link.substringAfter(":").substringAfter(":"))) }.requireBean() } } fun AbstractBean.toUniversalLink(): String { var link = "sn://" link += TypeMap.reversed[ProxyEntity().putBean(this).type] link += "?" link += Util.b64EncodeUrlSafe(Util.zlibCompress(KryoConverters.serialize(this), 9)) return link } fun ProxyGroup.toUniversalLink(): String { var link = "sn://subscription?" export = true link += Util.b64EncodeUrlSafe(Util.zlibCompress(KryoConverters.serialize(this), 9)) export = false return link } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/fmt/gson/GsonConverters.java ================================================ package io.nekohasekai.sagernet.fmt.gson; import androidx.room.TypeConverter; import java.util.Collection; import java.util.List; import java.util.Set; import kotlin.collections.CollectionsKt; import kotlin.collections.SetsKt; import moe.matsuri.nb4a.utils.JavaUtil; public class GsonConverters { @TypeConverter public static String toJson(Object value) { if (value instanceof Collection) { if (((Collection) value).isEmpty()) return ""; } return JavaUtil.gson.toJson(value); } @TypeConverter public static List toList(String value) { if (JavaUtil.isNullOrBlank(value)) return CollectionsKt.listOf(); return JavaUtil.gson.fromJson(value, List.class); } @TypeConverter public static Set toSet(String value) { if (JavaUtil.isNullOrBlank(value)) return SetsKt.setOf(); return JavaUtil.gson.fromJson(value, Set.class); } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/fmt/http/HttpBean.java ================================================ package io.nekohasekai.sagernet.fmt.http; import androidx.annotation.NonNull; import com.esotericsoftware.kryo.io.ByteBufferInput; import com.esotericsoftware.kryo.io.ByteBufferOutput; import org.jetbrains.annotations.NotNull; import io.nekohasekai.sagernet.fmt.KryoConverters; import io.nekohasekai.sagernet.fmt.v2ray.StandardV2RayBean; public class HttpBean extends StandardV2RayBean { public String username; public String password; @Override public void initializeDefaultValues() { super.initializeDefaultValues(); if (username == null) username = ""; if (password == null) password = ""; } @Override public void serialize(ByteBufferOutput output) { output.writeInt(0); super.serialize(output); output.writeString(username); output.writeString(password); } @Override public void deserialize(ByteBufferInput input) { int version = input.readInt(); super.deserialize(input); username = input.readString(); password = input.readString(); } @NotNull @Override public HttpBean clone() { return KryoConverters.deserialize(new HttpBean(), KryoConverters.serialize(this)); } public static final Creator CREATOR = new CREATOR() { @NonNull @Override public HttpBean newInstance() { return new HttpBean(); } @Override public HttpBean[] newArray(int size) { return new HttpBean[size]; } }; } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/fmt/http/HttpFmt.kt ================================================ package io.nekohasekai.sagernet.fmt.http import io.nekohasekai.sagernet.fmt.v2ray.isTLS import io.nekohasekai.sagernet.fmt.v2ray.setTLS import io.nekohasekai.sagernet.ktx.urlSafe import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrlOrNull fun parseHttp(link: String): HttpBean { val httpUrl = link.toHttpUrlOrNull() ?: error("Invalid http(s) link: $link") if (httpUrl.encodedPath != "/") error("Not http proxy") return HttpBean().apply { serverAddress = httpUrl.host serverPort = httpUrl.port username = httpUrl.username password = httpUrl.password sni = httpUrl.queryParameter("sni") name = httpUrl.fragment setTLS(httpUrl.scheme == "https") } } fun HttpBean.toUri(): String { val builder = HttpUrl.Builder().scheme(if (isTLS()) "https" else "http").host(serverAddress) if (serverPort in 1..65535) { builder.port(serverPort) } if (username.isNotBlank()) { builder.username(username) } if (password.isNotBlank()) { builder.password(password) } if (sni.isNotBlank()) { builder.addQueryParameter("sni", sni) } if (name.isNotBlank()) { builder.encodedFragment(name.urlSafe()) } return builder.toString() } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/fmt/hysteria/HysteriaBean.java ================================================ package io.nekohasekai.sagernet.fmt.hysteria; import androidx.annotation.NonNull; import com.esotericsoftware.kryo.io.ByteBufferInput; import com.esotericsoftware.kryo.io.ByteBufferOutput; import org.jetbrains.annotations.NotNull; import io.nekohasekai.sagernet.fmt.AbstractBean; import io.nekohasekai.sagernet.fmt.KryoConverters; import io.nekohasekai.sagernet.ktx.NetsKt; import kotlin.text.StringsKt; public class HysteriaBean extends AbstractBean { public Integer protocolVersion; // Use serverPorts instead of serverPort public String serverPorts; // HY1 & 2 public String authPayload; public String obfuscation; public String sni; public String caText; public Integer uploadMbps; public Integer downloadMbps; public Boolean allowInsecure; public Integer streamReceiveWindow; public Integer connectionReceiveWindow; public Boolean disableMtuDiscovery; public Integer hopInterval; // HY1 public String alpn; public static final int TYPE_NONE = 0; public static final int TYPE_STRING = 1; public static final int TYPE_BASE64 = 2; public Integer authPayloadType; public static final int PROTOCOL_UDP = 0; public static final int PROTOCOL_FAKETCP = 1; public static final int PROTOCOL_WECHAT_VIDEO = 2; public Integer protocol; @Override public boolean canMapping() { return protocol != PROTOCOL_FAKETCP; } @Override public void initializeDefaultValues() { super.initializeDefaultValues(); if (protocolVersion == null) protocolVersion = 2; if (authPayloadType == null) authPayloadType = TYPE_NONE; if (authPayload == null) authPayload = ""; if (protocol == null) protocol = PROTOCOL_UDP; if (obfuscation == null) obfuscation = ""; if (sni == null) sni = ""; if (alpn == null) alpn = ""; if (caText == null) caText = ""; if (allowInsecure == null) allowInsecure = false; if (protocolVersion == 1) { if (uploadMbps == null) uploadMbps = 10; if (downloadMbps == null) downloadMbps = 50; } else { if (uploadMbps == null) uploadMbps = 0; if (downloadMbps == null) downloadMbps = 0; } if (streamReceiveWindow == null) streamReceiveWindow = 0; if (connectionReceiveWindow == null) connectionReceiveWindow = 0; if (disableMtuDiscovery == null) disableMtuDiscovery = false; if (hopInterval == null) hopInterval = 10; if (serverPorts == null) serverPorts = "443"; } @Override public void serialize(ByteBufferOutput output) { output.writeInt(7); super.serialize(output); output.writeInt(protocolVersion); output.writeInt(authPayloadType); output.writeString(authPayload); output.writeInt(protocol); output.writeString(obfuscation); output.writeString(sni); output.writeString(alpn); output.writeInt(uploadMbps); output.writeInt(downloadMbps); output.writeBoolean(allowInsecure); output.writeString(caText); output.writeInt(streamReceiveWindow); output.writeInt(connectionReceiveWindow); output.writeBoolean(disableMtuDiscovery); output.writeInt(hopInterval); output.writeString(serverPorts); } @Override public void deserialize(ByteBufferInput input) { int version = input.readInt(); super.deserialize(input); if (version >= 7) { protocolVersion = input.readInt(); } else { protocolVersion = 1; } authPayloadType = input.readInt(); authPayload = input.readString(); if (version >= 3) { protocol = input.readInt(); } obfuscation = input.readString(); sni = input.readString(); if (version >= 2) { alpn = input.readString(); } uploadMbps = input.readInt(); downloadMbps = input.readInt(); allowInsecure = input.readBoolean(); if (version >= 1) { caText = input.readString(); streamReceiveWindow = input.readInt(); connectionReceiveWindow = input.readInt(); if (version != 4) disableMtuDiscovery = input.readBoolean(); // note: skip 4 } if (version >= 5) { hopInterval = input.readInt(); } if (version >= 6) { serverPorts = input.readString(); } else { // old update to new if (HysteriaFmtKt.isMultiPort(serverAddress)) { serverPorts = StringsKt.substringAfterLast(serverAddress, ":", serverAddress); serverAddress = StringsKt.substringBeforeLast(serverAddress, ":", serverAddress); } else { serverPorts = serverPort.toString(); } } } @Override public String displayAddress() { return NetsKt.wrapIPV6Host(serverAddress) + ":" + serverPorts; } @Override public boolean canTCPing() { return false; } @NotNull @Override public HysteriaBean clone() { return KryoConverters.deserialize(new HysteriaBean(), KryoConverters.serialize(this)); } public static final Creator CREATOR = new CREATOR() { @NonNull @Override public HysteriaBean newInstance() { return new HysteriaBean(); } @Override public HysteriaBean[] newArray(int size) { return new HysteriaBean[size]; } }; } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/fmt/hysteria/HysteriaFmt.kt ================================================ package io.nekohasekai.sagernet.fmt.hysteria import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.fmt.LOCALHOST import io.nekohasekai.sagernet.ktx.* import moe.matsuri.nb4a.SingBoxOptions import moe.matsuri.nb4a.utils.listByLineOrComma import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import org.json.JSONObject import java.io.File // hysteria://host:port?auth=123456&peer=sni.domain&insecure=1|0&upmbps=100&downmbps=100&alpn=hysteria&obfs=xplus&obfsParam=123456#remarks fun parseHysteria1(url: String): HysteriaBean { val link = url.replace("hysteria://", "https://").toHttpUrlOrNull() ?: error( "invalid hysteria link $url" ) return HysteriaBean().apply { protocolVersion = 1 serverAddress = link.host serverPorts = link.port.toString() name = link.fragment link.queryParameter("mport")?.also { serverPorts = it } link.queryParameter("peer")?.also { sni = it } link.queryParameter("auth")?.takeIf { it.isNotBlank() }?.also { authPayloadType = HysteriaBean.TYPE_STRING authPayload = it } link.queryParameter("insecure")?.also { allowInsecure = it == "1" || it == "true" } link.queryParameter("upmbps")?.also { uploadMbps = it.toIntOrNull() ?: uploadMbps } link.queryParameter("downmbps")?.also { downloadMbps = it.toIntOrNull() ?: downloadMbps } link.queryParameter("alpn")?.also { alpn = it } link.queryParameter("obfsParam")?.also { obfuscation = it } link.queryParameter("protocol")?.also { when (it) { "faketcp" -> { protocol = HysteriaBean.PROTOCOL_FAKETCP } "wechat-video" -> { protocol = HysteriaBean.PROTOCOL_WECHAT_VIDEO } } } } } // hysteria2://[auth@]hostname[:port]/?[key=value]&[key=value]... fun parseHysteria2(url: String): HysteriaBean { val link = url .replace("hysteria2://", "https://") .replace("hy2://", "https://") .toHttpUrlOrNull() ?: error("invalid hysteria link $url") return HysteriaBean().apply { protocolVersion = 2 serverAddress = link.host serverPorts = link.port.toString() authPayload = if (link.password.isNotBlank()) { link.username + ":" + link.password } else { link.username } name = link.fragment link.queryParameter("mport")?.also { serverPorts = it } link.queryParameter("sni")?.also { sni = it } link.queryParameter("insecure")?.also { allowInsecure = it == "1" || it == "true" } // link.queryParameter("upmbps")?.also { // uploadMbps = it.toIntOrNull() ?: uploadMbps // } // link.queryParameter("downmbps")?.also { // downloadMbps = it.toIntOrNull() ?: downloadMbps // } link.queryParameter("obfs-password")?.also { obfuscation = it } // link.queryParameter("pinSHA256")?.also { // // TODO your box do not support it // } } } fun HysteriaBean.toUri(): String { var un = "" var pw = "" if (protocolVersion == 2) { if (authPayload.contains(":")) { un = authPayload.substringBefore(":") pw = authPayload.substringAfter(":") } else { un = authPayload } } // val builder = linkBuilder() .host(serverAddress) .port(getFirstPort(serverPorts)) .username(un) .password(pw) if (isMultiPort(displayAddress())) { builder.addQueryParameter("mport", serverPorts) } if (name.isNotBlank()) { builder.encodedFragment(name.urlSafe()) } if (allowInsecure) { builder.addQueryParameter("insecure", "1") } if (protocolVersion == 1) { if (sni.isNotBlank()) { builder.addQueryParameter("peer", sni) } if (authPayload.isNotBlank()) { builder.addQueryParameter("auth", authPayload) } builder.addQueryParameter("upmbps", "$uploadMbps") builder.addQueryParameter("downmbps", "$downloadMbps") if (alpn.isNotBlank()) { builder.addQueryParameter("alpn", alpn) } if (obfuscation.isNotBlank()) { builder.addQueryParameter("obfs", "xplus") builder.addQueryParameter("obfsParam", obfuscation) } when (protocol) { HysteriaBean.PROTOCOL_FAKETCP -> { builder.addQueryParameter("protocol", "faketcp") } HysteriaBean.PROTOCOL_WECHAT_VIDEO -> { builder.addQueryParameter("protocol", "wechat-video") } } } else { if (sni.isNotBlank()) { builder.addQueryParameter("sni", sni) } if (obfuscation.isNotBlank()) { builder.addQueryParameter("obfs", "salamander") builder.addQueryParameter("obfs-password", obfuscation) } } return builder.toLink(if (protocolVersion == 2) "hy2" else "hysteria") } fun JSONObject.parseHysteria1Json(): HysteriaBean { // TODO parse HY2 JSON+YAML return HysteriaBean().apply { protocolVersion = 1 serverAddress = optString("server").substringBeforeLast(":") serverPorts = optString("server").substringAfterLast(":") uploadMbps = getIntNya("up_mbps") downloadMbps = getIntNya("down_mbps") obfuscation = getStr("obfs") getStr("auth")?.also { authPayloadType = HysteriaBean.TYPE_BASE64 authPayload = it } getStr("auth_str")?.also { authPayloadType = HysteriaBean.TYPE_STRING authPayload = it } getStr("protocol")?.also { when (it) { "faketcp" -> { protocol = HysteriaBean.PROTOCOL_FAKETCP } "wechat-video" -> { protocol = HysteriaBean.PROTOCOL_WECHAT_VIDEO } } } sni = getStr("server_name") alpn = getStr("alpn") allowInsecure = getBool("insecure") streamReceiveWindow = getIntNya("recv_window_conn") connectionReceiveWindow = getIntNya("recv_window") disableMtuDiscovery = getBool("disable_mtu_discovery") } } fun HysteriaBean.buildHysteria1Config(port: Int, cacheFile: (() -> File)?): String { if (protocolVersion != 1) { throw Exception("error version: $protocolVersion") } return JSONObject().apply { put("server", displayAddress()) when (protocol) { HysteriaBean.PROTOCOL_FAKETCP -> { put("protocol", "faketcp") } HysteriaBean.PROTOCOL_WECHAT_VIDEO -> { put("protocol", "wechat-video") } } put("up_mbps", uploadMbps) put("down_mbps", downloadMbps) put( "socks5", JSONObject( mapOf( "listen" to "$LOCALHOST:$port", ) ) ) put("retry", 5) put("fast_open", true) put("lazy_start", true) put("obfs", obfuscation) when (authPayloadType) { HysteriaBean.TYPE_BASE64 -> put("auth", authPayload) HysteriaBean.TYPE_STRING -> put("auth_str", authPayload) } if (sni.isBlank() && finalAddress == LOCALHOST && !serverAddress.isIpAddress()) { sni = serverAddress } if (sni.isNotBlank()) { put("server_name", sni) } if (alpn.isNotBlank()) put("alpn", alpn) if (caText.isNotBlank() && cacheFile != null) { val caFile = cacheFile() caFile.writeText(caText) put("ca", caFile.absolutePath) } if (allowInsecure) put("insecure", true) if (streamReceiveWindow > 0) put("recv_window_conn", streamReceiveWindow) if (connectionReceiveWindow > 0) put("recv_window", connectionReceiveWindow) if (disableMtuDiscovery) put("disable_mtu_discovery", true) put("hop_interval", hopInterval) }.toStringPretty() } fun isMultiPort(hyAddr: String): Boolean { if (!hyAddr.contains(":")) return false val p = hyAddr.substringAfterLast(":") if (p.contains("-") || p.contains(",")) return true return false } fun getFirstPort(portStr: String): Int { return portStr.substringBefore(":").substringBefore(",").toIntOrNull() ?: 443 } fun HysteriaBean.canUseSingBox(): Boolean { if (protocol != HysteriaBean.PROTOCOL_UDP) return false return true } fun buildSingBoxOutboundHysteriaBean(bean: HysteriaBean): SingBoxOptions.SingBoxOption { return when (bean.protocolVersion) { 1 -> SingBoxOptions.Outbound_HysteriaOptions().apply { type = "hysteria" server = bean.serverAddress val port = bean.serverPorts.toIntOrNull() if (port != null) { server_port = port } else { server_ports = hopPortsToSingboxList(bean.serverPorts) } hop_interval = "${bean.hopInterval}s" up_mbps = bean.uploadMbps down_mbps = bean.downloadMbps obfs = bean.obfuscation disable_mtu_discovery = bean.disableMtuDiscovery when (bean.authPayloadType) { HysteriaBean.TYPE_BASE64 -> auth = bean.authPayload HysteriaBean.TYPE_STRING -> auth_str = bean.authPayload } if (bean.streamReceiveWindow > 0) { recv_window_conn = bean.streamReceiveWindow.toLong() } if (bean.connectionReceiveWindow > 0) { recv_window_conn = bean.connectionReceiveWindow.toLong() } tls = SingBoxOptions.OutboundTLSOptions().apply { if (bean.sni.isNotBlank()) { server_name = bean.sni } if (bean.alpn.isNotBlank()) { alpn = bean.alpn.listByLineOrComma() } if (bean.caText.isNotBlank()) { certificate = bean.caText } insecure = bean.allowInsecure || DataStore.globalAllowInsecure enabled = true } } 2 -> SingBoxOptions.Outbound_Hysteria2Options().apply { type = "hysteria2" server = bean.serverAddress val port = bean.serverPorts.toIntOrNull() if (port != null) { server_port = port } else { server_ports = hopPortsToSingboxList(bean.serverPorts) } hop_interval = "${bean.hopInterval}s" up_mbps = bean.uploadMbps down_mbps = bean.downloadMbps if (bean.obfuscation.isNotBlank()) { obfs = SingBoxOptions.Hysteria2Obfs().apply { type = "salamander" password = bean.obfuscation } } // disable_mtu_discovery = bean.disableMtuDiscovery password = bean.authPayload // if (bean.streamReceiveWindow > 0) { // recv_window_conn = bean.streamReceiveWindow.toLong() // } // if (bean.connectionReceiveWindow > 0) { // recv_window_conn = bean.connectionReceiveWindow.toLong() // } tls = SingBoxOptions.OutboundTLSOptions().apply { if (bean.sni.isNotBlank()) { server_name = bean.sni } alpn = listOf("h3") if (bean.caText.isNotBlank()) { certificate = bean.caText } insecure = bean.allowInsecure || DataStore.globalAllowInsecure enabled = true } } else -> error("error_version $bean.protocolVersion") } } fun hopPortsToSingboxList(s: String): List { return s.split(",").mapNotNull { val pRange = it.replace("-", ":") if (pRange.split(":").size == 2) { pRange } else { null } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/fmt/internal/ChainBean.java ================================================ package io.nekohasekai.sagernet.fmt.internal; import androidx.annotation.NonNull; import com.esotericsoftware.kryo.io.ByteBufferInput; import com.esotericsoftware.kryo.io.ByteBufferOutput; import org.jetbrains.annotations.NotNull; import java.util.ArrayList; import java.util.List; import io.nekohasekai.sagernet.fmt.KryoConverters; import moe.matsuri.nb4a.utils.JavaUtil; public class ChainBean extends InternalBean { public List proxies; @Override public String displayName() { if (JavaUtil.isNotBlank(name)) { return name; } else { return "Chain " + Math.abs(hashCode()); } } @Override public void initializeDefaultValues() { super.initializeDefaultValues(); if (name == null) name = ""; if (proxies == null) { proxies = new ArrayList<>(); } } @Override public void serialize(ByteBufferOutput output) { output.writeInt(1); output.writeInt(proxies.size()); for (Long proxy : proxies) { output.writeLong(proxy); } } @Override public void deserialize(ByteBufferInput input) { int version = input.readInt(); if (version < 1) { input.readString(); input.readInt(); } int length = input.readInt(); proxies = new ArrayList<>(); for (int i = 0; i < length; i++) { proxies.add(input.readLong()); } } @NotNull @Override public ChainBean clone() { return KryoConverters.deserialize(new ChainBean(), KryoConverters.serialize(this)); } public static final Creator CREATOR = new CREATOR() { @NonNull @Override public ChainBean newInstance() { return new ChainBean(); } @Override public ChainBean[] newArray(int size) { return new ChainBean[size]; } }; } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/fmt/internal/InternalBean.java ================================================ package io.nekohasekai.sagernet.fmt.internal; import io.nekohasekai.sagernet.fmt.AbstractBean; public abstract class InternalBean extends AbstractBean { @Override public String displayAddress() { return ""; } @Override public boolean canICMPing() { return false; } @Override public boolean canTCPing() { return false; } @Override public boolean canMapping() { return false; } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/fmt/mieru/MieruBean.java ================================================ /****************************************************************************** * Copyright (C) 2022 by nekohasekai * * * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . * * * ******************************************************************************/ package io.nekohasekai.sagernet.fmt.mieru; import androidx.annotation.NonNull; import com.esotericsoftware.kryo.io.ByteBufferInput; import com.esotericsoftware.kryo.io.ByteBufferOutput; import org.jetbrains.annotations.NotNull; import io.nekohasekai.sagernet.fmt.AbstractBean; import io.nekohasekai.sagernet.fmt.KryoConverters; public class MieruBean extends AbstractBean { public String protocol; public String username; public String password; public Integer mtu; @Override public void initializeDefaultValues() { super.initializeDefaultValues(); if (protocol == null) protocol = "TCP"; if (username == null) username = ""; if (password == null) password = ""; if (mtu == null) mtu = 1400; } @Override public void serialize(ByteBufferOutput output) { output.writeInt(0); super.serialize(output); output.writeString(protocol); output.writeString(username); output.writeString(password); if (protocol.equals("UDP")) { output.writeInt(mtu); } } @Override public void deserialize(ByteBufferInput input) { int version = input.readInt(); super.deserialize(input); protocol = input.readString(); username = input.readString(); password = input.readString(); if (protocol.equals("UDP")) { mtu = input.readInt(); } } @NotNull @Override public MieruBean clone() { return KryoConverters.deserialize(new MieruBean(), KryoConverters.serialize(this)); } public static final Creator CREATOR = new CREATOR() { @NonNull @Override public MieruBean newInstance() { return new MieruBean(); } @Override public MieruBean[] newArray(int size) { return new MieruBean[size]; } }; } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/fmt/mieru/MieruFmt.kt ================================================ /****************************************************************************** * Copyright (C) 2022 by nekohasekai * * * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . * * * ******************************************************************************/ package io.nekohasekai.sagernet.fmt.mieru import io.nekohasekai.sagernet.ktx.toStringPretty import org.json.JSONArray import org.json.JSONObject fun MieruBean.buildMieruConfig(port: Int): String { val serverInfo = JSONArray().apply { put(JSONObject().apply { put("ipAddress", finalAddress) put("portBindings", JSONArray().apply { put(JSONObject().apply { put("port", finalPort) put("protocol", protocol) }) }) }) } return JSONObject().apply { put("activeProfile", "default") put("socks5Port", port) // TODO: follow NekoBox logging level. put("loggingLevel", "INFO") put("profiles", JSONArray().apply { put(JSONObject().apply { put("profileName", "default") put("user", JSONObject().apply { put("name", username) put("password", password) }) put("servers", serverInfo) put("mtu", mtu) }) }) }.toStringPretty() } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/fmt/naive/NaiveBean.java ================================================ package io.nekohasekai.sagernet.fmt.naive; import androidx.annotation.NonNull; import com.esotericsoftware.kryo.io.ByteBufferInput; import com.esotericsoftware.kryo.io.ByteBufferOutput; import org.jetbrains.annotations.NotNull; import io.nekohasekai.sagernet.fmt.AbstractBean; import io.nekohasekai.sagernet.fmt.KryoConverters; public class NaiveBean extends AbstractBean { /** * Available proto: https, quic. */ public String proto; public String username; public String password; public String extraHeaders; public String sni; public String certificates; public Integer insecureConcurrency; // sing-box socks public Boolean sUoT; @Override public void initializeDefaultValues() { if (serverPort == null) serverPort = 443; super.initializeDefaultValues(); if (proto == null) proto = "https"; if (username == null) username = ""; if (password == null) password = ""; if (extraHeaders == null) extraHeaders = ""; if (certificates == null) certificates = ""; if (sni == null) sni = ""; if (insecureConcurrency == null) insecureConcurrency = 0; if (sUoT == null) sUoT = false; } @Override public void serialize(ByteBufferOutput output) { output.writeInt(3); super.serialize(output); output.writeString(proto); output.writeString(username); output.writeString(password); // note: sequence is different from SagerNet,,, output.writeString(extraHeaders); output.writeString(certificates); output.writeString(sni); output.writeInt(insecureConcurrency); output.writeBoolean(sUoT); } @Override public void deserialize(ByteBufferInput input) { int version = input.readInt(); super.deserialize(input); proto = input.readString(); username = input.readString(); password = input.readString(); extraHeaders = input.readString(); if (version >= 2) { certificates = input.readString(); sni = input.readString(); } if (version >= 1) { insecureConcurrency = input.readInt(); } if (version >= 3) { sUoT = input.readBoolean(); } } @NotNull @Override public NaiveBean clone() { return KryoConverters.deserialize(new NaiveBean(), KryoConverters.serialize(this)); } public static final Creator CREATOR = new CREATOR() { @NonNull @Override public NaiveBean newInstance() { return new NaiveBean(); } @Override public NaiveBean[] newArray(int size) { return new NaiveBean[size]; } }; } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/fmt/naive/NaiveFmt.kt ================================================ package io.nekohasekai.sagernet.fmt.naive import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.fmt.LOCALHOST import io.nekohasekai.sagernet.ktx.* import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import org.json.JSONObject fun parseNaive(link: String): NaiveBean { val proto = link.substringAfter("+").substringBefore(":") val url = ("https://" + link.substringAfter("://")).toHttpUrlOrNull() ?: error("Invalid naive link: $link") return NaiveBean().also { it.proto = proto }.apply { serverAddress = url.host serverPort = url.port username = url.username password = url.password sni = url.queryParameter("sni") certificates = url.queryParameter("cert") extraHeaders = url.queryParameter("extra-headers")?.unUrlSafe()?.replace("\r\n", "\n") insecureConcurrency = url.queryParameter("insecure-concurrency")?.toIntOrNull() name = url.fragment initializeDefaultValues() } } fun NaiveBean.toUri(proxyOnly: Boolean = false): String { val builder = linkBuilder().host(finalAddress).port(finalPort) if (username.isNotBlank()) { builder.username(username) if (password.isNotBlank()) { builder.password(password) } } if (!proxyOnly) { if (sni.isNotBlank()) { builder.addQueryParameter("sni", sni) } if (certificates.isNotBlank()) { builder.addQueryParameter("cert", certificates) } if (extraHeaders.isNotBlank()) { builder.addQueryParameter("extra-headers", extraHeaders) } if (name.isNotBlank()) { builder.encodedFragment(name.urlSafe()) } if (insecureConcurrency > 0) { builder.addQueryParameter("insecure-concurrency", "$insecureConcurrency") } } return builder.toLink(if (proxyOnly) proto else "naive+$proto", false) } fun NaiveBean.buildNaiveConfig(port: Int): String { return JSONObject().apply { // process ipv6 finalAddress = finalAddress.wrapIPV6Host() serverAddress = serverAddress.wrapIPV6Host() // process sni if (sni.isNotBlank()) { put("host-resolver-rules", "MAP $sni $finalAddress") finalAddress = sni } else { if (serverAddress.isIpAddress()) { // for naive, using IP as SNI name hardly happens // and host-resolver-rules cannot resolve the SNI problem // so do nothing } else { put("host-resolver-rules", "MAP $serverAddress $finalAddress") finalAddress = serverAddress } } put("listen", "socks://$LOCALHOST:$port") put("proxy", toUri(true)) if (extraHeaders.isNotBlank()) { put("extra-headers", extraHeaders.split("\n").joinToString("\r\n")) } if (DataStore.logLevel > 0) { put("log", "") } if (insecureConcurrency > 0) { put("insecure-concurrency", insecureConcurrency) } }.toStringPretty() } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/fmt/shadowsocks/ShadowsocksBean.java ================================================ package io.nekohasekai.sagernet.fmt.shadowsocks; import androidx.annotation.NonNull; import com.esotericsoftware.kryo.io.ByteBufferInput; import com.esotericsoftware.kryo.io.ByteBufferOutput; import org.jetbrains.annotations.NotNull; import io.nekohasekai.sagernet.fmt.AbstractBean; import io.nekohasekai.sagernet.fmt.KryoConverters; import moe.matsuri.nb4a.utils.JavaUtil; public class ShadowsocksBean extends AbstractBean { public String method; public String password; public String plugin; public Boolean sUoT; @Override public void initializeDefaultValues() { super.initializeDefaultValues(); if (JavaUtil.isNullOrBlank(method)) method = "aes-256-gcm"; if (method == null) method = ""; if (password == null) password = ""; if (plugin == null) plugin = ""; if (sUoT == null) sUoT = false; } @Override public void serialize(ByteBufferOutput output) { output.writeInt(2); super.serialize(output); output.writeString(method); output.writeString(password); output.writeString(plugin); output.writeBoolean(sUoT); } @Override public void deserialize(ByteBufferInput input) { int version = input.readInt(); super.deserialize(input); method = input.readString(); password = input.readString(); plugin = input.readString(); sUoT = input.readBoolean(); } @NotNull @Override public ShadowsocksBean clone() { return KryoConverters.deserialize(new ShadowsocksBean(), KryoConverters.serialize(this)); } public static final Creator CREATOR = new CREATOR() { @NonNull @Override public ShadowsocksBean newInstance() { return new ShadowsocksBean(); } @Override public ShadowsocksBean[] newArray(int size) { return new ShadowsocksBean[size]; } }; } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/fmt/shadowsocks/ShadowsocksFmt.kt ================================================ package io.nekohasekai.sagernet.fmt.shadowsocks import io.nekohasekai.sagernet.ktx.* import moe.matsuri.nb4a.SingBoxOptions import moe.matsuri.nb4a.utils.Util import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import org.json.JSONObject fun ShadowsocksBean.fixPluginName() { if (plugin.startsWith("simple-obfs")) { plugin = plugin.replaceFirst("simple-obfs", "obfs-local") } } fun parseShadowsocks(url: String): ShadowsocksBean { if (url.substringBefore("#").contains("@")) { var link = url.replace("ss://", "https://").toHttpUrlOrNull() ?: error( "invalid ss-android link $url" ) if (link.username.isBlank()) { // fix justmysocks's shit link link = (("https://" + url.substringAfter("ss://") .substringBefore("#") .decodeBase64UrlSafe()).toHttpUrlOrNull() ?: error("invalid jms link $url") ).newBuilder().fragment(url.substringAfter("#")).build() } // ss-android style if (link.password.isNotBlank()) { return ShadowsocksBean().apply { serverAddress = link.host serverPort = link.port method = link.username password = link.password plugin = link.queryParameter("plugin") ?: "" name = link.fragment fixPluginName() } } val methodAndPswd = link.username.decodeBase64UrlSafe() return ShadowsocksBean().apply { serverAddress = link.host serverPort = link.port method = methodAndPswd.substringBefore(":") password = methodAndPswd.substringAfter(":") plugin = link.queryParameter("plugin") ?: "" name = link.fragment fixPluginName() } } else { // v2rayN style var v2Url = url if (v2Url.contains("#")) v2Url = v2Url.substringBefore("#") val link = ("https://" + v2Url.substringAfter("ss://") .decodeBase64UrlSafe()).toHttpUrlOrNull() ?: error("invalid v2rayN link $url") return ShadowsocksBean().apply { serverAddress = link.host serverPort = link.port method = link.username password = link.password plugin = "" val remarks = url.substringAfter("#").unUrlSafe() if (remarks.isNotBlank()) name = remarks } } } fun ShadowsocksBean.toUri(): String { val builder = linkBuilder().username(Util.b64EncodeUrlSafe("$method:$password")) .host(serverAddress) .port(serverPort) if (plugin.isNotBlank()) { builder.addQueryParameter("plugin", plugin) } if (name.isNotBlank()) { builder.encodedFragment(name.urlSafe()) } return builder.toLink("ss").replace("$serverPort/", "$serverPort") } fun JSONObject.parseShadowsocks(): ShadowsocksBean { return ShadowsocksBean().apply { serverAddress = getStr("server") serverPort = getIntNya("server_port") password = getStr("password") method = getStr("method") name = optString("remarks", "") val pId = getStr("plugin") if (!pId.isNullOrBlank()) { plugin = pId + ";" + optString("plugin_opts", "") } } } fun buildSingBoxOutboundShadowsocksBean(bean: ShadowsocksBean): SingBoxOptions.Outbound_ShadowsocksOptions { return SingBoxOptions.Outbound_ShadowsocksOptions().apply { type = "shadowsocks" server = bean.serverAddress server_port = bean.serverPort password = bean.password method = bean.method if (bean.plugin.isNotBlank()) { plugin = bean.plugin.substringBefore(";") plugin_opts = bean.plugin.substringAfter(";") if (plugin == "none") { plugin = null plugin_opts = null } } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/fmt/socks/SOCKSBean.java ================================================ package io.nekohasekai.sagernet.fmt.socks; import androidx.annotation.NonNull; import com.esotericsoftware.kryo.io.ByteBufferInput; import com.esotericsoftware.kryo.io.ByteBufferOutput; import org.jetbrains.annotations.NotNull; import io.nekohasekai.sagernet.fmt.AbstractBean; import io.nekohasekai.sagernet.fmt.KryoConverters; public class SOCKSBean extends AbstractBean { public Integer protocol; public Boolean sUoT; public int protocolVersion() { switch (protocol) { case 0: case 1: return 4; default: return 5; } } public String protocolName() { switch (protocol) { case 0: return "SOCKS4"; case 1: return "SOCKS4A"; default: return "SOCKS5"; } } public String protocolVersionName() { switch (protocol) { case 0: return "4"; case 1: return "4a"; default: return "5"; } } public String username; public String password; public static final int PROTOCOL_SOCKS4 = 0; public static final int PROTOCOL_SOCKS4A = 1; public static final int PROTOCOL_SOCKS5 = 2; @Override public String network() { if (protocol < PROTOCOL_SOCKS5) return "tcp"; return super.network(); } @Override public void initializeDefaultValues() { super.initializeDefaultValues(); if (protocol == null) protocol = PROTOCOL_SOCKS5; if (username == null) username = ""; if (password == null) password = ""; if (sUoT == null) sUoT = false; } @Override public void serialize(ByteBufferOutput output) { output.writeInt(2); super.serialize(output); output.writeInt(protocol); output.writeString(username); output.writeString(password); output.writeBoolean(sUoT); } @Override public void deserialize(ByteBufferInput input) { int version = input.readInt(); super.deserialize(input); if (version >= 1) { protocol = input.readInt(); } username = input.readString(); password = input.readString(); if (version >= 2) { sUoT = input.readBoolean(); } } @NotNull @Override public SOCKSBean clone() { return KryoConverters.deserialize(new SOCKSBean(), KryoConverters.serialize(this)); } public static final Creator CREATOR = new CREATOR() { @NonNull @Override public SOCKSBean newInstance() { return new SOCKSBean(); } @Override public SOCKSBean[] newArray(int size) { return new SOCKSBean[size]; } }; } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/fmt/socks/SOCKSFmt.kt ================================================ package io.nekohasekai.sagernet.fmt.socks import io.nekohasekai.sagernet.ktx.decodeBase64UrlSafe import io.nekohasekai.sagernet.ktx.toLink import io.nekohasekai.sagernet.ktx.unUrlSafe import io.nekohasekai.sagernet.ktx.urlSafe import moe.matsuri.nb4a.SingBoxOptions import moe.matsuri.nb4a.utils.NGUtil import moe.matsuri.nb4a.utils.Util import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrlOrNull fun parseSOCKS(link: String): SOCKSBean { val url = ("http://" + link.substringAfter("://")).toHttpUrlOrNull() ?: error("Not supported: $link") return SOCKSBean().apply { protocol = when { link.startsWith("socks4://") -> SOCKSBean.PROTOCOL_SOCKS4 link.startsWith("socks4a://") -> SOCKSBean.PROTOCOL_SOCKS4A else -> SOCKSBean.PROTOCOL_SOCKS5 } name = url.fragment serverAddress = url.host serverPort = url.port username = url.username password = url.password // v2rayN fmt if (password.isNullOrBlank() && !username.isNullOrBlank()) { try { val n = username.decodeBase64UrlSafe() username = n.substringBefore(":") password = n.substringAfter(":") } catch (_: Exception) { } } } } fun SOCKSBean.toUri(): String { val builder = HttpUrl.Builder().scheme("http").host(serverAddress).port(serverPort) if (!username.isNullOrBlank()) builder.username(username) if (!password.isNullOrBlank()) builder.password(password) if (!name.isNullOrBlank()) builder.encodedFragment(name.urlSafe()) return builder.toLink("socks${protocolVersion()}") } fun SOCKSBean.toV2rayN(): String { var link = "" if (username.isNotBlank()) { link += username.urlSafe() + ":" + password.urlSafe() + "@" } link += "$serverAddress:$serverPort" link = "socks://" + NGUtil.encode(link) if (name.isNotBlank()) { link += "#" + name.urlSafe() } return link } fun buildSingBoxOutboundSocksBean(bean: SOCKSBean): SingBoxOptions.Outbound_SocksOptions { return SingBoxOptions.Outbound_SocksOptions().apply { type = "socks" server = bean.serverAddress server_port = bean.serverPort username = bean.username password = bean.password version = bean.protocolVersionName() } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/fmt/ssh/SSHBean.java ================================================ package io.nekohasekai.sagernet.fmt.ssh; import androidx.annotation.NonNull; import com.esotericsoftware.kryo.io.ByteBufferInput; import com.esotericsoftware.kryo.io.ByteBufferOutput; import org.jetbrains.annotations.NotNull; import io.nekohasekai.sagernet.fmt.AbstractBean; import io.nekohasekai.sagernet.fmt.KryoConverters; public class SSHBean extends AbstractBean { public static final int AUTH_TYPE_NONE = 0; public static final int AUTH_TYPE_PASSWORD = 1; public static final int AUTH_TYPE_PRIVATE_KEY = 2; public String username; public Integer authType; public String password; public String privateKey; public String privateKeyPassphrase; public String publicKey; @Override public void initializeDefaultValues() { if (serverPort == null) serverPort = 22; super.initializeDefaultValues(); if (username == null) username = "root"; if (authType == null) authType = AUTH_TYPE_PASSWORD; if (password == null) password = ""; if (privateKey == null) privateKey = ""; if (privateKeyPassphrase == null) privateKeyPassphrase = ""; if (publicKey == null) publicKey = ""; } @Override public void serialize(ByteBufferOutput output) { output.writeInt(0); super.serialize(output); output.writeString(username); output.writeInt(authType); switch (authType) { case AUTH_TYPE_NONE: break; case AUTH_TYPE_PASSWORD: output.writeString(password); break; case AUTH_TYPE_PRIVATE_KEY: output.writeString(privateKey); output.writeString(privateKeyPassphrase); break; } output.writeString(publicKey); } @Override public void deserialize(ByteBufferInput input) { int version = input.readInt(); super.deserialize(input); username = input.readString(); authType = input.readInt(); switch (authType) { case AUTH_TYPE_NONE: break; case AUTH_TYPE_PASSWORD: password = input.readString(); break; case AUTH_TYPE_PRIVATE_KEY: privateKey = input.readString(); privateKeyPassphrase = input.readString(); break; } publicKey = input.readString(); } @NotNull @Override public SSHBean clone() { return KryoConverters.deserialize(new SSHBean(), KryoConverters.serialize(this)); } public static final Creator CREATOR = new CREATOR() { @NonNull @Override public SSHBean newInstance() { return new SSHBean(); } @Override public SSHBean[] newArray(int size) { return new SSHBean[size]; } }; } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/fmt/ssh/SSHFmt.kt ================================================ package io.nekohasekai.sagernet.fmt.ssh import moe.matsuri.nb4a.SingBoxOptions import moe.matsuri.nb4a.utils.listByLineOrComma fun buildSingBoxOutboundSSHBean(bean: SSHBean): SingBoxOptions.Outbound_SSHOptions { return SingBoxOptions.Outbound_SSHOptions().apply { type = "ssh" server = bean.serverAddress server_port = bean.serverPort user = bean.username if (bean.publicKey.isNotBlank()) { host_key = bean.publicKey.listByLineOrComma() } when (bean.authType) { SSHBean.AUTH_TYPE_PRIVATE_KEY -> { private_key = bean.privateKey private_key_passphrase = bean.privateKeyPassphrase } else -> { password = bean.password } } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/fmt/trojan/TrojanBean.java ================================================ package io.nekohasekai.sagernet.fmt.trojan; import androidx.annotation.NonNull; import com.esotericsoftware.kryo.io.ByteBufferInput; import com.esotericsoftware.kryo.io.ByteBufferOutput; import org.jetbrains.annotations.NotNull; import io.nekohasekai.sagernet.fmt.KryoConverters; import io.nekohasekai.sagernet.fmt.v2ray.StandardV2RayBean; public class TrojanBean extends StandardV2RayBean { public String password; @Override public void initializeDefaultValues() { super.initializeDefaultValues(); if (security == null || security.isEmpty()) security = "tls"; if (password == null) password = ""; } @Override public void serialize(ByteBufferOutput output) { output.writeInt(2); super.serialize(output); output.writeString(password); } @Override public void deserialize(ByteBufferInput input) { int version = input.readInt(); if (version >= 2) { super.deserialize(input); // StandardV2RayBean password = input.readString(); } else { // From AbstractBean serverAddress = input.readString(); serverPort = input.readInt(); // From TrojanBean password = input.readString(); security = input.readString(); sni = input.readString(); alpn = input.readString(); if (version == 1) allowInsecure = input.readBoolean(); } } @NotNull @Override public TrojanBean clone() { return KryoConverters.deserialize(new TrojanBean(), KryoConverters.serialize(this)); } public static final Creator CREATOR = new CREATOR() { @NonNull @Override public TrojanBean newInstance() { return new TrojanBean(); } @Override public TrojanBean[] newArray(int size) { return new TrojanBean[size]; } }; } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/fmt/trojan/TrojanFmt.kt ================================================ package io.nekohasekai.sagernet.fmt.trojan import io.nekohasekai.sagernet.fmt.v2ray.parseDuckSoft import okhttp3.HttpUrl.Companion.toHttpUrlOrNull fun parseTrojan(server: String): TrojanBean { val link = server.replace("trojan://", "https://").toHttpUrlOrNull() ?: error("invalid trojan link $server") return TrojanBean().apply { parseDuckSoft(link) link.queryParameter("allowInsecure") ?.apply { if (this == "1" || this == "true") allowInsecure = true } link.queryParameter("peer")?.apply { if (this.isNotBlank()) sni = this } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/fmt/trojan_go/TrojanGoBean.java ================================================ package io.nekohasekai.sagernet.fmt.trojan_go; import androidx.annotation.NonNull; import com.esotericsoftware.kryo.io.ByteBufferInput; import com.esotericsoftware.kryo.io.ByteBufferOutput; import org.jetbrains.annotations.NotNull; import io.nekohasekai.sagernet.fmt.AbstractBean; import io.nekohasekai.sagernet.fmt.KryoConverters; import moe.matsuri.nb4a.utils.JavaUtil; public class TrojanGoBean extends AbstractBean { /** * Trojan 的密码。 * 不可省略,不能为空字符串,不建议含有非 ASCII 可打印字符。 * 必须使用 encodeURIComponent 编码。 */ public String password; /** * 自定义 TLS 的 SNI。 * 省略时默认与 trojan-host 同值。不得为空字符串。 *

* 必须使用 encodeURIComponent 编码。 */ public String sni; /** * 传输类型。 * 省略时默认为 original,但不可为空字符串。 * 目前可选值只有 original 和 ws,未来可能会有 h2、h2+ws 等取值。 *

* 当取值为 original 时,使用原始 Trojan 传输方式,无法方便通过 CDN。 * 当取值为 ws 时,使用 wss 作为传输层。 */ public String type; /** * 自定义 HTTP Host 头。 * 可以省略,省略时值同 trojan-host。 * 可以为空字符串,但可能带来非预期情形。 *

* 警告:若你的端口非标准端口(不是 80 / 443),RFC 标准规定 Host 应在主机名后附上端口号,例如 example.com:44333。至于是否遵守,请自行斟酌。 *

* 必须使用 encodeURIComponent 编码。 */ public String host; /** * 当传输类型 type 取 ws、h2、h2+ws 时,此项有效。 * 不可省略,不可为空。 * 必须以 / 开头。 * 可以使用 URL 中的 & # ? 等字符,但应当是合法的 URL 路径。 *

* 必须使用 encodeURIComponent 编码。 */ public String path; /** * 用于保证 Trojan 流量密码学安全的加密层。 * 可省略,默认为 none,即不使用加密。 * 不可以为空字符串。 *

* 必须使用 encodeURIComponent 编码。 *

* 使用 Shadowsocks 算法进行流量加密时,其格式为: *

* ss;method:password *

* 其中 ss 是固定内容,method 是加密方法,必须为下列之一: *

* aes-128-gcm * aes-256-gcm * chacha20-ietf-poly1305 */ public String encryption; /** * 额外的插件选项。本字段保留。 * 可省略,但不可以为空字符串。 */ // not used in NB4A public String plugin; // --- public Boolean allowInsecure; @Override public void initializeDefaultValues() { super.initializeDefaultValues(); if (password == null) password = ""; if (sni == null) sni = ""; if (JavaUtil.isNullOrBlank(type)) type = "original"; if (host == null) host = ""; if (path == null) path = ""; if (JavaUtil.isNullOrBlank(encryption)) encryption = "none"; if (plugin == null) plugin = ""; if (allowInsecure == null) allowInsecure = false; } @Override public void serialize(ByteBufferOutput output) { output.writeInt(1); super.serialize(output); output.writeString(password); output.writeString(sni); output.writeString(type); //noinspection SwitchStatementWithTooFewBranches switch (type) { case "ws": { output.writeString(host); output.writeString(path); break; } } output.writeString(encryption); output.writeString(plugin); output.writeBoolean(allowInsecure); } @Override public void deserialize(ByteBufferInput input) { int version = input.readInt(); super.deserialize(input); password = input.readString(); sni = input.readString(); type = input.readString(); //noinspection SwitchStatementWithTooFewBranches switch (type) { case "ws": { host = input.readString(); path = input.readString(); break; } } encryption = input.readString(); plugin = input.readString(); if (version >= 1) { allowInsecure = input.readBoolean(); } } @NotNull @Override public TrojanGoBean clone() { return KryoConverters.deserialize(new TrojanGoBean(), KryoConverters.serialize(this)); } public static final Creator CREATOR = new CREATOR() { @NonNull @Override public TrojanGoBean newInstance() { return new TrojanGoBean(); } @Override public TrojanGoBean[] newArray(int size) { return new TrojanGoBean[size]; } }; } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/fmt/trojan_go/TrojanGoFmt.kt ================================================ package io.nekohasekai.sagernet.fmt.trojan_go import io.nekohasekai.sagernet.IPv6Mode import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.fmt.LOCALHOST import io.nekohasekai.sagernet.ktx.* import moe.matsuri.nb4a.Protocols import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import org.json.JSONArray import org.json.JSONObject fun parseTrojanGo(server: String): TrojanGoBean { val link = server.replace("trojan-go://", "https://").toHttpUrlOrNull() ?: error( "invalid trojan-link link $server" ) return TrojanGoBean().apply { serverAddress = link.host serverPort = link.port password = link.username link.queryParameter("sni")?.let { sni = it } link.queryParameter("type")?.let { lType -> type = lType when (type) { "ws" -> { link.queryParameter("host")?.let { host = it } link.queryParameter("path")?.let { path = it } } else -> { } } } link.queryParameter("encryption")?.let { encryption = it } link.queryParameter("plugin")?.let { plugin = it } link.fragment.takeIf { !it.isNullOrBlank() }?.let { name = it } } } fun TrojanGoBean.toUri(): String { val builder = linkBuilder().username(password).host(serverAddress).port(serverPort) if (sni.isNotBlank()) { builder.addQueryParameter("sni", sni) } if (type.isNotBlank() && type != "original") { builder.addQueryParameter("type", type) when (type) { "ws" -> { if (host.isNotBlank()) { builder.addQueryParameter("host", host) } if (path.isNotBlank()) { builder.addQueryParameter("path", path) } } } } if (type.isNotBlank() && type != "none") { builder.addQueryParameter("encryption", encryption) } if (plugin.isNotBlank()) { builder.addQueryParameter("plugin", plugin) } if (name.isNotBlank()) { builder.encodedFragment(name.urlSafe()) } return builder.toLink("trojan-go") } fun TrojanGoBean.buildTrojanGoConfig(port: Int): String { return JSONObject().apply { put("run_type", "client") put("local_addr", LOCALHOST) put("local_port", port) put("remote_addr", finalAddress) put("remote_port", finalPort) put("password", JSONArray().apply { put(password) }) put("log_level", if (DataStore.logLevel > 0) 0 else 2) // if (Protocols.shouldEnableMux("trojan-go")) put("mux", JSONObject().apply { // put("enabled", true) // put("concurrency", DataStore.muxConcurrency) // }) put("tcp", JSONObject().apply { put("prefer_ipv4", DataStore.ipv6Mode <= IPv6Mode.ENABLE) }) when (type) { "original" -> { } "ws" -> put("websocket", JSONObject().apply { put("enabled", true) put("host", host) put("path", path) }) } if (sni.isBlank() && finalAddress == LOCALHOST && !serverAddress.isIpAddress()) { sni = serverAddress } put("ssl", JSONObject().apply { if (sni.isNotBlank()) put("sni", sni) if (allowInsecure) put("verify", false) }) when { encryption == "none" -> { } encryption.startsWith("ss;") -> put("shadowsocks", JSONObject().apply { put("enabled", true) put("method", encryption.substringAfter(";").substringBefore(":")) put("password", encryption.substringAfter(":")) }) } }.toStringPretty() } fun JSONObject.parseTrojanGo(): TrojanGoBean { return TrojanGoBean().applyDefaultValues().apply { serverAddress = optString("remote_addr", serverAddress) serverPort = optInt("remote_port", serverPort) when (val pass = get("password")) { is String -> { password = pass } is List<*> -> { password = pass[0] as String } } optJSONArray("ssl")?.apply { sni = optString("sni", sni) } optJSONArray("websocket")?.apply { if (optBoolean("enabled", false)) { type = "ws" host = optString("host", host) path = optString("path", path) } } optJSONArray("shadowsocks")?.apply { if (optBoolean("enabled", false)) { encryption = "ss;${optString("method", "")}:${optString("password", "")}" } } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/fmt/tuic/TuicBean.java ================================================ package io.nekohasekai.sagernet.fmt.tuic; import androidx.annotation.NonNull; import com.esotericsoftware.kryo.io.ByteBufferInput; import com.esotericsoftware.kryo.io.ByteBufferOutput; import org.jetbrains.annotations.NotNull; import io.nekohasekai.sagernet.fmt.AbstractBean; import io.nekohasekai.sagernet.fmt.KryoConverters; public class TuicBean extends AbstractBean { public String token; public String caText; public String udpRelayMode; public String congestionController; public String alpn; public Boolean disableSNI; public Boolean reduceRTT; public Integer mtu; public String sni; // TUIC zep public Boolean fastConnect; public Boolean allowInsecure; // TUIC v5 public String customJSON; public Integer protocolVersion; public String uuid; @Override public void initializeDefaultValues() { super.initializeDefaultValues(); if (token == null) token = ""; if (caText == null) caText = ""; if (udpRelayMode == null) udpRelayMode = "native"; if (congestionController == null) congestionController = "cubic"; if (alpn == null) alpn = ""; if (disableSNI == null) disableSNI = false; if (reduceRTT == null) reduceRTT = false; if (mtu == null) mtu = 1400; if (sni == null) sni = ""; if (fastConnect == null) fastConnect = false; if (allowInsecure == null) allowInsecure = false; if (customJSON == null) customJSON = ""; if (protocolVersion == null) protocolVersion = 5; if (uuid == null) uuid = ""; } @Override public void serialize(ByteBufferOutput output) { output.writeInt(2); super.serialize(output); output.writeString(token); output.writeString(caText); output.writeString(udpRelayMode); output.writeString(congestionController); output.writeString(alpn); output.writeBoolean(disableSNI); output.writeBoolean(reduceRTT); output.writeInt(mtu); output.writeString(sni); output.writeBoolean(fastConnect); output.writeBoolean(allowInsecure); output.writeString(customJSON); output.writeInt(protocolVersion); output.writeString(uuid); } @Override public void deserialize(ByteBufferInput input) { int version = input.readInt(); super.deserialize(input); token = input.readString(); caText = input.readString(); udpRelayMode = input.readString(); congestionController = input.readString(); alpn = input.readString(); disableSNI = input.readBoolean(); reduceRTT = input.readBoolean(); mtu = input.readInt(); sni = input.readString(); if (version >= 1) { fastConnect = input.readBoolean(); allowInsecure = input.readBoolean(); } if (version >= 2) { customJSON = input.readString(); protocolVersion = input.readInt(); uuid = input.readString(); } else { protocolVersion = 4; } } @Override public boolean canTCPing() { return false; } @NotNull @Override public TuicBean clone() { return KryoConverters.deserialize(new TuicBean(), KryoConverters.serialize(this)); } public static final Creator CREATOR = new CREATOR() { @NonNull @Override public TuicBean newInstance() { return new TuicBean(); } @Override public TuicBean[] newArray(int size) { return new TuicBean[size]; } }; } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/fmt/tuic/TuicFmt.kt ================================================ package io.nekohasekai.sagernet.fmt.tuic import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.ktx.linkBuilder import io.nekohasekai.sagernet.ktx.toLink import io.nekohasekai.sagernet.ktx.urlSafe import moe.matsuri.nb4a.SingBoxOptions import moe.matsuri.nb4a.utils.listByLineOrComma import okhttp3.HttpUrl.Companion.toHttpUrlOrNull fun parseTuic(url: String): TuicBean { // https://github.com/daeuniverse/dae/discussions/182 val link = url.replace("tuic://", "https://").toHttpUrlOrNull() ?: error( "invalid tuic link $url" ) return TuicBean().apply { protocolVersion = 5 name = link.fragment serverAddress = link.host serverPort = link.port val rawUser = link.username val rawPass = link.password if (rawUser.contains(":")) { val parts = rawUser.split(":", limit = 2) uuid = parts[0] token = parts.getOrElse(1) { "" } } else { uuid = rawUser token = rawPass } link.queryParameter("sni")?.let { sni = it } link.queryParameter("congestion_control")?.let { congestionController = it } link.queryParameter("udp_relay_mode")?.let { udpRelayMode = it } link.queryParameter("alpn")?.let { alpn = it } link.queryParameter("allow_insecure")?.let { if (it == "1") allowInsecure = true } link.queryParameter("disable_sni")?.let { if (it == "1") disableSNI = true } } } fun TuicBean.toUri(): String { val builder = linkBuilder().username(uuid).password(token).host(serverAddress).port(serverPort) builder.addQueryParameter("congestion_control", congestionController) builder.addQueryParameter("udp_relay_mode", udpRelayMode) if (sni.isNotBlank()) builder.addQueryParameter("sni", sni) if (alpn.isNotBlank()) builder.addQueryParameter("alpn", alpn) if (allowInsecure) builder.addQueryParameter("allow_insecure", "1") if (disableSNI) builder.addQueryParameter("disable_sni", "1") if (name.isNotBlank()) builder.encodedFragment(name.urlSafe()) return builder.toLink("tuic") } fun buildSingBoxOutboundTuicBean(bean: TuicBean): SingBoxOptions.Outbound_TUICOptions { if (bean.protocolVersion == 4) throw Exception("TUIC v4 is no longer supported") return SingBoxOptions.Outbound_TUICOptions().apply { type = "tuic" server = bean.serverAddress server_port = bean.serverPort uuid = bean.uuid password = bean.token congestion_control = bean.congestionController when (bean.udpRelayMode) { "quic" -> udp_relay_mode = "quic" } zero_rtt_handshake = bean.reduceRTT tls = SingBoxOptions.OutboundTLSOptions().apply { if (bean.sni.isNotBlank()) { server_name = bean.sni } if (bean.alpn.isNotBlank()) { alpn = bean.alpn.listByLineOrComma() } if (bean.caText.isNotBlank()) { certificate = bean.caText } disable_sni = bean.disableSNI insecure = bean.allowInsecure || DataStore.globalAllowInsecure enabled = true } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/fmt/v2ray/StandardV2RayBean.java ================================================ package io.nekohasekai.sagernet.fmt.v2ray; import com.esotericsoftware.kryo.io.ByteBufferInput; import com.esotericsoftware.kryo.io.ByteBufferOutput; import io.nekohasekai.sagernet.fmt.AbstractBean; import io.nekohasekai.sagernet.fmt.trojan.TrojanBean; import moe.matsuri.nb4a.utils.JavaUtil; public abstract class StandardV2RayBean extends AbstractBean { public String uuid; public String encryption; // or VLESS flow //////// End of VMess & VLESS //////// // "V2Ray Transport" tcp/http/ws/quic/grpc/httpupgrade public String type; public String host; public String path; // --------------------------------------- tls? public String security; public String sni; public String alpn; public String utlsFingerprint; public Boolean allowInsecure; // --------------------------------------- reality public String realityPubKey; public String realityShortId; // --------------------------------------- // public Integer wsMaxEarlyData; public String earlyDataHeaderName; public String certificates; // --------------------------------------- ech public Boolean enableECH; public String echConfig; // --------------------------------------- Mux public Boolean enableMux; public Boolean muxPadding; public Integer muxType; public Integer muxConcurrency; // --------------------------------------- // public Integer packetEncoding; // 1:packet 2:xudp @Override public void initializeDefaultValues() { super.initializeDefaultValues(); if (JavaUtil.isNullOrBlank(uuid)) uuid = ""; if (JavaUtil.isNullOrBlank(type)) type = "tcp"; else if ("h2".equals(type)) type = "http"; type = type.toLowerCase(); if (JavaUtil.isNullOrBlank(host)) host = ""; if (JavaUtil.isNullOrBlank(path)) path = ""; if (JavaUtil.isNullOrBlank(security)) { if (this instanceof TrojanBean) { security = "tls"; } else { security = "none"; } } if (JavaUtil.isNullOrBlank(sni)) sni = ""; if (JavaUtil.isNullOrBlank(alpn)) alpn = ""; if (JavaUtil.isNullOrBlank(certificates)) certificates = ""; if (JavaUtil.isNullOrBlank(earlyDataHeaderName)) earlyDataHeaderName = ""; if (JavaUtil.isNullOrBlank(utlsFingerprint)) utlsFingerprint = ""; if (wsMaxEarlyData == null) wsMaxEarlyData = 0; if (allowInsecure == null) allowInsecure = false; if (packetEncoding == null) packetEncoding = 0; if (realityPubKey == null) realityPubKey = ""; if (realityShortId == null) realityShortId = ""; if (enableECH == null) enableECH = false; if (JavaUtil.isNullOrBlank(echConfig)) echConfig = ""; if (enableMux == null) enableMux = false; if (muxPadding == null) muxPadding = false; if (muxType == null) muxType = 0; if (muxConcurrency == null) muxConcurrency = 1; } @Override public void serialize(ByteBufferOutput output) { output.writeInt(4); super.serialize(output); output.writeString(uuid); output.writeString(encryption); if (this instanceof VMessBean) { output.writeInt(((VMessBean) this).alterId); } output.writeString(type); switch (type) { case "tcp": case "quic": { break; } case "ws": { output.writeString(host); output.writeString(path); output.writeInt(wsMaxEarlyData); output.writeString(earlyDataHeaderName); break; } case "http": case "httpupgrade": { output.writeString(host); output.writeString(path); break; } case "grpc": { output.writeString(path); break; } } output.writeString(security); if ("tls".equals(security)) { output.writeString(sni); output.writeString(alpn); output.writeString(certificates); output.writeBoolean(allowInsecure); output.writeString(utlsFingerprint); output.writeString(realityPubKey); output.writeString(realityShortId); } output.writeBoolean(enableECH); output.writeString(echConfig); output.writeInt(packetEncoding); output.writeBoolean(enableMux); output.writeBoolean(muxPadding); output.writeInt(muxType); output.writeInt(muxConcurrency); } @Override public void deserialize(ByteBufferInput input) { int version = input.readInt(); super.deserialize(input); uuid = input.readString(); encryption = input.readString(); if (this instanceof VMessBean) { ((VMessBean) this).alterId = input.readInt(); } type = input.readString(); switch (type) { case "tcp": case "quic": { break; } case "ws": { host = input.readString(); path = input.readString(); wsMaxEarlyData = input.readInt(); earlyDataHeaderName = input.readString(); break; } case "http": case "httpupgrade": { host = input.readString(); path = input.readString(); break; } case "grpc": { path = input.readString(); if (version < 4) { // 解决老版本数据的读取问题 input.readString(); input.readString(); } break; } } security = input.readString(); if ("tls".equals(security)) { sni = input.readString(); alpn = input.readString(); certificates = input.readString(); allowInsecure = input.readBoolean(); utlsFingerprint = input.readString(); realityPubKey = input.readString(); realityShortId = input.readString(); } if (version >= 1) { enableECH = input.readBoolean(); if (version >= 3) { echConfig = input.readString(); } else { if (enableECH) { input.readBoolean(); input.readBoolean(); echConfig = input.readString(); } } } else if (version == 0) { // 从老版本升级上来但是 version == 0, 可能有 enableECH 也可能没有,需要做判断 int position = input.getByteBuffer().position(); // 当前位置 boolean tmpEnableECH = input.readBoolean(); int tmpPacketEncoding = input.readInt(); input.setPosition(position); // 读后归位 if (tmpPacketEncoding != 1 && tmpPacketEncoding != 2) { enableECH = tmpEnableECH; if (enableECH) { input.readBoolean(); input.readBoolean(); echConfig = input.readString(); } } // 否则后一位就是 packetEncoding } packetEncoding = input.readInt(); if (version >= 2) { enableMux = input.readBoolean(); muxPadding = input.readBoolean(); muxType = input.readInt(); muxConcurrency = input.readInt(); } } public boolean isVLESS() { if (this instanceof VMessBean) { Integer aid = ((VMessBean) this).alterId; return aid != null && aid == -1; } return false; } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/fmt/v2ray/V2RayFmt.kt ================================================ package io.nekohasekai.sagernet.fmt.v2ray import android.text.TextUtils import com.google.gson.Gson import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.fmt.http.HttpBean import io.nekohasekai.sagernet.fmt.trojan.TrojanBean import io.nekohasekai.sagernet.ktx.* import moe.matsuri.nb4a.SingBoxOptions.* import moe.matsuri.nb4a.utils.NGUtil import moe.matsuri.nb4a.utils.listByLineOrComma import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl import org.json.JSONObject data class VmessQRCode( var v: String = "", var ps: String = "", var add: String = "", var port: String = "", var id: String = "", var aid: String = "0", var scy: String = "", var net: String = "", var type: String = "", var host: String = "", var path: String = "", var tls: String = "", var sni: String = "", var alpn: String = "", var fp: String = "", ) fun StandardV2RayBean.isTLS(): Boolean { return security == "tls" } fun StandardV2RayBean.setTLS(boolean: Boolean) { security = if (boolean) "tls" else "" } fun parseV2Ray(link: String): StandardV2RayBean { // Try parse stupid formats first if (!link.contains("?")) { try { return parseV2RayN(link) } catch (e: Exception) { Logs.i("try v2rayN: " + e.readableMessage) } } try { return tryResolveVmess4Kitsunebi(link) } catch (e: Exception) { Logs.i("try Kitsunebi: " + e.readableMessage) } // "std" format val bean = VMessBean().apply { if (link.startsWith("vless://")) alterId = -1 } val url = link.replace("vmess://", "https://").replace("vless://", "https://").toHttpUrl() if (url.password.isNotBlank()) { // https://github.com/v2fly/v2fly-github-io/issues/26 (rarely use) bean.serverAddress = url.host bean.serverPort = url.port bean.name = url.fragment var protocol = url.username bean.type = protocol bean.alterId = url.password.substringAfterLast('-').toInt() bean.uuid = url.password.substringBeforeLast('-') if (protocol.endsWith("+tls")) { bean.security = "tls" protocol = protocol.substring(0, protocol.length - 4) url.queryParameter("tlsServerName")?.let { if (it.isNotBlank()) { bean.sni = it } } } when (protocol) { // "tcp" -> { // url.queryParameter("type")?.let { type -> // if (type == "http") { // bean.headerType = "http" // url.queryParameter("host")?.let { // bean.host = it // } // } // } // } "http" -> { url.queryParameter("path")?.let { bean.path = it } url.queryParameter("host")?.let { bean.host = it.split("|").joinToString(",") } } "ws" -> { url.queryParameter("path")?.let { bean.path = it } url.queryParameter("host")?.let { bean.host = it } } "grpc" -> { url.queryParameter("serviceName")?.let { bean.path = it } } "httpupgrade" -> { url.queryParameter("path")?.let { bean.path = it } url.queryParameter("host")?.let { bean.host = it } } } } else { // also vless format bean.parseDuckSoft(url) } return bean } // https://github.com/XTLS/Xray-core/issues/91 fun StandardV2RayBean.parseDuckSoft(url: HttpUrl) { serverAddress = url.host serverPort = url.port name = url.fragment if (this is TrojanBean) { password = url.username } else { uuid = url.username } // not ducksoft fmt path if (url.pathSegments.size > 1 || url.pathSegments[0].isNotBlank()) { path = url.pathSegments.joinToString("/") } type = url.queryParameter("type") ?: "tcp" if (type == "h2" || url.queryParameter("headerType") == "http") type = "http" security = url.queryParameter("security") if (security.isNullOrBlank()) { security = if (this is TrojanBean) "tls" else "none" } when (security) { "tls", "reality" -> { security = "tls" url.queryParameter("allowInsecure")?.let { allowInsecure = it == "1" || it == "true" } url.queryParameter("sni")?.let { sni = it } url.queryParameter("host")?.let { if (sni.isNullOrBlank()) sni = it } url.queryParameter("alpn")?.let { alpn = it } url.queryParameter("cert")?.let { certificates = it } url.queryParameter("pbk")?.let { realityPubKey = it } url.queryParameter("sid")?.let { realityShortId = it } } } when (type) { "http" -> { url.queryParameter("host")?.let { host = it } url.queryParameter("path")?.let { path = it } } "ws" -> { url.queryParameter("host")?.let { host = it } url.queryParameter("path")?.let { path = it } url.queryParameter("ed")?.let { ed -> wsMaxEarlyData = ed.toInt() url.queryParameter("eh")?.let { earlyDataHeaderName = it } } } "grpc" -> { url.queryParameter("serviceName")?.let { path = it } } "httpupgrade" -> { url.queryParameter("host")?.let { host = it } url.queryParameter("path")?.let { path = it } } } // maybe from matsuri vmess exoprt if (this is VMessBean && !isVLESS) { url.queryParameter("encryption")?.let { encryption = it } } url.queryParameter("packetEncoding")?.let { when (it) { "packet" -> packetEncoding = 1 "xudp" -> packetEncoding = 2 } } url.queryParameter("flow")?.let { if (isVLESS) { encryption = it.removeSuffix("-udp443") } } url.queryParameter("fp")?.let { utlsFingerprint = it } } // 不确定是谁的格式 private fun tryResolveVmess4Kitsunebi(server: String): VMessBean { // vmess://YXV0bzo1YWY1ZDBlYy02ZWEwLTNjNDMtOTNkYi1jYTMwMDg1MDNiZGJAMTgzLjIzMi41Ni4xNjE6MTIwMg // ?remarks=*%F0%9F%87%AF%F0%9F%87%B5JP%20-355%20TG@moon365free&obfsParam=%7B%22Host%22:%22183.232.56.161%22%7D&path=/v2ray&obfs=websocket&alterId=0 var result = server.replace("vmess://", "") val indexSplit = result.indexOf("?") if (indexSplit > 0) { result = result.substring(0, indexSplit) } result = NGUtil.decode(result) val arr1 = result.split('@') if (arr1.count() != 2) { throw IllegalStateException("invalid kitsunebi format") } val arr21 = arr1[0].split(':') val arr22 = arr1[1].split(':') if (arr21.count() != 2) { throw IllegalStateException("invalid kitsunebi format") } return VMessBean().apply { serverAddress = arr22[0] serverPort = NGUtil.parseInt(arr22[1]) uuid = arr21[1] encryption = arr21[0] if (indexSplit < 0) return@apply val url = ("https://localhost/path?" + server.substringAfter("?")).toHttpUrl() url.queryParameter("remarks")?.apply { name = this } url.queryParameter("alterId")?.apply { alterId = this.toInt() } url.queryParameter("path")?.apply { path = this } url.queryParameter("tls")?.apply { security = "tls" } url.queryParameter("allowInsecure") ?.apply { if (this == "1" || this == "true") allowInsecure = true } url.queryParameter("obfs")?.apply { type = this.replace("websocket", "ws").replace("none", "tcp") if (type == "ws") { url.queryParameter("obfsParam")?.apply { if (this.startsWith("{")) { host = JSONObject(this).getStr("Host") } else if (security == "tls") { sni = this } } } } } } // SagerNet's // Do not support some format and then throw exception fun parseV2RayN(link: String): VMessBean { val result = link.substringAfter("vmess://").decodeBase64UrlSafe() if (result.contains("= vmess")) { return parseCsvVMess(result) } val bean = VMessBean() val vmessQRCode = Gson().fromJson(result, VmessQRCode::class.java) // Although VmessQRCode fields are non null, looks like Gson may still create null fields if (TextUtils.isEmpty(vmessQRCode.add) || TextUtils.isEmpty(vmessQRCode.port) || TextUtils.isEmpty(vmessQRCode.id) || TextUtils.isEmpty(vmessQRCode.net) ) { throw Exception("invalid VmessQRCode") } bean.name = vmessQRCode.ps bean.serverAddress = vmessQRCode.add bean.serverPort = vmessQRCode.port.toIntOrNull() bean.encryption = vmessQRCode.scy bean.uuid = vmessQRCode.id bean.alterId = vmessQRCode.aid.toIntOrNull() bean.type = vmessQRCode.net bean.host = vmessQRCode.host bean.path = vmessQRCode.path val headerType = vmessQRCode.type when (bean.type) { "tcp" -> { if (headerType == "http") { bean.type = "http" } } } when (vmessQRCode.tls) { "tls", "reality" -> { bean.security = "tls" bean.sni = vmessQRCode.sni if (bean.sni.isNullOrBlank()) bean.sni = bean.host bean.alpn = vmessQRCode.alpn bean.utlsFingerprint = vmessQRCode.fp } } return bean } private fun parseCsvVMess(csv: String): VMessBean { val args = csv.split(",") val bean = VMessBean() bean.serverAddress = args[1] bean.serverPort = args[2].toInt() bean.encryption = args[3] bean.uuid = args[4].replace("\"", "") args.subList(5, args.size).forEach { when { it == "over-tls=true" -> bean.security = "tls" it.startsWith("tls-host=") -> bean.host = it.substringAfter("=") it.startsWith("obfs=") -> bean.type = it.substringAfter("=") it.startsWith("obfs-path=") || it.contains("Host:") -> { runCatching { bean.path = it.substringAfter("obfs-path=\"").substringBefore("\"obfs") } runCatching { bean.host = it.substringAfter("Host:").substringBefore("[") } } } } return bean } fun VMessBean.toV2rayN(): String { val bean = this return "vmess://" + VmessQRCode().apply { v = "2" ps = bean.name add = bean.serverAddress port = bean.serverPort.toString() id = bean.uuid aid = bean.alterId.toString() net = bean.type host = bean.host path = bean.path when (net) { "http" -> { if (!isTLS()) { type = "http" net = "tcp" } } } if (isTLS()) { tls = "tls" if (bean.realityPubKey.isNotBlank()) { tls = "reality" } } scy = bean.encryption sni = bean.sni alpn = bean.alpn.replace("\n", ",") fp = bean.utlsFingerprint }.let { NGUtil.encode(Gson().toJson(it)) } } fun StandardV2RayBean.toUriVMessVLESSTrojan(isTrojan: Boolean): String { // VMess if (this is VMessBean && !isVLESS) { return toV2rayN() } // VLESS & Trojan (ducksoft fmt) val builder = linkBuilder() .username(if (this is TrojanBean) password else uuid) .host(serverAddress) .port(serverPort) .addQueryParameter("type", type) if (isVLESS) { builder.addQueryParameter("encryption", "none") if (encryption != "auto") builder.addQueryParameter("flow", encryption) } when (type) { "tcp" -> {} "ws", "http", "httpupgrade" -> { if (host.isNotBlank()) { builder.addQueryParameter("host", host) } if (path.isNotBlank()) { builder.addQueryParameter("path", path) } if (type == "ws") { if (wsMaxEarlyData > 0) { builder.addQueryParameter("ed", "$wsMaxEarlyData") if (earlyDataHeaderName.isNotBlank()) { builder.addQueryParameter("eh", earlyDataHeaderName) } } } else if (type == "http" && !isTLS()) { builder.setQueryParameter("type", "tcp") builder.addQueryParameter("headerType", "http") } } "grpc" -> { if (path.isNotBlank()) { builder.setQueryParameter("serviceName", path) } } } if (security.isNotBlank() && security != "none") { builder.addQueryParameter("security", security) when (security) { "tls" -> { if (sni.isNotBlank()) { builder.addQueryParameter("sni", sni) } if (alpn.isNotBlank()) { builder.addQueryParameter("alpn", alpn.replace("\n", ",")) } if (certificates.isNotBlank()) { builder.addQueryParameter("cert", certificates) } if (allowInsecure) { builder.addQueryParameter("allowInsecure", "1") } if (utlsFingerprint.isNotBlank()) { builder.addQueryParameter("fp", utlsFingerprint) } if (realityPubKey.isNotBlank()) { builder.setQueryParameter("security", "reality") builder.addQueryParameter("pbk", realityPubKey) builder.addQueryParameter("sid", realityShortId) } } } } when (packetEncoding) { 1 -> { builder.addQueryParameter("packetEncoding", "packetaddr") } 2 -> { builder.addQueryParameter("packetEncoding", "xudp") } } if (name.isNotBlank()) { builder.encodedFragment(name.urlSafe()) } return builder.toLink(if (isTrojan) "trojan" else "vless") } fun buildSingBoxOutboundStreamSettings(bean: StandardV2RayBean): V2RayTransportOptions? { when (bean.type) { "tcp" -> { return null } "ws" -> { return V2RayTransportOptions_WebsocketOptions().apply { type = "ws" headers = mutableMapOf() if (bean.host.isNotBlank()) { headers["Host"] = bean.host } if (bean.path.contains("?ed=")) { path = bean.path.substringBefore("?ed=") max_early_data = bean.path.substringAfter("?ed=").toIntOrNull() ?: 2048 early_data_header_name = "Sec-WebSocket-Protocol" } else { path = bean.path.takeIf { it.isNotBlank() } ?: "/" } if (bean.wsMaxEarlyData > 0) { max_early_data = bean.wsMaxEarlyData } if (bean.earlyDataHeaderName.isNotBlank()) { early_data_header_name = bean.earlyDataHeaderName } } } "http" -> { return V2RayTransportOptions_HTTPOptions().apply { type = "http" if (!bean.isTLS()) method = "GET" // v2ray tcp header if (bean.host.isNotBlank()) { host = bean.host.split(",") } path = bean.path.takeIf { it.isNotBlank() } ?: "/" } } "quic" -> { return V2RayTransportOptions().apply { type = "quic" } } "grpc" -> { return V2RayTransportOptions_GRPCOptions().apply { type = "grpc" service_name = bean.path } } "httpupgrade" -> { return V2RayTransportOptions_HTTPUpgradeOptions().apply { type = "httpupgrade" host = bean.host path = bean.path } } } return null } fun buildSingBoxOutboundTLS(bean: StandardV2RayBean): OutboundTLSOptions? { if (bean.security != "tls") return null return OutboundTLSOptions().apply { enabled = true insecure = bean.allowInsecure || DataStore.globalAllowInsecure if (bean.sni.isNotBlank()) server_name = bean.sni if (bean.alpn.isNotBlank()) alpn = bean.alpn.listByLineOrComma() if (bean.certificates.isNotBlank()) certificate = bean.certificates var fp = bean.utlsFingerprint if (bean.realityPubKey.isNotBlank()) { reality = OutboundRealityOptions().apply { enabled = true public_key = bean.realityPubKey short_id = bean.realityShortId } if (fp.isNullOrBlank()) fp = "chrome" } if (fp.isNotBlank()) { utls = OutboundUTLSOptions().apply { enabled = true fingerprint = fp } } if (bean.enableECH) { ech = OutboundECHOptions().apply { enabled = true if (bean.echConfig.isNotBlank()) { config = bean.echConfig.lines() } } } } } fun buildSingBoxOutboundStandardV2RayBean(bean: StandardV2RayBean): Outbound { when (bean) { is HttpBean -> { return Outbound_HTTPOptions().apply { type = "http" server = bean.serverAddress server_port = bean.serverPort username = bean.username password = bean.password tls = buildSingBoxOutboundTLS(bean) } } is VMessBean -> { if (bean.isVLESS) return Outbound_VLESSOptions().apply { type = "vless" server = bean.serverAddress server_port = bean.serverPort uuid = bean.uuid if (bean.encryption.isNotBlank() && bean.encryption != "auto") { flow = bean.encryption } when (bean.packetEncoding) { 0 -> packet_encoding = "" 1 -> packet_encoding = "packetaddr" 2 -> packet_encoding = "xudp" } tls = buildSingBoxOutboundTLS(bean) transport = buildSingBoxOutboundStreamSettings(bean) } return Outbound_VMessOptions().apply { type = "vmess" server = bean.serverAddress server_port = bean.serverPort uuid = bean.uuid alter_id = bean.alterId security = bean.encryption.takeIf { it.isNotBlank() } ?: "auto" when (bean.packetEncoding) { 0 -> packet_encoding = "" 1 -> packet_encoding = "packetaddr" 2 -> packet_encoding = "xudp" } tls = buildSingBoxOutboundTLS(bean) transport = buildSingBoxOutboundStreamSettings(bean) } } is TrojanBean -> { return Outbound_TrojanOptions().apply { type = "trojan" server = bean.serverAddress server_port = bean.serverPort password = bean.password tls = buildSingBoxOutboundTLS(bean) transport = buildSingBoxOutboundStreamSettings(bean) } } else -> throw IllegalStateException("can't reach") } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/fmt/v2ray/VMessBean.java ================================================ package io.nekohasekai.sagernet.fmt.v2ray; import androidx.annotation.NonNull; import org.jetbrains.annotations.NotNull; import io.nekohasekai.sagernet.fmt.KryoConverters; import moe.matsuri.nb4a.utils.JavaUtil; public class VMessBean extends StandardV2RayBean { public Integer alterId; // alterID == -1 --> VLESS @Override public void initializeDefaultValues() { super.initializeDefaultValues(); alterId = alterId != null ? alterId : 0; if (alterId == -1) { encryption = JavaUtil.isNotBlank(encryption) ? encryption : ""; } else { encryption = JavaUtil.isNotBlank(encryption) ? encryption : "auto"; } } @NotNull @Override public VMessBean clone() { return KryoConverters.deserialize(new VMessBean(), KryoConverters.serialize(this)); } public static final Creator CREATOR = new CREATOR() { @NonNull @Override public VMessBean newInstance() { return new VMessBean(); } @Override public VMessBean[] newArray(int size) { return new VMessBean[size]; } }; } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/fmt/wireguard/WireGuardBean.java ================================================ package io.nekohasekai.sagernet.fmt.wireguard; import androidx.annotation.NonNull; import com.esotericsoftware.kryo.io.ByteBufferInput; import com.esotericsoftware.kryo.io.ByteBufferOutput; import org.jetbrains.annotations.NotNull; import io.nekohasekai.sagernet.fmt.AbstractBean; import io.nekohasekai.sagernet.fmt.KryoConverters; public class WireGuardBean extends AbstractBean { public String localAddress; public String privateKey; public String peerPublicKey; public String peerPreSharedKey; public Integer mtu; public String reserved; @Override public void initializeDefaultValues() { super.initializeDefaultValues(); if (localAddress == null) localAddress = ""; if (privateKey == null) privateKey = ""; if (peerPublicKey == null) peerPublicKey = ""; if (peerPreSharedKey == null) peerPreSharedKey = ""; if (mtu == null) mtu = 1420; if (reserved == null) reserved = ""; } @Override public void serialize(ByteBufferOutput output) { output.writeInt(2); super.serialize(output); output.writeString(localAddress); output.writeString(privateKey); output.writeString(peerPublicKey); output.writeString(peerPreSharedKey); output.writeInt(mtu); output.writeString(reserved); } @Override public void deserialize(ByteBufferInput input) { int version = input.readInt(); super.deserialize(input); localAddress = input.readString(); privateKey = input.readString(); peerPublicKey = input.readString(); peerPreSharedKey = input.readString(); mtu = input.readInt(); reserved = input.readString(); } @Override public boolean canTCPing() { return false; } @NotNull @Override public WireGuardBean clone() { return KryoConverters.deserialize(new WireGuardBean(), KryoConverters.serialize(this)); } public static final Creator CREATOR = new CREATOR() { @NonNull @Override public WireGuardBean newInstance() { return new WireGuardBean(); } @Override public WireGuardBean[] newArray(int size) { return new WireGuardBean[size]; } }; } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/fmt/wireguard/WireGuardFmt.kt ================================================ package io.nekohasekai.sagernet.fmt.wireguard import moe.matsuri.nb4a.SingBoxOptions import moe.matsuri.nb4a.utils.Util import moe.matsuri.nb4a.utils.listByLineOrComma fun genReserved(anyStr: String): String { try { val list = anyStr.listByLineOrComma() val ba = ByteArray(3) if (list.size == 3) { list.forEachIndexed { index, s -> val i = s .replace("[", "") .replace("]", "") .replace(" ", "") .toIntOrNull() ?: return anyStr ba[index] = i.toByte() } return Util.b64EncodeOneLine(ba) } else { return anyStr } } catch (e: Exception) { return anyStr } } fun buildSingBoxOutboundWireguardBean(bean: WireGuardBean): SingBoxOptions.Outbound_WireGuardOptions { return SingBoxOptions.Outbound_WireGuardOptions().apply { type = "wireguard" server = bean.serverAddress server_port = bean.serverPort local_address = bean.localAddress.listByLineOrComma() private_key = bean.privateKey peer_public_key = bean.peerPublicKey pre_shared_key = bean.peerPreSharedKey mtu = bean.mtu if (bean.reserved.isNotBlank()) reserved = genReserved(bean.reserved) } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/group/GroupInterfaceAdapter.kt ================================================ package io.nekohasekai.sagernet.group import com.google.android.material.dialog.MaterialAlertDialogBuilder import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.database.GroupManager import io.nekohasekai.sagernet.database.ProxyGroup import io.nekohasekai.sagernet.ktx.onMainDispatcher import io.nekohasekai.sagernet.ktx.runOnMainDispatcher import io.nekohasekai.sagernet.ui.ThemedActivity import kotlinx.coroutines.delay import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine class GroupInterfaceAdapter(val context: ThemedActivity) : GroupManager.Interface { override suspend fun confirm(message: String): Boolean { return suspendCoroutine { runOnMainDispatcher { MaterialAlertDialogBuilder(context).setTitle(R.string.confirm) .setMessage(message) .setPositiveButton(R.string.yes) { _, _ -> it.resume(true) } .setNegativeButton(R.string.no) { _, _ -> it.resume(false) } .setOnCancelListener { _ -> it.resume(false) } .show() } } } override suspend fun onUpdateSuccess( group: ProxyGroup, changed: Int, added: List, updated: Map, deleted: List, duplicate: List, byUser: Boolean ) { if (changed == 0 && duplicate.isEmpty()) { if (byUser) context.snackbar( context.getString( R.string.group_no_difference, group.displayName() ) ).show() } else { context.snackbar(context.getString(R.string.group_updated, group.name, changed)).show() var status = "" if (added.isNotEmpty()) { status += context.getString( R.string.group_added, added.joinToString("\n", postfix = "\n\n") ) } if (updated.isNotEmpty()) { status += context.getString(R.string.group_changed, updated.map { it }.joinToString("\n", postfix = "\n\n") { if (it.key == it.value) it.key else "${it.key} => ${it.value}" }) } if (deleted.isNotEmpty()) { status += context.getString( R.string.group_deleted, deleted.joinToString("\n", postfix = "\n\n") ) } if (duplicate.isNotEmpty()) { status += context.getString( R.string.group_duplicate, duplicate.joinToString("\n", postfix = "\n\n") ) } onMainDispatcher { delay(1000L) MaterialAlertDialogBuilder(context).setTitle( context.getString( R.string.group_diff, group.displayName() ) ).setMessage(status.trim()).setPositiveButton(android.R.string.ok, null).show() } } } override suspend fun onUpdateFailure(group: ProxyGroup, message: String) { onMainDispatcher { context.snackbar(message).show() } } override suspend fun alert(message: String) { return suspendCoroutine { runOnMainDispatcher { MaterialAlertDialogBuilder(context).setTitle(R.string.ooc_warning) .setMessage(message) .setPositiveButton(android.R.string.ok) { _, _ -> it.resume(Unit) } .setOnCancelListener { _ -> it.resume(Unit) } .show() } } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/group/GroupUpdater.kt ================================================ package io.nekohasekai.sagernet.group import io.nekohasekai.sagernet.* import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.database.GroupManager import io.nekohasekai.sagernet.database.ProxyGroup import io.nekohasekai.sagernet.database.SubscriptionBean import io.nekohasekai.sagernet.fmt.AbstractBean import io.nekohasekai.sagernet.fmt.http.HttpBean import io.nekohasekai.sagernet.fmt.hysteria.HysteriaBean import io.nekohasekai.sagernet.fmt.naive.NaiveBean import io.nekohasekai.sagernet.fmt.trojan.TrojanBean import io.nekohasekai.sagernet.fmt.trojan_go.TrojanGoBean import io.nekohasekai.sagernet.fmt.v2ray.StandardV2RayBean import io.nekohasekai.sagernet.fmt.v2ray.isTLS import io.nekohasekai.sagernet.ktx.* import kotlinx.coroutines.* import java.net.Inet4Address import java.net.InetAddress import java.util.* import java.util.concurrent.atomic.AtomicInteger @Suppress("EXPERIMENTAL_API_USAGE") abstract class GroupUpdater { abstract suspend fun doUpdate( proxyGroup: ProxyGroup, subscription: SubscriptionBean, userInterface: GroupManager.Interface?, byUser: Boolean ) data class Progress( var max: Int ) { var progress by AtomicInteger() } protected suspend fun forceResolve( profiles: List, groupId: Long? ) { val ipv6Mode = DataStore.ipv6Mode val lookupPool = newFixedThreadPoolContext(5, "DNS Lookup") val lookupJobs = mutableListOf() val progress = Progress(profiles.size) if (groupId != null) { GroupUpdater.progress[groupId] = progress GroupManager.postReload(groupId) } val ipv6First = ipv6Mode >= IPv6Mode.PREFER for (profile in profiles) { when (profile) { // SNI rewrite unsupported is NaiveBean -> continue } if (profile.serverAddress.isIpAddress()) continue lookupJobs.add(GlobalScope.launch(lookupPool) { try { val results = if ( SagerNet.underlyingNetwork != null && DataStore.enableFakeDns && DataStore.serviceState.started && DataStore.serviceMode == Key.MODE_VPN ) { // FakeDNS SagerNet.underlyingNetwork!! .getAllByName(profile.serverAddress) .filterNotNull() } else { // System DNS is enough (when VPN connected, it uses v2ray-core) InetAddress.getAllByName(profile.serverAddress).filterNotNull() } if (results.isEmpty()) error("empty response") rewriteAddress(profile, results, ipv6First) } catch (e: Exception) { Logs.d("Lookup ${profile.serverAddress} failed: ${e.readableMessage}", e) } if (groupId != null) { progress.progress++ GroupManager.postReload(groupId) } }) } lookupJobs.joinAll() lookupPool.close() } protected fun rewriteAddress( bean: AbstractBean, addresses: List, ipv6First: Boolean ) { val address = addresses.sortedBy { (it is Inet4Address) xor ipv6First }[0].hostAddress with(bean) { when (this) { is HttpBean -> { if (isTLS() && sni.isBlank()) sni = bean.serverAddress } is StandardV2RayBean -> { when (security) { "tls" -> if (sni.isBlank()) sni = bean.serverAddress } } is TrojanBean -> { if (sni.isBlank()) sni = bean.serverAddress } is TrojanGoBean -> { if (sni.isBlank()) sni = bean.serverAddress } is HysteriaBean -> { if (sni.isBlank()) sni = bean.serverAddress } } bean.serverAddress = address } } companion object { val updating = Collections.synchronizedSet(mutableSetOf()) val progress = Collections.synchronizedMap(mutableMapOf()) fun startUpdate(proxyGroup: ProxyGroup, byUser: Boolean) { runOnDefaultDispatcher { executeUpdate(proxyGroup, byUser) } } suspend fun executeUpdate(proxyGroup: ProxyGroup, byUser: Boolean): Boolean { return coroutineScope { if (!updating.add(proxyGroup.id)) cancel() GroupManager.postReload(proxyGroup.id) val subscription = proxyGroup.subscription!! val connected = DataStore.serviceState.connected val userInterface = GroupManager.userInterface if (byUser && (subscription.link?.startsWith("http://") == true || subscription.updateWhenConnectedOnly) && !connected) { if (userInterface == null || !userInterface.confirm(app.getString(R.string.update_subscription_warning))) { finishUpdate(proxyGroup) cancel() return@coroutineScope true } } try { RawUpdater.doUpdate(proxyGroup, subscription, userInterface, byUser) true } catch (e: Throwable) { Logs.w(e) userInterface?.onUpdateFailure(proxyGroup, e.readableMessage) finishUpdate(proxyGroup) false } } } suspend fun finishUpdate(proxyGroup: ProxyGroup) { updating.remove(proxyGroup.id) progress.remove(proxyGroup.id) GroupManager.postUpdate(proxyGroup) } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/group/RawUpdater.kt ================================================ package io.nekohasekai.sagernet.group import android.annotation.SuppressLint import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.database.* import io.nekohasekai.sagernet.fmt.AbstractBean import io.nekohasekai.sagernet.fmt.http.HttpBean import io.nekohasekai.sagernet.fmt.hysteria.HysteriaBean import io.nekohasekai.sagernet.fmt.hysteria.parseHysteria1Json import io.nekohasekai.sagernet.fmt.shadowsocks.ShadowsocksBean import io.nekohasekai.sagernet.fmt.shadowsocks.parseShadowsocks import io.nekohasekai.sagernet.fmt.socks.SOCKSBean import io.nekohasekai.sagernet.fmt.trojan.TrojanBean import io.nekohasekai.sagernet.fmt.trojan_go.parseTrojanGo import io.nekohasekai.sagernet.fmt.tuic.TuicBean import io.nekohasekai.sagernet.fmt.v2ray.StandardV2RayBean import io.nekohasekai.sagernet.fmt.v2ray.VMessBean import io.nekohasekai.sagernet.fmt.v2ray.isTLS import io.nekohasekai.sagernet.fmt.v2ray.setTLS import io.nekohasekai.sagernet.fmt.wireguard.WireGuardBean import io.nekohasekai.sagernet.ktx.* import libcore.Libcore import moe.matsuri.nb4a.Protocols import moe.matsuri.nb4a.proxy.anytls.AnyTLSBean import moe.matsuri.nb4a.proxy.config.ConfigBean import moe.matsuri.nb4a.utils.Util import org.ini4j.Ini import org.json.JSONArray import org.json.JSONObject import org.json.JSONTokener import org.yaml.snakeyaml.TypeDescription import org.yaml.snakeyaml.Yaml import org.yaml.snakeyaml.error.YAMLException import java.io.StringReader import androidx.core.net.toUri @Suppress("EXPERIMENTAL_API_USAGE") object RawUpdater : GroupUpdater() { @SuppressLint("Recycle") override suspend fun doUpdate( proxyGroup: ProxyGroup, subscription: SubscriptionBean, userInterface: GroupManager.Interface?, byUser: Boolean ) { val link = subscription.link var proxies: List if (link.startsWith("content://")) { val contentText = app.contentResolver.openInputStream(link.toUri()) ?.bufferedReader() ?.readText() proxies = contentText?.let { parseRaw(contentText) } ?: error(app.getString(R.string.no_proxies_found_in_subscription)) } else { val response = Libcore.newHttpClient().apply { trySocks5(DataStore.mixedPort) tryH3Direct() when (DataStore.appTLSVersion) { "1.3" -> restrictedTLS() } }.newRequest().apply { if (DataStore.allowInsecureOnRequest) { allowInsecure() } setURL(subscription.link) setUserAgent(subscription.customUserAgent.takeIf { it.isNotBlank() } ?: USER_AGENT) }.execute() proxies = parseRaw(Util.getStringBox(response.contentString)) ?: error(app.getString(R.string.no_proxies_found)) subscription.subscriptionUserinfo = Util.getStringBox(response.getHeader("Subscription-Userinfo")) // 修改默认名字 if (proxyGroup.name?.startsWith("Subscription #") == true) { var remoteName = Util.getStringBox(response.getHeader("content-disposition")) if (remoteName.isNotBlank()) { remoteName = Util.decodeFilename(remoteName) if (remoteName.isNotBlank()) { proxyGroup.name = remoteName } } } } val proxiesMap = LinkedHashMap() for (proxy in proxies) { var index = 0 var name = proxy.displayName() while (proxiesMap.containsKey(name)) { println("Exists name: $name") index++ name = name.replace(" (${index - 1})", "") name = "$name ($index)" proxy.name = name } proxiesMap[proxy.displayName()] = proxy } proxies = proxiesMap.values.toList() if (subscription.forceResolve) forceResolve(proxies, proxyGroup.id) val exists = SagerDatabase.proxyDao.getByGroup(proxyGroup.id) val duplicate = ArrayList() if (subscription.deduplication) { Logs.d("Before deduplication: ${proxies.size}") val uniqueProxies = LinkedHashSet() val uniqueNames = HashMap() for (_proxy in proxies) { val proxy = Protocols.Deduplication(_proxy, _proxy.javaClass.toString()) if (!uniqueProxies.add(proxy)) { val index = uniqueProxies.indexOf(proxy) if (uniqueNames.containsKey(proxy)) { val name = uniqueNames[proxy]!!.replace(" ($index)", "") if (name.isNotBlank()) { duplicate.add("$name ($index)") uniqueNames[proxy] = "" } } duplicate.add(_proxy.displayName() + " ($index)") } else { uniqueNames[proxy] = _proxy.displayName() } } uniqueProxies.retainAll(uniqueNames.keys) proxies = uniqueProxies.toList().map { it.bean } } Logs.d("New profiles: ${proxies.size}") val nameMap = proxies.associateBy { bean -> bean.displayName() } Logs.d("Unique profiles: ${nameMap.size}") val toDelete = ArrayList() val toReplace = exists.mapNotNull { entity -> val name = entity.displayName() if (nameMap.contains(name)) name to entity else let { toDelete.add(entity) null } }.toMap() Logs.d("toDelete profiles: ${toDelete.size}") Logs.d("toReplace profiles: ${toReplace.size}") val toUpdate = ArrayList() val added = mutableListOf() val updated = mutableMapOf() val deleted = toDelete.map { it.displayName() } var userOrder = 1L var changed = toDelete.size for ((name, bean) in nameMap.entries) { if (toReplace.contains(name)) { val entity = toReplace[name]!! val existsBean = entity.requireBean() // 更新订阅,保留自定义覆写设置 bean.customOutboundJson = existsBean.customOutboundJson bean.customConfigJson = existsBean.customConfigJson when { existsBean != bean -> { changed++ entity.putBean(bean) toUpdate.add(entity) updated[entity.displayName()] = name Logs.d("Updated profile: $name") } entity.userOrder != userOrder -> { entity.putBean(bean) toUpdate.add(entity) entity.userOrder = userOrder Logs.d("Reordered profile: $name") } else -> { Logs.d("Ignored profile: $name") } } } else { changed++ SagerDatabase.proxyDao.addProxy( ProxyEntity( groupId = proxyGroup.id, userOrder = userOrder ).apply { putBean(bean) }) added.add(name) Logs.d("Inserted profile: $name") } userOrder++ } SagerDatabase.proxyDao.updateProxy(toUpdate).also { Logs.d("Updated profiles: $it") } SagerDatabase.proxyDao.deleteProxy(toDelete).also { Logs.d("Deleted profiles: $it") } val existCount = SagerDatabase.proxyDao.countByGroup(proxyGroup.id).toInt() if (existCount != proxies.size) { Logs.e("Exist profiles: $existCount, new profiles: ${proxies.size}") } subscription.lastUpdated = (System.currentTimeMillis() / 1000).toInt() SagerDatabase.groupDao.updateGroup(proxyGroup) finishUpdate(proxyGroup) userInterface?.onUpdateSuccess( proxyGroup, changed, added, updated, deleted, duplicate, byUser ) } @Suppress("UNCHECKED_CAST") suspend fun parseRaw(text: String, fileName: String = ""): List? { val proxies = mutableListOf() if (text.contains("proxies:")) { // clash & meta try { val yaml = Yaml().apply { addTypeDescription(TypeDescription(String::class.java, "str")) }.loadAs(text, Map::class.java) val globalClientFingerprint = yaml["global-client-fingerprint"]?.toString() ?: "" for (proxy in (yaml["proxies"] as? (List>) ?: error( app.getString(R.string.no_proxies_found_in_file) ))) { // Note: YAML numbers parsed as "Long" when (proxy["type"] as String) { "socks5" -> { proxies.add(SOCKSBean().apply { serverAddress = proxy["server"] as String serverPort = proxy["port"].toString().toInt() username = proxy["username"]?.toString() password = proxy["password"]?.toString() name = proxy["name"]?.toString() }) } "http" -> { proxies.add(HttpBean().apply { serverAddress = proxy["server"] as String serverPort = proxy["port"].toString().toInt() username = proxy["username"]?.toString() password = proxy["password"]?.toString() setTLS(proxy["tls"]?.toString() == "true") sni = proxy["sni"]?.toString() name = proxy["name"]?.toString() allowInsecure = proxy["skip-cert-verify"]?.toString() == "true" }) } "ss" -> { val ssPlugin = mutableListOf() if (proxy.contains("plugin")) { val opts = proxy["plugin-opts"] as Map when (proxy["plugin"]) { "obfs" -> { ssPlugin.apply { add("obfs-local") add("obfs=" + (opts["mode"]?.toString() ?: "")) add("obfs-host=" + (opts["host"]?.toString() ?: "")) } } "v2ray-plugin" -> { ssPlugin.apply { add("v2ray-plugin") add("mode=" + (opts["mode"]?.toString() ?: "")) if (opts["mode"]?.toString() == "true") add("tls") add("host=" + (opts["host"]?.toString() ?: "")) add("path=" + (opts["path"]?.toString() ?: "")) if (opts["mux"]?.toString() == "true") add("mux=8") } } } } proxies.add(ShadowsocksBean().apply { serverAddress = proxy["server"] as String serverPort = proxy["port"].toString().toInt() password = proxy["password"]?.toString() method = clashCipher(proxy["cipher"] as String) plugin = ssPlugin.joinToString(";") name = proxy["name"]?.toString() }) } "vmess", "vless", "trojan" -> { val bean = when (proxy["type"] as String) { "vmess" -> VMessBean() "vless" -> VMessBean().apply { alterId = -1 // make it VLESS packetEncoding = 2 // clash meta default XUDP } "trojan" -> TrojanBean().apply { security = "tls" } else -> error("impossible") } bean.serverAddress = proxy["server"]?.toString() ?: continue bean.serverPort = proxy["port"]?.toString()?.toIntOrNull() ?: continue for (opt in proxy) { when (opt.key) { "name" -> bean.name = opt.value?.toString() "password" -> if (bean is TrojanBean) bean.password = opt.value?.toString() "uuid" -> if (bean is VMessBean) bean.uuid = opt.value?.toString() "alterId" -> if (bean is VMessBean && !bean.isVLESS) bean.alterId = opt.value?.toString()?.toIntOrNull() "cipher" -> if (bean is VMessBean && !bean.isVLESS) bean.encryption = (opt.value as? String) "flow" -> if (bean is VMessBean && bean.isVLESS) { (opt.value as? String)?.let { if (it.contains("xtls-rprx-vision")) { bean.encryption = "xtls-rprx-vision" } } } "packet-encoding" -> if (bean is VMessBean) { bean.packetEncoding = when ((opt.value as? String)) { "packetaddr" -> 1 "xudp" -> 2 else -> 0 } } "tls" -> if (bean is VMessBean) { bean.security = if (opt.value as? Boolean == true) "tls" else "" } "servername", "sni" -> bean.sni = opt.value?.toString() "alpn" -> bean.alpn = (opt.value as? List)?.joinToString("\n") "skip-cert-verify" -> bean.allowInsecure = opt.value as? Boolean == true "client-fingerprint" -> bean.utlsFingerprint = opt.value as String "reality-opts" -> (opt.value as? Map)?.also { for (realityOpt in it) { bean.security = "tls" when (realityOpt.key) { "public-key" -> bean.realityPubKey = realityOpt.value?.toString() "short-id" -> bean.realityShortId = realityOpt.value?.toString() } } } "network" -> { when (opt.value) { "h2", "http" -> bean.type = "http" "ws", "grpc" -> bean.type = opt.value as String } } "ws-opts" -> (opt.value as? Map)?.also { for (wsOpt in it) { when (wsOpt.key) { "headers" -> (wsOpt.value as? Map)?.forEach { (key, value) -> when (key.toString().lowercase()) { "host" -> { bean.host = value?.toString() } } } "path" -> { bean.path = wsOpt.value?.toString() } "max-early-data" -> { bean.wsMaxEarlyData = wsOpt.value?.toString()?.toIntOrNull() } "early-data-header-name" -> { bean.earlyDataHeaderName = wsOpt.value?.toString() } "v2ray-http-upgrade" -> { if (wsOpt.value as? Boolean == true) { bean.type = "httpupgrade" } } } } } "h2-opts" -> (opt.value as? Map)?.also { for (h2Opt in it) { when (h2Opt.key) { "host" -> bean.host = (h2Opt.value as? List)?.joinToString("\n") "path" -> bean.path = h2Opt.value?.toString() } } } "http-opts" -> (opt.value as? Map)?.also { for (httpOpt in it) { when (httpOpt.key) { "path" -> bean.path = (httpOpt.value as? List)?.joinToString("\n") "headers" -> { (httpOpt.value as? Map>)?.forEach { (key, value) -> when (key.toString().lowercase()) { "host" -> { bean.host = value.joinToString("\n") } } } } } } } "grpc-opts" -> (opt.value as? Map)?.also { for (grpcOpt in it) { when (grpcOpt.key) { "grpc-service-name" -> bean.path = grpcOpt.value?.toString() } } } "smux" -> (opt.value as? Map)?.also { for (smuxOpt in it) { when (smuxOpt.key) { "enabled" -> bean.enableMux = smuxOpt.value.toString() == "true" "max-streams" -> bean.muxConcurrency = smuxOpt.value.toString().toInt() "padding" -> bean.muxPadding = smuxOpt.value.toString() == "true" } } } "ech-opts" -> (opt.value as? Map)?.also { for (echOpt in it) { when (echOpt.key) { "enable" -> bean.enableECH = echOpt.value.toString() == "true" } } } } } proxies.add(bean) } "anytls" -> { val bean = AnyTLSBean() for (opt in proxy) { if (opt.value == null) continue when (opt.key.replace("_", "-")) { "name" -> bean.name = opt.value.toString() "server" -> bean.serverAddress = opt.value as String "port" -> bean.serverPort = opt.value.toString().toInt() "password" -> bean.password = opt.value.toString() "client-fingerprint" -> bean.utlsFingerprint = opt.value as String "sni" -> bean.sni = opt.value.toString() "skip-cert-verify" -> bean.allowInsecure = opt.value.toString() == "true" "alpn" -> { val alpn = (opt.value as? (List)) bean.alpn = alpn?.joinToString("\n") } } } proxies.add(bean) } "hysteria" -> { val bean = HysteriaBean() bean.protocolVersion = 1 var hopPorts = "" for (opt in proxy) { if (opt.value == null) continue when (opt.key.replace("_", "-")) { "name" -> bean.name = opt.value.toString() "server" -> bean.serverAddress = opt.value as String "port" -> bean.serverPorts = opt.value.toString() "ports" -> hopPorts = opt.value.toString() "obfs" -> bean.obfuscation = opt.value.toString() "auth-str" -> { bean.authPayloadType = HysteriaBean.TYPE_STRING bean.authPayload = opt.value.toString() } "sni" -> bean.sni = opt.value.toString() "skip-cert-verify" -> bean.allowInsecure = opt.value.toString() == "true" "up" -> bean.uploadMbps = opt.value.toString().substringBefore(" ").toIntOrNull() ?: 100 "down" -> bean.downloadMbps = opt.value.toString().substringBefore(" ").toIntOrNull() ?: 100 "recv-window-conn" -> bean.connectionReceiveWindow = opt.value.toString().toIntOrNull() ?: 0 "recv-window" -> bean.streamReceiveWindow = opt.value.toString().toIntOrNull() ?: 0 "disable-mtu-discovery" -> bean.disableMtuDiscovery = opt.value.toString() == "true" || opt.value.toString() == "1" "alpn" -> { val alpn = (opt.value as? (List)) bean.alpn = alpn?.joinToString("\n") ?: "h3" } } } if (hopPorts.isNotBlank()) { bean.serverPorts = hopPorts } proxies.add(bean) } "hysteria2" -> { val bean = HysteriaBean() bean.protocolVersion = 2 var hopPorts = "" for (opt in proxy) { if (opt.value == null) continue when (opt.key.replace("_", "-")) { "name" -> bean.name = opt.value.toString() "server" -> bean.serverAddress = opt.value as String "port" -> bean.serverPorts = opt.value.toString() "ports" -> hopPorts = opt.value.toString() "obfs-password" -> bean.obfuscation = opt.value.toString() "password" -> bean.authPayload = opt.value.toString() "sni" -> bean.sni = opt.value.toString() "skip-cert-verify" -> bean.allowInsecure = opt.value.toString() == "true" "up" -> bean.uploadMbps = opt.value.toString().substringBefore(" ").toIntOrNull() ?: 0 "down" -> bean.downloadMbps = opt.value.toString().substringBefore(" ").toIntOrNull() ?: 0 } } if (hopPorts.isNotBlank()) { bean.serverPorts = hopPorts } proxies.add(bean) } "tuic" -> { val bean = TuicBean() var ip = "" for (opt in proxy) { if (opt.value == null) continue when (opt.key.replace("_", "-")) { "name" -> bean.name = opt.value.toString() "server" -> bean.serverAddress = opt.value.toString() "ip" -> ip = opt.value.toString() "port" -> bean.serverPort = opt.value.toString().toInt() "token" -> { bean.protocolVersion = 4 bean.token = opt.value.toString() } "uuid" -> bean.uuid = opt.value.toString() "password" -> bean.token = opt.value.toString() "skip-cert-verify" -> bean.allowInsecure = opt.value.toString() == "true" "disable-sni" -> bean.disableSNI = opt.value.toString() == "true" "reduce-rtt" -> bean.reduceRTT = opt.value.toString() == "true" "sni" -> bean.sni = opt.value.toString() "alpn" -> { val alpn = (opt.value as? (List)) bean.alpn = alpn?.joinToString("\n") } "congestion-controller" -> bean.congestionController = opt.value.toString() "udp-relay-mode" -> bean.udpRelayMode = opt.value.toString() } } if (ip.isNotBlank()) { bean.serverAddress = ip if (bean.sni.isNullOrBlank() && !bean.serverAddress.isNullOrBlank() && !bean.serverAddress.isIpAddress()) { bean.sni = bean.serverAddress } } proxies.add(bean) } } } // Fix ent proxies.forEach { it.initializeDefaultValues() if (it is StandardV2RayBean) { // 1. SNI if (it.isTLS() && it.sni.isNullOrBlank() && !it.host.isNullOrBlank() && !it.host.isIpAddress()) { it.sni = it.host } // 2. globalClientFingerprint if (!it.realityPubKey.isNullOrBlank() && it.utlsFingerprint.isNullOrBlank()) { it.utlsFingerprint = globalClientFingerprint if (it.utlsFingerprint.isNullOrBlank()) it.utlsFingerprint = "chrome" } } } return proxies } catch (e: YAMLException) { Logs.w(e) } } else if (text.contains("[Interface]")) { // wireguard try { proxies.addAll(parseWireGuard(text).map { if (fileName.isNotBlank()) it.name = fileName.removeSuffix(".conf") it }) return proxies } catch (e: Exception) { Logs.w(e) } } try { val json = JSONTokener(text).nextValue() return parseJSON(json) } catch (ignored: Exception) { } try { return parseProxies(text.decodeBase64UrlSafe()).takeIf { it.isNotEmpty() } ?: error("Not found") } catch (e: Exception) { Logs.w(e) } try { return parseProxies(text).takeIf { it.isNotEmpty() } ?: error("Not found") } catch (e: SubscriptionFoundException) { throw e } catch (ignored: Exception) { } return null } fun clashCipher(cipher: String): String { return when (cipher) { "dummy" -> "none" else -> cipher } } fun parseWireGuard(conf: String): List { val ini = Ini(StringReader(conf)) val iface = ini["Interface"] ?: error("Missing 'Interface' selection") val bean = WireGuardBean().applyDefaultValues() val localAddresses = iface.getAll("Address") if (localAddresses.isNullOrEmpty()) error("Empty address in 'Interface' selection") bean.localAddress = localAddresses.flatMap { it.split(",") }.joinToString("\n") bean.privateKey = iface["PrivateKey"] bean.mtu = iface["MTU"]?.toIntOrNull() val peers = ini.getAll("Peer") if (peers.isNullOrEmpty()) error("Missing 'Peer' selections") val beans = mutableListOf() for (peer in peers) { val endpoint = peer["Endpoint"] if (endpoint.isNullOrBlank() || !endpoint.contains(":")) { continue } val peerBean = bean.clone() peerBean.serverAddress = endpoint.substringBeforeLast(":") peerBean.serverPort = endpoint.substringAfterLast(":").toIntOrNull() ?: continue peerBean.peerPublicKey = peer["PublicKey"] ?: continue peerBean.peerPreSharedKey = peer["PresharedKey"] beans.add(peerBean.applyDefaultValues()) } if (beans.isEmpty()) error("Empty available peer list") return beans } fun parseJSON(json: Any): List { val proxies = ArrayList() if (json is JSONObject) { when { json.has("server") && (json.has("up") || json.has("up_mbps")) -> { return listOf(json.parseHysteria1Json()) } json.has("method") -> { return listOf(json.parseShadowsocks()) } json.has("remote_addr") -> { return listOf(json.parseTrojanGo()) } json.has("outbounds") -> { return json.getJSONArray("outbounds") .filterIsInstance() .mapNotNull { val ty = it.getStr("type") if (ty == null || ty == "" || ty == "dns" || ty == "block" || ty == "direct" || ty == "selector" || ty == "urltest" ) { null } else { it } }.map { ConfigBean().apply { applyDefaultValues() type = 1 config = it.toStringPretty() name = it.getStr("tag") } } } json.has("server") && json.has("server_port") -> { return listOf(ConfigBean().applyDefaultValues().apply { type = 1 config = json.toStringPretty() }) } } } else { json as JSONArray json.forEach { _, it -> if (isJsonObjectValid(it)) { proxies.addAll(parseJSON(it)) } } } proxies.forEach { it.initializeDefaultValues() } return proxies } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ktx/Asyncs.kt ================================================ @file:Suppress("EXPERIMENTAL_API_USAGE") package io.nekohasekai.sagernet.ktx import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.* fun block(block: suspend CoroutineScope.() -> Unit): suspend CoroutineScope.() -> Unit { return block } fun runOnDefaultDispatcher(block: suspend CoroutineScope.() -> Unit) = GlobalScope.launch(Dispatchers.Default, block = block) fun Fragment.runOnLifecycleDispatcher(block: suspend CoroutineScope.() -> Unit) = lifecycleScope.launch(Dispatchers.Default, block = block) suspend fun onDefaultDispatcher(block: suspend CoroutineScope.() -> T) = withContext(Dispatchers.Default, block = block) fun runOnIoDispatcher(block: suspend CoroutineScope.() -> Unit) = GlobalScope.launch(Dispatchers.IO, block = block) suspend fun onIoDispatcher(block: suspend CoroutineScope.() -> T) = withContext(Dispatchers.IO, block = block) fun runOnMainDispatcher(block: suspend CoroutineScope.() -> Unit) = GlobalScope.launch(Dispatchers.Main.immediate, block = block) suspend fun onMainDispatcher(block: suspend CoroutineScope.() -> T) = withContext(Dispatchers.Main.immediate, block = block) fun runBlockingOnMainDispatcher(block: suspend CoroutineScope.() -> Unit) { runBlocking { GlobalScope.launch(Dispatchers.Main.immediate, block = block) } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ktx/Browsers.kt ================================================ package io.nekohasekai.sagernet.ktx import android.content.Context import android.net.Uri import androidx.browser.customtabs.CustomTabColorSchemeParams import androidx.browser.customtabs.CustomTabsIntent import io.nekohasekai.sagernet.R fun Context.launchCustomTab(link: String) { CustomTabsIntent.Builder().apply { setColorScheme(CustomTabsIntent.COLOR_SCHEME_SYSTEM) setColorSchemeParams( CustomTabsIntent.COLOR_SCHEME_LIGHT, CustomTabColorSchemeParams.Builder().apply { setToolbarColor(getColorAttr(R.attr.colorPrimary)) }.build() ) setColorSchemeParams( CustomTabsIntent.COLOR_SCHEME_DARK, CustomTabColorSchemeParams.Builder().apply { setToolbarColor(getColorAttr(R.attr.colorPrimary)) }.build() ) }.build().apply { if (intent.resolveActivity(packageManager) != null) { launchUrl(this@launchCustomTab, Uri.parse(link)) } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ktx/Dialogs.kt ================================================ package io.nekohasekai.sagernet.ktx import android.app.Activity import android.content.Context import androidx.appcompat.app.AlertDialog import androidx.fragment.app.Fragment import com.google.android.material.dialog.MaterialAlertDialogBuilder import io.nekohasekai.sagernet.R fun Context.alert(text: String): AlertDialog { return MaterialAlertDialogBuilder(this).setTitle(R.string.error_title) .setMessage(text) .setPositiveButton(android.R.string.ok, null) .create() } fun Fragment.alert(text: String) = requireContext().alert(text) fun AlertDialog.tryToShow() { try { val activity = context as Activity if (!activity.isFinishing) { show() } } catch (e: Exception) { Logs.e(e) } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ktx/Dimens.kt ================================================ package io.nekohasekai.sagernet.ktx import android.content.res.Resources import kotlin.math.ceil private val density = Resources.getSystem().displayMetrics.density fun dp2pxf(dpValue: Int): Float { return density * dpValue } fun dp2px(dpValue: Int): Int { return ceil(dp2pxf(dpValue)).toInt() } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ktx/Formats.kt ================================================ package io.nekohasekai.sagernet.ktx import com.google.gson.JsonParser import io.nekohasekai.sagernet.fmt.AbstractBean import io.nekohasekai.sagernet.fmt.Serializable import io.nekohasekai.sagernet.fmt.http.parseHttp import io.nekohasekai.sagernet.fmt.hysteria.parseHysteria1 import io.nekohasekai.sagernet.fmt.hysteria.parseHysteria2 import io.nekohasekai.sagernet.fmt.naive.parseNaive import io.nekohasekai.sagernet.fmt.parseUniversal import io.nekohasekai.sagernet.fmt.shadowsocks.parseShadowsocks import io.nekohasekai.sagernet.fmt.socks.parseSOCKS import io.nekohasekai.sagernet.fmt.trojan.parseTrojan import io.nekohasekai.sagernet.fmt.tuic.parseTuic import io.nekohasekai.sagernet.fmt.trojan_go.parseTrojanGo import io.nekohasekai.sagernet.fmt.v2ray.parseV2Ray import moe.matsuri.nb4a.proxy.anytls.parseAnytls import moe.matsuri.nb4a.utils.JavaUtil.gson import moe.matsuri.nb4a.utils.Util import okhttp3.HttpUrl import org.json.JSONArray import org.json.JSONException import org.json.JSONObject // JSON & Base64 fun JSONObject.toStringPretty(): String { return gson.toJson(JsonParser.parseString(this.toString())) } inline fun JSONArray.filterIsInstance(): List { val list = mutableListOf() for (i in 0 until this.length()) { if (this[i] is T) list.add(this[i] as T) } return list } inline fun JSONArray.forEach(action: (Int, Any) -> Unit) { for (i in 0 until this.length()) { action(i, this[i]) } } inline fun JSONObject.forEach(action: (String, Any) -> Unit) { for (k in this.keys()) { action(k, this.get(k)) } } fun isJsonObjectValid(j: Any): Boolean { if (j is JSONObject) return true if (j is JSONArray) return true try { JSONObject(j as String) } catch (ex: JSONException) { try { JSONArray(j) } catch (ex1: JSONException) { return false } } return true } // wtf hutool fun JSONObject.getStr(name: String): String? { val obj = this.opt(name) ?: return null if (obj is String) { if (obj.isBlank()) { return null } return obj } else { return null } } fun JSONObject.getBool(name: String): Boolean? { return try { getBoolean(name) } catch (ignored: Exception) { null } } // 重名了喵 fun JSONObject.getIntNya(name: String): Int? { return try { getInt(name) } catch (ignored: Exception) { null } } fun String.decodeBase64UrlSafe(): String { return String(Util.b64Decode(this)) } // Sub class SubscriptionFoundException(val link: String) : RuntimeException() suspend fun parseProxies(text: String): List { val links = text.split('\n').flatMap { it.trim().split(' ') } val linksByLine = text.split('\n').map { it.trim() } val entities = ArrayList() val entitiesByLine = ArrayList() fun String.parseLink(entities: ArrayList) { if (startsWith("clash://install-config?") || startsWith("sn://subscription?")) { throw SubscriptionFoundException(this) } if (startsWith("sn://")) { Logs.d("Try parse universal link: $this") runCatching { entities.add(parseUniversal(this)) }.onFailure { Logs.w(it) } } else if (startsWith("socks://") || startsWith("socks4://") || startsWith("socks4a://") || startsWith( "socks5://" ) ) { Logs.d("Try parse socks link: $this") runCatching { entities.add(parseSOCKS(this)) }.onFailure { Logs.w(it) } } else if (matches("(http|https)://.*".toRegex())) { Logs.d("Try parse http link: $this") runCatching { entities.add(parseHttp(this)) }.onFailure { Logs.w(it) val clashUrl = HttpUrl.Builder() .scheme("https") .host("install-config") .addQueryParameter("url", this) .build() .toString() .replaceFirst("https://", "clash://") throw (SubscriptionFoundException(clashUrl)) } } else if (startsWith("vmess://")) { Logs.d("Try parse v2ray link: $this") runCatching { entities.add(parseV2Ray(this)) }.onFailure { Logs.w(it) } } else if (startsWith("vless://")) { Logs.d("Try parse vless link: $this") runCatching { entities.add(parseV2Ray(this)) }.onFailure { Logs.w(it) } } else if (startsWith("trojan://")) { Logs.d("Try parse trojan link: $this") runCatching { entities.add(parseTrojan(this)) }.onFailure { Logs.w(it) } } else if (startsWith("trojan-go://")) { Logs.d("Try parse trojan-go link: $this") runCatching { entities.add(parseTrojanGo(this)) }.onFailure { Logs.w(it) } } else if (startsWith("ss://")) { Logs.d("Try parse shadowsocks link: $this") runCatching { entities.add(parseShadowsocks(this)) }.onFailure { Logs.w(it) } } else if (startsWith("naive+")) { Logs.d("Try parse naive link: $this") runCatching { entities.add(parseNaive(this)) }.onFailure { Logs.w(it) } } else if (startsWith("hysteria://")) { Logs.d("Try parse hysteria1 link: $this") runCatching { entities.add(parseHysteria1(this)) }.onFailure { Logs.w(it) } } else if (startsWith("hysteria2://") || startsWith("hy2://")) { Logs.d("Try parse hysteria2 link: $this") runCatching { entities.add(parseHysteria2(this)) }.onFailure { Logs.w(it) } } else if (startsWith("tuic://")) { Logs.d("Try parse TUIC link: $this") runCatching { entities.add(parseTuic(this)) }.onFailure { Logs.w(it) } } else if (startsWith("anytls://")) { Logs.d("Try parse anytls link: $this") runCatching { entities.add(parseAnytls(this)) }.onFailure { Logs.w(it) } } } for (link in links) { link.parseLink(entities) } for (link in linksByLine) { link.parseLink(entitiesByLine) } // var isBadLink = false if (entities.onEach { it.initializeDefaultValues() }.size == entitiesByLine.onEach { it.initializeDefaultValues() }.size) run test@{ entities.forEachIndexed { index, bean -> val lineBean = entitiesByLine[index] if (bean == lineBean && bean.displayName() != lineBean.displayName()) { // isBadLink = true return@test } } } return if (entities.size > entitiesByLine.size) entities else entitiesByLine } fun T.applyDefaultValues(): T { initializeDefaultValues() return this } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ktx/Kryos.kt ================================================ package io.nekohasekai.sagernet.ktx import android.os.Parcel import android.os.Parcelable import com.esotericsoftware.kryo.io.ByteBufferInput import com.esotericsoftware.kryo.io.ByteBufferOutput import java.io.InputStream import java.io.OutputStream fun InputStream.byteBuffer() = ByteBufferInput(this) fun OutputStream.byteBuffer() = ByteBufferOutput(this) fun ByteBufferInput.readStringList(): List { return mutableListOf().apply { repeat(readInt()) { add(readString()) } } } fun ByteBufferInput.readStringSet(): Set { return linkedSetOf().apply { repeat(readInt()) { add(readString()) } } } fun ByteBufferOutput.writeStringList(list: List) { writeInt(list.size) for (str in list) writeString(str) } fun ByteBufferOutput.writeStringList(list: Set) { writeInt(list.size) for (str in list) writeString(str) } fun Parcelable.marshall(): ByteArray { val parcel = Parcel.obtain() writeToParcel(parcel, 0) val bytes = parcel.marshall() parcel.recycle() return bytes } fun ByteArray.unmarshall(): Parcel { val parcel = Parcel.obtain() parcel.unmarshall(this, 0, size) parcel.setDataPosition(0) // This is extremely important! return parcel } fun ByteArray.unmarshall(constructor: (Parcel) -> T): T { val parcel = unmarshall() val result = constructor(parcel) parcel.recycle() return result } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ktx/Layouts.kt ================================================ package io.nekohasekai.sagernet.ktx import android.graphics.Rect import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.ui.MainActivity class FixedLinearLayoutManager(val recyclerView: RecyclerView) : LinearLayoutManager(recyclerView.context, RecyclerView.VERTICAL, false) { override fun onLayoutChildren(recycler: RecyclerView.Recycler?, state: RecyclerView.State?) { try { super.onLayoutChildren(recycler, state) } catch (ignored: IndexOutOfBoundsException) { } } private var listenerDisabled = false override fun scrollVerticallyBy( dx: Int, recycler: RecyclerView.Recycler, state: RecyclerView.State ): Int { // Matsuri style if (!DataStore.showBottomBar) return super.scrollVerticallyBy(dx, recycler, state) // SagerNet Style val scrollRange = super.scrollVerticallyBy(dx, recycler, state) if (listenerDisabled) return scrollRange val activity = recyclerView.context as? MainActivity if (activity == null) { listenerDisabled = true return scrollRange } val overscroll = dx - scrollRange if (overscroll > 0) { val view = (recyclerView.findViewHolderForAdapterPosition(findLastVisibleItemPosition()) ?: return scrollRange).itemView val itemLocation = Rect().also { view.getGlobalVisibleRect(it) } val fabLocation = Rect().also { activity.binding.fab.getGlobalVisibleRect(it) } if (!itemLocation.contains(fabLocation.left, fabLocation.top) && !itemLocation.contains( fabLocation.right, fabLocation.bottom ) ) { return scrollRange } activity.binding.fab.apply { if (isShown) hide() } } else { /*val screen = Rect().also { activity.window.decorView.getGlobalVisibleRect(it) } val location = Rect().also { activity.stats.getGlobalVisibleRect(it) } if (screen.bottom < location.bottom) { return scrollRange } val height = location.bottom - location.top val mH = activity.stats.measuredHeight if (mH > height) { return scrollRange }*/ activity.binding.fab.apply { if (!isShown) show() } } return scrollRange } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ktx/Logs.kt ================================================ package io.nekohasekai.sagernet.ktx import libcore.Libcore import java.io.InputStream import java.io.OutputStream object Logs { private fun mkTag(): String { val stackTrace = Thread.currentThread().stackTrace return stackTrace[4].className.substringAfterLast(".") } // level int use logrus.go fun d(message: String) { Libcore.nekoLogPrintln("[Debug] [${mkTag()}] $message") } fun d(message: String, exception: Throwable) { Libcore.nekoLogPrintln("[Debug] [${mkTag()}] $message" + "\n" + exception.stackTraceToString()) } fun i(message: String) { Libcore.nekoLogPrintln("[Info] [${mkTag()}] $message") } fun i(message: String, exception: Throwable) { Libcore.nekoLogPrintln("[Info] [${mkTag()}] $message" + "\n" + exception.stackTraceToString()) } fun w(message: String) { Libcore.nekoLogPrintln("[Warning] [${mkTag()}] $message") } fun w(message: String, exception: Throwable) { Libcore.nekoLogPrintln("[Warning] [${mkTag()}] $message" + "\n" + exception.stackTraceToString()) } fun w(exception: Throwable) { Libcore.nekoLogPrintln("[Warning] [${mkTag()}] " + exception.stackTraceToString()) } fun e(message: String) { Libcore.nekoLogPrintln("[Error] [${mkTag()}] $message") } fun e(message: String, exception: Throwable) { Libcore.nekoLogPrintln("[Error] [${mkTag()}] $message" + "\n" + exception.stackTraceToString()) } fun e(exception: Throwable) { Libcore.nekoLogPrintln("[Error] [${mkTag()}] " + exception.stackTraceToString()) } } fun InputStream.use(out: OutputStream) { use { input -> out.use { output -> input.copyTo(output) } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ktx/Nets.kt ================================================ @file:Suppress("SpellCheckingInspection") package io.nekohasekai.sagernet.ktx import io.nekohasekai.sagernet.BuildConfig import io.nekohasekai.sagernet.fmt.AbstractBean import moe.matsuri.nb4a.utils.NGUtil import okhttp3.HttpUrl import java.net.InetSocketAddress import java.net.Socket fun linkBuilder() = HttpUrl.Builder().scheme("https") fun HttpUrl.Builder.toLink(scheme: String, appendDefaultPort: Boolean = true): String { var url = build() val defaultPort = HttpUrl.defaultPort(url.scheme) var replace = false if (appendDefaultPort && url.port == defaultPort) { url = url.newBuilder().port(14514).build() replace = true } return url.toString().replace("${url.scheme}://", "$scheme://").let { if (replace) it.replace(":14514", ":$defaultPort") else it } } fun String.isIpAddress(): Boolean { return NGUtil.isIpv4Address(this) || NGUtil.isIpv6Address(this) } fun String.isIpAddressV6(): Boolean { return NGUtil.isIpv6Address(this) } // [2001:4860:4860::8888] -> 2001:4860:4860::8888 fun String.unwrapIPV6Host(): String { if (startsWith("[") && endsWith("]")) { return substring(1, length - 1).unwrapIPV6Host() } return this } // [2001:4860:4860::8888] or 2001:4860:4860::8888 -> [2001:4860:4860::8888] fun String.wrapIPV6Host(): String { val unwrapped = this.unwrapIPV6Host() if (unwrapped.isIpAddressV6()) { return "[$unwrapped]" } else { return this } } fun AbstractBean.wrapUri(): String { return "${finalAddress.wrapIPV6Host()}:$finalPort" } fun mkPort(): Int { val socket = Socket() socket.reuseAddress = true socket.bind(InetSocketAddress(0)) val port = socket.localPort socket.close() return port } const val USER_AGENT = "NekoBox/Android/" + BuildConfig.VERSION_NAME + " (Prefer ClashMeta Format)" ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ktx/Preferences.kt ================================================ package io.nekohasekai.sagernet.ktx import androidx.preference.PreferenceDataStore import kotlin.reflect.KProperty fun PreferenceDataStore.string( name: String, defaultValue: () -> String = { "" }, ) = PreferenceProxy(name, defaultValue, ::getString, ::putString) 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.stringSet( name: String, defaultValue: () -> Set = { setOf() }, ) = PreferenceProxy(name, defaultValue, ::getStringSet, ::putStringSet) 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 -> 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.stringToLong( name: String, defaultValue: () -> Long = { 0L }, ) = PreferenceProxy(name, defaultValue, { key, default -> getString(key, "$default")?.toLongOrNull() ?: default }, { key, value -> putString(key, "$value") }) class PreferenceProxy( val name: String, val defaultValue: () -> T, 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 getValue(thisObj: Any?, property: KProperty<*>) = getter(name, defaultValue())!! } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ktx/Utils.kt ================================================ @file:SuppressLint("SoonBlockedPrivateApi") package io.nekohasekai.sagernet.ktx import android.animation.Animator import android.animation.AnimatorListenerAdapter import android.annotation.SuppressLint import android.content.ActivityNotFoundException import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter import android.content.res.Resources import android.os.Build import android.system.Os import android.system.OsConstants import android.util.TypedValue import android.view.View import androidx.activity.result.ActivityResultLauncher import androidx.annotation.AttrRes import androidx.annotation.ColorRes import androidx.core.content.ContextCompat import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.fragment.app.DialogFragment import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager import androidx.preference.Preference import androidx.recyclerview.widget.LinearSmoothScroller import androidx.recyclerview.widget.RecyclerView import com.jakewharton.processphoenix.ProcessPhoenix import io.nekohasekai.sagernet.BuildConfig import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.SagerNet import io.nekohasekai.sagernet.aidl.ISagerNetService import io.nekohasekai.sagernet.bg.BaseService import io.nekohasekai.sagernet.bg.SagerConnection import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.ui.MainActivity import io.nekohasekai.sagernet.ui.ThemedActivity import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine import moe.matsuri.nb4a.utils.NGUtil import java.io.FileDescriptor import java.net.HttpURLConnection import java.net.InetAddress import java.net.Socket import java.net.URLEncoder import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicReference import kotlin.coroutines.Continuation import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException import kotlin.reflect.KMutableProperty0 import kotlin.reflect.KProperty import kotlin.reflect.KProperty0 fun String?.blankAsNull(): String? = if (isNullOrBlank()) null else this inline fun Iterable.forEachTry(action: (T) -> Unit) { var result: Exception? = null for (element in this) try { action(element) } catch (e: Exception) { if (result == null) result = e else result.addSuppressed(e) } if (result != null) { throw result } } val Throwable.readableMessage get() = localizedMessage.takeIf { !it.isNullOrBlank() } ?: javaClass.simpleName /** * https://android.googlesource.com/platform/prebuilts/runtime/+/94fec32/appcompat/hiddenapi-light-greylist.txt#9466 */ private val socketGetFileDescriptor = Socket::class.java.getDeclaredMethod("getFileDescriptor\$") val Socket.fileDescriptor get() = socketGetFileDescriptor.invoke(this) as FileDescriptor private val getInt = FileDescriptor::class.java.getDeclaredMethod("getInt$") val FileDescriptor.int get() = getInt.invoke(this) as Int suspend fun HttpURLConnection.useCancellable(block: suspend HttpURLConnection.() -> T): T { return suspendCancellableCoroutine { cont -> cont.invokeOnCancellation { if (Build.VERSION.SDK_INT >= 26) disconnect() else GlobalScope.launch(Dispatchers.IO) { disconnect() } } GlobalScope.launch(Dispatchers.IO) { try { cont.resume(block()) } catch (e: Throwable) { cont.resumeWithException(e) } } } } fun parsePort(str: String?, default: Int, min: Int = 1025): Int { val value = str?.toIntOrNull() ?: default return if (value < min || value > 65535) default else value } fun broadcastReceiver(callback: (Context, Intent) -> Unit): BroadcastReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) = callback(context, intent) } fun Context.listenForPackageChanges(onetime: Boolean = true, callback: () -> Unit) = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { callback() if (onetime) context.unregisterReceiver(this) } }.apply { registerReceiver(this, IntentFilter().apply { addAction(Intent.ACTION_PACKAGE_ADDED) addAction(Intent.ACTION_PACKAGE_REMOVED) addDataScheme("package") }) } /** * Based on: https://stackoverflow.com/a/26348729/2245107 */ fun Resources.Theme.resolveResourceId(@AttrRes resId: Int): Int { val typedValue = TypedValue() if (!resolveAttribute(resId, typedValue, true)) throw Resources.NotFoundException() return typedValue.resourceId } fun Preference.remove() = parent!!.removePreference(this) /** * A slightly more performant variant of parseNumericAddress. * * Bug in Android 9.0 and lower: https://issuetracker.google.com/issues/123456213 */ private val parseNumericAddress by lazy { InetAddress::class.java.getDeclaredMethod("parseNumericAddress", String::class.java).apply { isAccessible = true } } fun String?.parseNumericAddress(): InetAddress? = Os.inet_pton(OsConstants.AF_INET, this) ?: Os.inet_pton(OsConstants.AF_INET6, this)?.let { if (Build.VERSION.SDK_INT >= 29) it else parseNumericAddress.invoke( null, this ) as InetAddress } @JvmOverloads fun DialogFragment.showAllowingStateLoss(fragmentManager: FragmentManager, tag: String? = null) { if (!fragmentManager.isStateSaved) show(fragmentManager, tag) } fun String.pathSafe(): String { // " " encoded as + return URLEncoder.encode(this, "UTF-8") } fun String.urlSafe(): String { return URLEncoder.encode(this, "UTF-8").replace("+", "%20") } fun String.unUrlSafe(): String { return NGUtil.urlDecode(this) } fun RecyclerView.scrollTo(index: Int, force: Boolean = false) { if (force) post { scrollToPosition(index) } postDelayed({ try { layoutManager?.startSmoothScroll(object : LinearSmoothScroller(context) { init { targetPosition = index } override fun getVerticalSnapPreference(): Int { return SNAP_TO_START } }) } catch (ignored: IllegalArgumentException) { } }, 300L) } val app get() = SagerNet.application val shortAnimTime by lazy { app.resources.getInteger(android.R.integer.config_shortAnimTime).toLong() } fun View.crossFadeFrom(other: View) { clearAnimation() other.clearAnimation() if (isVisible && other.isGone) return alpha = 0F visibility = View.VISIBLE animate().alpha(1F).duration = shortAnimTime other.animate().alpha(0F).setListener(object : AnimatorListenerAdapter() { override fun onAnimationEnd(animation: Animator) { other.visibility = View.GONE } }).duration = shortAnimTime } fun Fragment.snackbar(textId: Int) = (requireActivity() as MainActivity).snackbar(textId) fun Fragment.snackbar(text: CharSequence) = (requireActivity() as MainActivity).snackbar(text) fun ThemedActivity.startFilesForResult( launcher: ActivityResultLauncher, input: String ) { try { return launcher.launch(input) } catch (_: ActivityNotFoundException) { } catch (_: SecurityException) { } snackbar(getString(R.string.file_manager_missing)).show() } fun Fragment.startFilesForResult( launcher: ActivityResultLauncher, input: String ) { try { return launcher.launch(input) } catch (_: ActivityNotFoundException) { } catch (_: SecurityException) { } (requireActivity() as ThemedActivity).snackbar(getString(R.string.file_manager_missing)).show() } fun Fragment.needReload() { if (DataStore.serviceState.started) { snackbar(getString(R.string.need_reload)).setAction(R.string.apply) { SagerNet.reloadService() }.show() } } fun Fragment.needRestart() { snackbar(R.string.need_restart).setAction(R.string.apply) { triggerFullRestart(requireContext()) }.show() } fun triggerFullRestart(ctx: Context) { runOnDefaultDispatcher { SagerNet.stopService() delay(500) SagerConnection.restartingApp = true val connection = SagerConnection(SagerConnection.CONNECTION_ID_RESTART_BG) connection.connect(ctx, RestartCallback { ProcessPhoenix.triggerRebirth(ctx, Intent(ctx, MainActivity::class.java)) }) } } private class RestartCallback(val callback: () -> Unit) : SagerConnection.Callback { override fun stateChanged( state: BaseService.State, profileName: String?, msg: String? ) { } override fun onServiceConnected(service: ISagerNetService) { callback() } } fun Context.getColour(@ColorRes colorRes: Int): Int { return ContextCompat.getColor(this, colorRes) } fun Context.getColorAttr(@AttrRes resId: Int): Int { return ContextCompat.getColor(this, TypedValue().also { theme.resolveAttribute(resId, it, true) }.resourceId) } val isExpert: Boolean by lazy { BuildConfig.DEBUG || DataStore.isExpert } const val isOss = BuildConfig.FLAVOR == "oss" const val isPlay = BuildConfig.FLAVOR == "play" const val isPreview = BuildConfig.FLAVOR == "preview" fun Continuation.tryResume(value: T) { try { resumeWith(Result.success(value)) } catch (ignored: IllegalStateException) { } } fun Continuation.tryResumeWithException(exception: Throwable) { try { resumeWith(Result.failure(exception)) } catch (ignored: IllegalStateException) { } } operator fun KProperty0.getValue(thisRef: Any?, property: KProperty<*>): F = get() operator fun KMutableProperty0.setValue( thisRef: Any?, property: KProperty<*>, value: F ) = set(value) operator fun AtomicBoolean.getValue(thisRef: Any?, property: KProperty<*>): Boolean = get() operator fun AtomicBoolean.setValue(thisRef: Any?, property: KProperty<*>, value: Boolean) = set(value) operator fun AtomicInteger.getValue(thisRef: Any?, property: KProperty<*>): Int = get() operator fun AtomicInteger.setValue(thisRef: Any?, property: KProperty<*>, value: Int) = set(value) operator fun AtomicLong.getValue(thisRef: Any?, property: KProperty<*>): Long = get() operator fun AtomicLong.setValue(thisRef: Any?, property: KProperty<*>, value: Long) = set(value) operator fun AtomicReference.getValue(thisRef: Any?, property: KProperty<*>): T = get() operator fun AtomicReference.setValue(thisRef: Any?, property: KProperty<*>, value: T) = set(value) operator fun Map.getValue(thisRef: K, property: KProperty<*>) = get(thisRef) operator fun MutableMap.setValue(thisRef: K, property: KProperty<*>, value: V?) { if (value != null) { put(thisRef, value) } else { remove(thisRef) } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/plugin/PluginManager.kt ================================================ package io.nekohasekai.sagernet.plugin import android.content.pm.ComponentInfo import android.content.pm.ProviderInfo import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.SagerNet import io.nekohasekai.sagernet.bg.BaseService import io.nekohasekai.sagernet.ktx.Logs import moe.matsuri.nb4a.plugin.Plugins import java.io.File import java.io.FileNotFoundException object PluginManager { class PluginNotFoundException(val plugin: String) : FileNotFoundException(plugin), BaseService.ExpectedException { override fun getLocalizedMessage() = SagerNet.application.getString(R.string.plugin_unknown, plugin) } data class InitResult( val path: String, val info: ProviderInfo, ) @Throws(Throwable::class) fun init(pluginId: String): InitResult? { if (pluginId.isEmpty()) return null var throwable: Throwable? = null try { val result = initNative(pluginId) if (result != null) return result } catch (t: Throwable) { throwable = t Logs.w(t) } throw throwable ?: PluginNotFoundException(pluginId) } private fun initNative(pluginId: String): InitResult? { val info = Plugins.getPlugin(pluginId) ?: return null // internal so if (info.applicationInfo == null) { try { initNativeInternal(pluginId)?.let { return InitResult(it, info) } } catch (t: Throwable) { Logs.w("initNativeInternal failed", t) } return null } try { initNativeFaster(info)?.let { return InitResult(it, info) } } catch (t: Throwable) { Logs.w("initNativeFaster failed", t) } Logs.w("Init native returns empty result") return null } private fun initNativeInternal(pluginId: String): String? { fun soIfExist(soName: String): String? { val f = File(SagerNet.application.applicationInfo.nativeLibraryDir, soName) if (f.canExecute()) { return f.absolutePath } return null } return when (pluginId) { "hysteria-plugin" -> soIfExist("libhysteria.so") "hysteria2-plugin" -> soIfExist("libhysteria2.so") else -> null } } private fun initNativeFaster(provider: ProviderInfo): String? { return provider.loadString(Plugins.METADATA_KEY_EXECUTABLE_PATH) ?.let { relativePath -> File(provider.applicationInfo.nativeLibraryDir).resolve(relativePath).apply { check(canExecute()) }.absolutePath } } fun ComponentInfo.loadString(key: String) = when (val value = metaData.get(key)) { is String -> value is Int -> SagerNet.application.packageManager.getResourcesForApplication(applicationInfo) .getString(value) null -> null else -> error("meta-data $key has invalid type ${value.javaClass}") } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ui/AboutFragment.kt ================================================ package io.nekohasekai.sagernet.ui import android.app.Activity import android.content.Context import android.content.Intent import android.net.Uri import android.os.Build import android.os.Bundle import android.os.PowerManager import android.provider.Settings import android.text.util.Linkify import android.view.View import android.widget.Toast import androidx.activity.result.component1 import androidx.activity.result.component2 import androidx.activity.result.contract.ActivityResultContracts import androidx.core.view.ViewCompat import androidx.recyclerview.widget.RecyclerView import com.danielstone.materialaboutlibrary.MaterialAboutFragment import com.danielstone.materialaboutlibrary.items.MaterialAboutActionItem import com.danielstone.materialaboutlibrary.model.MaterialAboutCard import com.danielstone.materialaboutlibrary.model.MaterialAboutList import io.nekohasekai.sagernet.BuildConfig import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.databinding.LayoutAboutBinding import io.nekohasekai.sagernet.ktx.* import io.nekohasekai.sagernet.plugin.PluginManager.loadString import io.nekohasekai.sagernet.utils.PackageCache import io.nekohasekai.sagernet.widget.ListListener import libcore.Libcore import moe.matsuri.nb4a.plugin.Plugins import androidx.core.net.toUri import com.google.android.material.dialog.MaterialAlertDialogBuilder import io.nekohasekai.sagernet.SagerNet import io.nekohasekai.sagernet.database.DataStore import moe.matsuri.nb4a.utils.Util import org.json.JSONObject class AboutFragment : ToolbarFragment(R.layout.layout_about) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) val binding = LayoutAboutBinding.bind(view) ViewCompat.setOnApplyWindowInsetsListener(view, ListListener) toolbar.setTitle(R.string.menu_about) parentFragmentManager.beginTransaction() .replace(R.id.about_fragment_holder, AboutContent()) .commitAllowingStateLoss() runOnDefaultDispatcher { val license = view.context.assets.open("LICENSE").bufferedReader().readText() onMainDispatcher { binding.license.text = license Linkify.addLinks(binding.license, Linkify.EMAIL_ADDRESSES or Linkify.WEB_URLS) } } } class AboutContent : MaterialAboutFragment() { val requestIgnoreBatteryOptimizations = registerForActivityResult( ActivityResultContracts.StartActivityForResult() ) { (resultCode, _) -> if (resultCode == Activity.RESULT_OK) { parentFragmentManager.beginTransaction() .replace(R.id.about_fragment_holder, AboutContent()) .commitAllowingStateLoss() } } override fun getMaterialAboutList(activityContext: Context): MaterialAboutList { return MaterialAboutList.Builder() .addCard( MaterialAboutCard.Builder() .outline(false) .addItem( MaterialAboutActionItem.Builder() .icon(R.drawable.ic_baseline_update_24) .text(R.string.app_version) .subText(SagerNet.appVersionNameForDisplay) .setOnClickAction { requireContext().launchCustomTab( "https://github.com/MatsuriDayo/NekoBoxForAndroid/releases" ) } .build()) .addItem( MaterialAboutActionItem.Builder() .text(R.string.check_update_release) .setOnClickAction { checkUpdate(false) } .build()) .addItem( MaterialAboutActionItem.Builder() .text(R.string.check_update_preview) .setOnClickAction { checkUpdate(true) } .build()) .addItem( MaterialAboutActionItem.Builder() .icon(R.drawable.ic_baseline_layers_24) .text(getString(R.string.version_x, "sing-box")) .subText(Libcore.versionBox()) .setOnClickAction { } .build()) .addItem( MaterialAboutActionItem.Builder() .icon(R.drawable.ic_baseline_card_giftcard_24) .text(R.string.donate) .subText(R.string.donate_info) .setOnClickAction { requireContext().launchCustomTab( "https://matsuridayo.github.io/index_docs/#donate" ) } .build()) .apply { PackageCache.awaitLoadSync() for ((_, pkg) in PackageCache.installedPluginPackages) { try { val pluginId = pkg.providers?.get(0)?.loadString(Plugins.METADATA_KEY_ID) if (pluginId.isNullOrBlank()) continue addItem( MaterialAboutActionItem.Builder() .icon(R.drawable.ic_baseline_nfc_24) .text( getString( R.string.version_x, pluginId ) + " (${Plugins.displayExeProvider(pkg.packageName)})" ) .subText("v" + pkg.versionName) .setOnClickAction { startActivity(Intent().apply { action = Settings.ACTION_APPLICATION_DETAILS_SETTINGS data = Uri.fromParts( "package", pkg.packageName, null ) }) } .build()) } catch (e: Exception) { Logs.w(e) } } } .apply { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { val pm = app.getSystemService(Context.POWER_SERVICE) as PowerManager if (!pm.isIgnoringBatteryOptimizations(app.packageName)) { addItem( MaterialAboutActionItem.Builder() .icon(R.drawable.ic_baseline_running_with_errors_24) .text(R.string.ignore_battery_optimizations) .subText(R.string.ignore_battery_optimizations_sum) .setOnClickAction { requestIgnoreBatteryOptimizations.launch( Intent( Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, "package:${app.packageName}".toUri() ) ) } .build()) } } } .build()) .addCard( MaterialAboutCard.Builder() .outline(false) .title(R.string.project) .addItem( MaterialAboutActionItem.Builder() .icon(R.drawable.ic_baseline_sanitizer_24) .text(R.string.github) .setOnClickAction { requireContext().launchCustomTab( "https://github.com/MatsuriDayo/NekoBoxForAndroid" ) } .build()) .addItem( MaterialAboutActionItem.Builder() .icon(R.drawable.ic_qu_shadowsocks_foreground) .text(R.string.telegram) .setOnClickAction { requireContext().launchCustomTab( "https://t.me/MatsuriDayo" ) } .build()) .build()) .build() } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) view.findViewById(R.id.mal_recyclerview).apply { overScrollMode = RecyclerView.OVER_SCROLL_NEVER } } fun checkUpdate(checkPreview: Boolean) { runOnIoDispatcher { try { val client = Libcore.newHttpClient().apply { modernTLS() trySocks5(DataStore.mixedPort) } val response = client.newRequest().apply { if (checkPreview) { setURL("https://api.github.com/repos/MatsuriDayo/NekoBoxForAndroid/releases/tags/preview") } else { setURL("https://api.github.com/repos/MatsuriDayo/NekoBoxForAndroid/releases/latest") } }.execute() val release = JSONObject(Util.getStringBox(response.contentString)) val releaseName = release.getString("name") val releaseUrl = release.getString("html_url") var haveUpdate = releaseName.isNotBlank() haveUpdate = if (isPreview) { if (checkPreview) { haveUpdate && releaseName != BuildConfig.PRE_VERSION_NAME } else { // User: 1.3.9 pre-1.4.0 Stable: 1.3.9 -> No update haveUpdate && releaseName != BuildConfig.VERSION_NAME } } else { // User: 1.4.0 Preview: pre-1.4.0 -> No update // User: 1.4.0 Preview: pre-1.4.1 -> Update // User: 1.4.0 Stable: 1.4.0 -> No update // User: 1.4.0 Stable: 1.4.1 -> Update haveUpdate && !releaseName.contains(BuildConfig.VERSION_NAME) } runOnMainDispatcher { if (haveUpdate) { val context = requireContext() MaterialAlertDialogBuilder(context) .setTitle(R.string.update_dialog_title) .setMessage( context.getString( R.string.update_dialog_message, SagerNet.appVersionNameForDisplay, releaseName ) ) .setPositiveButton(R.string.yes) { _, _ -> val intent = Intent(Intent.ACTION_VIEW, releaseUrl.toUri()) context.startActivity(intent) } .setNegativeButton(R.string.no, null) .show() } else { Toast.makeText(app, R.string.check_update_no, Toast.LENGTH_SHORT).show() } } } catch (e: Exception) { Logs.w(e) runOnMainDispatcher { Toast.makeText(app, e.readableMessage, Toast.LENGTH_SHORT).show() } } } } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ui/AppListActivity.kt ================================================ package io.nekohasekai.sagernet.ui import android.content.Intent import android.content.pm.ApplicationInfo import android.content.pm.PackageManager import android.graphics.drawable.Drawable import android.os.Bundle import android.util.SparseBooleanArray import android.view.KeyEvent import android.view.Menu import android.view.MenuItem import android.view.View import android.view.ViewGroup import android.widget.Filter import android.widget.Filterable import androidx.annotation.UiThread import androidx.core.util.contains import androidx.core.util.set import androidx.core.view.ViewCompat import androidx.core.widget.addTextChangedListener import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.DefaultItemAnimator import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.google.android.material.snackbar.Snackbar import com.simplecityapps.recyclerview_fastscroll.views.FastScrollRecyclerView import io.nekohasekai.sagernet.BuildConfig import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.SagerNet import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.databinding.LayoutAppListBinding import io.nekohasekai.sagernet.databinding.LayoutAppsItemBinding import io.nekohasekai.sagernet.ktx.crossFadeFrom import io.nekohasekai.sagernet.ktx.onMainDispatcher import io.nekohasekai.sagernet.ktx.runOnDefaultDispatcher import io.nekohasekai.sagernet.utils.PackageCache import io.nekohasekai.sagernet.widget.ListListener import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.ensureActive import kotlinx.coroutines.withContext import kotlin.coroutines.coroutineContext class AppListActivity : ThemedActivity() { companion object { private const val SWITCH = "switch" private val cachedApps get() = PackageCache.installedPackages.toMutableMap().apply { remove(BuildConfig.APPLICATION_ID) } } private class ProxiedApp( private val pm: PackageManager, private val appInfo: ApplicationInfo, val packageName: String, ) { val name: CharSequence = appInfo.loadLabel(pm) // cached for sorting val icon: Drawable get() = appInfo.loadIcon(pm) val uid get() = appInfo.uid val sys get() = (appInfo.flags and ApplicationInfo.FLAG_SYSTEM) != 0 } private inner class AppViewHolder(val binding: LayoutAppsItemBinding) : RecyclerView.ViewHolder( binding.root ), View.OnClickListener { private lateinit var item: ProxiedApp init { binding.root.setOnClickListener(this) } fun bind(app: ProxiedApp) { item = app binding.itemicon.setImageDrawable(app.icon) binding.title.text = app.name binding.desc.text = "${app.packageName} (${app.uid})" handlePayload(listOf(SWITCH)) } fun handlePayload(payloads: List) { if (payloads.contains(SWITCH)) { val selected = isProxiedApp(item) binding.itemcheck.isChecked = selected } } override fun onClick(v: View?) { if (isProxiedApp(item)) proxiedUids.delete(item.uid) else proxiedUids[item.uid] = true DataStore.routePackages = apps.filter { isProxiedApp(it) } .joinToString("\n") { it.packageName } appsAdapter.notifyItemRangeChanged(0, appsAdapter.itemCount, SWITCH) } } private inner class AppsAdapter : RecyclerView.Adapter(), Filterable, FastScrollRecyclerView.SectionedAdapter { var filteredApps = apps suspend fun reload() { PackageCache.reload() apps = cachedApps.mapNotNull { (packageName, packageInfo) -> coroutineContext[Job]!!.ensureActive() packageInfo.applicationInfo?.let { ProxiedApp(packageManager, it, packageName) } }.sortedWith(compareBy({ !isProxiedApp(it) }, { it.name.toString() })) } override fun onBindViewHolder(holder: AppViewHolder, position: Int) = holder.bind(filteredApps[position]) override fun onBindViewHolder(holder: AppViewHolder, position: Int, payloads: List) { if (payloads.isNotEmpty()) { @Suppress("UNCHECKED_CAST") holder.handlePayload(payloads as List) return } onBindViewHolder(holder, position) } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AppViewHolder = AppViewHolder(LayoutAppsItemBinding.inflate(layoutInflater, parent, false)) override fun getItemCount(): Int = filteredApps.size private val filterImpl = object : Filter() { override fun performFiltering(constraint: CharSequence) = FilterResults().apply { var filteredApps = if (constraint.isEmpty()) apps else apps.filter { it.name.contains(constraint, true) || it.packageName.contains( constraint, true ) || it.uid.toString().contains(constraint) } if (!sysApps) filteredApps = filteredApps.filter { !it.sys } count = filteredApps.size values = filteredApps } override fun publishResults(constraint: CharSequence, results: FilterResults) { @Suppress("UNCHECKED_CAST") filteredApps = results.values as List notifyDataSetChanged() } } override fun getFilter(): Filter = filterImpl override fun getSectionName(position: Int): String { return filteredApps[position].name.firstOrNull()?.toString() ?: "" } } private val loading by lazy { findViewById(R.id.loading) } private lateinit var binding: LayoutAppListBinding private val proxiedUids = SparseBooleanArray() private var loader: Job? = null private var apps = emptyList() private val appsAdapter = AppsAdapter() private fun initProxiedUids(str: String = DataStore.routePackages) { proxiedUids.clear() val apps = cachedApps for (line in str.lineSequence()) { val app = (apps[line] ?: continue) val uid = app.applicationInfo?.uid ?: continue proxiedUids[uid] = true } } private fun isProxiedApp(app: ProxiedApp) = proxiedUids[app.uid] @UiThread private fun loadApps() { loader?.cancel() loader = lifecycleScope.launchWhenCreated { loading.crossFadeFrom(binding.list) val adapter = binding.list.adapter as AppsAdapter withContext(Dispatchers.IO) { adapter.reload() } adapter.filter.filter(binding.search.text?.toString() ?: "") if (apps.isEmpty()) { binding.list.visibility = View.GONE binding.appPlaceholder.root.crossFadeFrom(loading) } else { binding.list.crossFadeFrom(loading) } } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = LayoutAppListBinding.inflate(layoutInflater) setContentView(binding.root) binding.appPlaceholder.openSettings.setOnClickListener { val intent = Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { data = android.net.Uri.fromParts("package", packageName, null) } startActivity(intent) } setSupportActionBar(binding.toolbar) supportActionBar?.apply { setTitle(R.string.select_apps) setDisplayHomeAsUpEnabled(true) setHomeAsUpIndicator(R.drawable.ic_navigation_close) } initProxiedUids() binding.list.layoutManager = LinearLayoutManager(this, RecyclerView.VERTICAL, false) binding.list.itemAnimator = DefaultItemAnimator() binding.list.adapter = appsAdapter ViewCompat.setOnApplyWindowInsetsListener(binding.root, ListListener) binding.search.addTextChangedListener { appsAdapter.filter.filter(it?.toString() ?: "") } binding.showSystemApps.isChecked = sysApps binding.showSystemApps.setOnCheckedChangeListener { _, isChecked -> sysApps = isChecked appsAdapter.filter.filter(binding.search.text?.toString() ?: "") } loadApps() } private var sysApps = false override fun onCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.app_list_menu, menu) return true } override fun onOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { R.id.action_invert_selections -> { runOnDefaultDispatcher { for (app in apps) { if (proxiedUids.contains(app.uid)) { proxiedUids.delete(app.uid) } else { proxiedUids[app.uid] = true } } DataStore.routePackages = apps.filter { isProxiedApp(it) } .joinToString("\n") { it.packageName } apps = apps.sortedWith(compareBy({ !isProxiedApp(it) }, { it.name.toString() })) onMainDispatcher { appsAdapter.filter.filter(binding.search.text?.toString() ?: "") } } return true } R.id.action_clear_selections -> { runOnDefaultDispatcher { proxiedUids.clear() DataStore.routePackages = "" apps = apps.sortedWith(compareBy({ !isProxiedApp(it) }, { it.name.toString() })) onMainDispatcher { appsAdapter.filter.filter(binding.search.text?.toString() ?: "") } } } R.id.action_export_clipboard -> { val success = SagerNet.trySetPrimaryClip("false\n${DataStore.routePackages}") Snackbar.make( binding.list, if (success) R.string.action_export_msg else R.string.action_export_err, Snackbar.LENGTH_LONG ).show() return true } R.id.action_import_clipboard -> { val proxiedAppString = SagerNet.clipboard.primaryClip?.getItemAt(0)?.text?.toString() if (!proxiedAppString.isNullOrEmpty()) { val i = proxiedAppString.indexOf('\n') try { val apps = if (i < 0) "" else proxiedAppString.substring(i + 1) DataStore.routePackages = apps Snackbar.make( binding.list, R.string.action_import_msg, Snackbar.LENGTH_LONG ).show() initProxiedUids(apps) appsAdapter.notifyItemRangeChanged(0, appsAdapter.itemCount, SWITCH) return true } catch (_: IllegalArgumentException) { } } Snackbar.make(binding.list, R.string.action_import_err, Snackbar.LENGTH_LONG).show() } } return super.onOptionsItemSelected(item) } override fun onSupportNavigateUp(): Boolean { if (!super.onSupportNavigateUp()) finish() return true } override fun supportNavigateUpTo(upIntent: Intent) = super.supportNavigateUpTo(upIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)) override fun onKeyUp(keyCode: Int, event: KeyEvent?) = if (keyCode == KeyEvent.KEYCODE_MENU) { if (binding.toolbar.isOverflowMenuShowing) binding.toolbar.hideOverflowMenu() else binding.toolbar.showOverflowMenu() } else super.onKeyUp(keyCode, event) override fun onDestroy() { loader?.cancel() super.onDestroy() } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ui/AppManagerActivity.kt ================================================ package io.nekohasekai.sagernet.ui import android.annotation.SuppressLint import android.content.Intent import android.content.pm.ApplicationInfo import android.content.pm.PackageManager import android.graphics.drawable.Drawable import android.os.Bundle import android.text.TextUtils import android.util.SparseBooleanArray import android.view.KeyEvent import android.view.Menu import android.view.MenuItem import android.view.View import android.view.ViewGroup import android.widget.Filter import android.widget.Filterable import androidx.annotation.UiThread import androidx.core.util.contains import androidx.core.util.set import androidx.core.view.ViewCompat import androidx.core.widget.addTextChangedListener import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.DefaultItemAnimator import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar import com.simplecityapps.recyclerview_fastscroll.views.FastScrollRecyclerView import io.nekohasekai.sagernet.BuildConfig import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.SagerNet import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.databinding.LayoutAppsBinding import io.nekohasekai.sagernet.databinding.LayoutAppsItemBinding import io.nekohasekai.sagernet.ktx.Logs import io.nekohasekai.sagernet.ktx.app import io.nekohasekai.sagernet.ktx.crossFadeFrom import io.nekohasekai.sagernet.ktx.onMainDispatcher import io.nekohasekai.sagernet.ktx.runOnDefaultDispatcher import io.nekohasekai.sagernet.utils.PackageCache import io.nekohasekai.sagernet.widget.ListListener import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.ensureActive import kotlinx.coroutines.withContext import moe.matsuri.nb4a.utils.NGUtil import kotlin.coroutines.coroutineContext class AppManagerActivity : ThemedActivity() { companion object { @SuppressLint("StaticFieldLeak") private var instance: AppManagerActivity? = null private const val SWITCH = "switch" private val cachedApps get() = PackageCache.installedPackages.toMutableMap().apply { remove(BuildConfig.APPLICATION_ID) } } private class ProxiedApp( private val pm: PackageManager, private val appInfo: ApplicationInfo, val packageName: String, ) { val name: CharSequence = appInfo.loadLabel(pm) // cached for sorting val icon: Drawable get() = appInfo.loadIcon(pm) val uid get() = appInfo.uid val sys get() = (appInfo.flags and ApplicationInfo.FLAG_SYSTEM) != 0 } private inner class AppViewHolder(val binding: LayoutAppsItemBinding) : RecyclerView.ViewHolder( binding.root ), View.OnClickListener { private lateinit var item: ProxiedApp init { binding.root.setOnClickListener(this) } fun bind(app: ProxiedApp) { item = app binding.itemicon.setImageDrawable(app.icon) binding.title.text = app.name binding.desc.text = "${app.packageName} (${app.uid})" binding.itemcheck.isChecked = isProxiedApp(app) } fun handlePayload(payloads: List) { if (payloads.contains(SWITCH)) binding.itemcheck.isChecked = isProxiedApp(item) } override fun onClick(v: View?) { if (isProxiedApp(item)) proxiedUids.delete(item.uid) else proxiedUids[item.uid] = true DataStore.individual = apps.filter { isProxiedApp(it) } .joinToString("\n") { it.packageName } appsAdapter.notifyItemRangeChanged(0, appsAdapter.itemCount, SWITCH) } } private inner class AppsAdapter : RecyclerView.Adapter(), Filterable, FastScrollRecyclerView.SectionedAdapter { var filteredApps = apps suspend fun reload() { PackageCache.reload() apps = cachedApps.mapNotNull { (packageName, packageInfo) -> coroutineContext[Job]!!.ensureActive() packageInfo.applicationInfo?.let { ProxiedApp(packageManager, it, packageName) } }.sortedWith(compareBy({ !isProxiedApp(it) }, { it.name.toString() })) } override fun onBindViewHolder(holder: AppViewHolder, position: Int) = holder.bind(filteredApps[position]) override fun onBindViewHolder(holder: AppViewHolder, position: Int, payloads: List) { if (payloads.isNotEmpty()) { @Suppress("UNCHECKED_CAST") holder.handlePayload(payloads as List) return } onBindViewHolder(holder, position) } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AppViewHolder = AppViewHolder(LayoutAppsItemBinding.inflate(layoutInflater, parent, false)) override fun getItemCount(): Int = filteredApps.size private val filterImpl = object : Filter() { override fun performFiltering(constraint: CharSequence) = FilterResults().apply { var filteredApps = if (constraint.isEmpty()) apps else apps.filter { it.name.contains(constraint, true) || it.packageName.contains( constraint, true ) || it.uid.toString().contains(constraint) } if (!sysApps) filteredApps = filteredApps.filter { !it.sys } count = filteredApps.size values = filteredApps } override fun publishResults(constraint: CharSequence, results: FilterResults) { @Suppress("UNCHECKED_CAST") filteredApps = results.values as List notifyDataSetChanged() } } override fun getFilter(): Filter = filterImpl override fun getSectionName(position: Int): String { return filteredApps[position].name.firstOrNull()?.toString() ?: "" } } private val loading by lazy { findViewById(R.id.loading) } private lateinit var binding: LayoutAppsBinding private val proxiedUids = SparseBooleanArray() private var loader: Job? = null private var apps = emptyList() private val appsAdapter = AppsAdapter() private fun initProxiedUids(str: String = DataStore.individual) { proxiedUids.clear() val apps = cachedApps for (line in str.lineSequence()) { val app = (apps[line] ?: continue) val uid = app.applicationInfo?.uid ?: continue proxiedUids[uid] = true } } private fun isProxiedApp(app: ProxiedApp) = proxiedUids[app.uid] @UiThread private fun loadApps() { loader?.cancel() loader = lifecycleScope.launchWhenCreated { loading.crossFadeFrom(binding.list) val adapter = binding.list.adapter as AppsAdapter withContext(Dispatchers.IO) { adapter.reload() } adapter.filter.filter(binding.search.text?.toString() ?: "") if (apps.isEmpty()) { binding.list.visibility = View.GONE binding.appPlaceholder.root.crossFadeFrom(loading) } else { binding.list.crossFadeFrom(loading) } } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = LayoutAppsBinding.inflate(layoutInflater) setContentView(binding.root) binding.appPlaceholder.openSettings.setOnClickListener { val intent = Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { data = android.net.Uri.fromParts("package", packageName, null) } startActivity(intent) } setSupportActionBar(binding.toolbar) supportActionBar?.apply { setTitle(R.string.proxied_apps) setDisplayHomeAsUpEnabled(true) setHomeAsUpIndicator(R.drawable.ic_navigation_close) } if (!DataStore.proxyApps) { DataStore.proxyApps = true } binding.bypassGroup.check(if (DataStore.bypass) R.id.appProxyModeBypass else R.id.appProxyModeOn) binding.bypassGroup.setOnCheckedChangeListener { _, checkedId -> when (checkedId) { R.id.appProxyModeDisable -> { DataStore.proxyApps = false finish() } R.id.appProxyModeOn -> DataStore.bypass = false R.id.appProxyModeBypass -> DataStore.bypass = true } } binding.autoSelectProxyApps.setOnClickListener { selectProxyApp() } initProxiedUids() binding.list.layoutManager = LinearLayoutManager(this, RecyclerView.VERTICAL, false) binding.list.itemAnimator = DefaultItemAnimator() binding.list.adapter = appsAdapter ViewCompat.setOnApplyWindowInsetsListener(binding.root, ListListener) binding.search.addTextChangedListener { appsAdapter.filter.filter(it?.toString() ?: "") } binding.showSystemApps.isChecked = sysApps binding.showSystemApps.setOnCheckedChangeListener { _, isChecked -> sysApps = isChecked appsAdapter.filter.filter(binding.search.text?.toString() ?: "") } instance = this loadApps() } private var sysApps = true override fun onCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.per_app_proxy_menu, menu) return true } override fun onOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { R.id.action_invert_selections -> { runOnDefaultDispatcher { val proxiedUidsOld = proxiedUids.clone() for (app in apps) { if (proxiedUidsOld.contains(app.uid)) { proxiedUids.delete(app.uid) } else { proxiedUids[app.uid] = true } } DataStore.individual = apps.filter { isProxiedApp(it) } .joinToString("\n") { it.packageName } apps = apps.sortedWith(compareBy({ !isProxiedApp(it) }, { it.name.toString() })) onMainDispatcher { appsAdapter.filter.filter(binding.search.text?.toString() ?: "") } } return true } R.id.action_clear_selections -> { runOnDefaultDispatcher { proxiedUids.clear() DataStore.individual = "" apps = apps.sortedWith(compareBy({ !isProxiedApp(it) }, { it.name.toString() })) onMainDispatcher { appsAdapter.filter.filter(binding.search.text?.toString() ?: "") } } } R.id.action_export_clipboard -> { val success = SagerNet.trySetPrimaryClip("${DataStore.bypass}\n${DataStore.individual}") Snackbar.make( binding.list, if (success) R.string.action_export_msg else R.string.action_export_err, Snackbar.LENGTH_LONG ).show() return true } R.id.action_import_clipboard -> { val proxiedAppString = SagerNet.clipboard.primaryClip?.getItemAt(0)?.text?.toString() if (!proxiedAppString.isNullOrEmpty()) { val i = proxiedAppString.indexOf('\n') try { val (enabled, apps) = if (i < 0) { proxiedAppString to "" } else proxiedAppString.substring( 0, i ) to proxiedAppString.substring(i + 1) binding.bypassGroup.check(if (enabled.toBoolean()) R.id.appProxyModeBypass else R.id.appProxyModeOn) DataStore.individual = apps Snackbar.make( binding.list, R.string.action_import_msg, Snackbar.LENGTH_LONG ).show() initProxiedUids(apps) appsAdapter.notifyItemRangeChanged(0, appsAdapter.itemCount, SWITCH) return true } catch (_: IllegalArgumentException) { } } Snackbar.make(binding.list, R.string.action_import_err, Snackbar.LENGTH_LONG).show() } } return super.onOptionsItemSelected(item) } private fun selectProxyApp() { MaterialAlertDialogBuilder(this).setTitle(R.string.confirm) .setMessage(R.string.auto_select_proxy_apps_message) .setPositiveButton(R.string.yes) { _, _ -> try { val needProxyAppsList = getAutoProxyApps("") val bypass = DataStore.bypass proxiedUids.clear() for (app in cachedApps) { val needProxy = needProxyAppsList.contains(app.key) || (app.value.applicationInfo?.uid ?: 0) == 1000 if (needProxy) { if (!bypass) { app.value.applicationInfo?.apply { proxiedUids[uid] = true } } } else { if (bypass) { app.value.applicationInfo?.apply { proxiedUids[uid] = true } } } } DataStore.individual = apps.filter { isProxiedApp(it) }.joinToString("\n") { it.packageName } apps = apps.sortedWith(compareBy({ !isProxiedApp(it) }, { it.name.toString() })) appsAdapter.filter.filter(binding.search.text?.toString() ?: "") } catch (e: Exception) { Logs.e(e) } } .setNegativeButton(R.string.no, null) .show() } private fun getAutoProxyApps(content: String): List { var list = listOf() try { val proxyApps = if (TextUtils.isEmpty(content)) { NGUtil.readTextFromAssets(app, "proxy_packagename.txt") } else { content } if (!TextUtils.isEmpty(proxyApps)) { list = proxyApps.split("\n") } } catch (_: Exception) { } return list } override fun supportNavigateUpTo(upIntent: Intent) = super.supportNavigateUpTo(upIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)) override fun onKeyUp(keyCode: Int, event: KeyEvent?) = if (keyCode == KeyEvent.KEYCODE_MENU) { if (binding.toolbar.isOverflowMenuShowing) binding.toolbar.hideOverflowMenu() else binding.toolbar.showOverflowMenu() } else super.onKeyUp(keyCode, event) override fun onDestroy() { instance = null loader?.cancel() super.onDestroy() } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ui/AssetsActivity.kt ================================================ package io.nekohasekai.sagernet.ui import android.os.Bundle import android.provider.OpenableColumns import android.text.format.DateFormat import android.view.Menu import android.view.MenuItem import android.view.ViewGroup import androidx.activity.result.contract.ActivityResultContracts import androidx.core.view.isInvisible import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.RecyclerView import com.google.android.material.snackbar.Snackbar import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.databinding.LayoutAssetItemBinding import io.nekohasekai.sagernet.databinding.LayoutAssetsBinding import io.nekohasekai.sagernet.ktx.* import io.nekohasekai.sagernet.widget.UndoSnackbarManager import libcore.Libcore import moe.matsuri.nb4a.utils.Util import org.json.JSONObject import java.io.File import java.io.FileWriter import java.util.* import java.util.concurrent.atomic.AtomicInteger class AssetsActivity : ThemedActivity() { lateinit var adapter: AssetAdapter lateinit var layout: LayoutAssetsBinding lateinit var undoManager: UndoSnackbarManager override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val binding = LayoutAssetsBinding.inflate(layoutInflater) layout = binding setContentView(binding.root) setSupportActionBar(findViewById(R.id.toolbar)) supportActionBar?.apply { setTitle(R.string.route_assets) setDisplayHomeAsUpEnabled(true) setHomeAsUpIndicator(R.drawable.ic_navigation_close) } binding.recyclerView.layoutManager = FixedLinearLayoutManager(binding.recyclerView) adapter = AssetAdapter() binding.recyclerView.adapter = adapter binding.refreshLayout.setOnRefreshListener { adapter.reloadAssets() binding.refreshLayout.isRefreshing = false } binding.refreshLayout.setColorSchemeColors(getColorAttr(R.attr.primaryOrTextPrimary)) undoManager = UndoSnackbarManager(this, adapter) ItemTouchHelper(object : ItemTouchHelper.SimpleCallback( 0, ItemTouchHelper.START ) { override fun getSwipeDirs( recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder ): Int { val index = viewHolder.bindingAdapterPosition if (index < 2) return 0 return super.getSwipeDirs(recyclerView, viewHolder) } override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { val index = viewHolder.bindingAdapterPosition adapter.remove(index) undoManager.remove(index to (viewHolder as AssetHolder).file) } override fun onMove( recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder ) = false }).attachToRecyclerView(binding.recyclerView) } override fun snackbarInternal(text: CharSequence): Snackbar { return Snackbar.make(layout.coordinator, text, Snackbar.LENGTH_LONG) } val assetNames = arrayOf("geoip.db", "geosite.db") override fun onCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.import_asset_menu, menu) return true } val importFile = registerForActivityResult(ActivityResultContracts.GetContent()) { file -> if (file != null) { val fileName = contentResolver.query(file, null, null, null, null)?.use { cursor -> cursor.moveToFirst() cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME).let(cursor::getString) }?.takeIf { it.isNotBlank() } ?: file.pathSegments.last() .substringAfterLast('/') .substringAfter(':') if (!fileName.endsWith(".db")) { alert(getString(R.string.route_not_asset, fileName)).show() return@registerForActivityResult } val filesDir = getExternalFilesDir(null) ?: filesDir runOnDefaultDispatcher { val outFile = File(filesDir, fileName).apply { parentFile?.mkdirs() } contentResolver.openInputStream(file)?.use(outFile.outputStream()) File(outFile.parentFile, outFile.nameWithoutExtension + ".version.txt").apply { if (isFile) delete() createNewFile() val fw = FileWriter(this) fw.write("Custom") fw.close() } adapter.reloadAssets() } } } override fun onOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { R.id.action_import_file -> { startFilesForResult(importFile, "*/*") return true } } return false } inner class AssetAdapter : RecyclerView.Adapter(), UndoSnackbarManager.Interface { val assets = ArrayList() init { reloadAssets() } fun reloadAssets() { val filesDir = getExternalFilesDir(null) ?: filesDir val files = filesDir.listFiles() ?.filter { it.isFile && it.name.endsWith(".db") && it.name !in assetNames } assets.clear() assets.add(File(filesDir, "geoip.db")) assets.add(File(filesDir, "geosite.db")) if (files != null) assets.addAll(files) layout.refreshLayout.post { notifyDataSetChanged() } } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AssetHolder { return AssetHolder(LayoutAssetItemBinding.inflate(layoutInflater, parent, false)) } override fun onBindViewHolder(holder: AssetHolder, position: Int) { holder.bind(assets[position]) } override fun getItemCount(): Int { return assets.size } fun remove(index: Int) { assets.removeAt(index) notifyItemRemoved(index) } override fun undo(actions: List>) { for ((index, item) in actions) { assets.add(index, item) notifyItemInserted(index) } } override fun commit(actions: List>) { val groups = actions.map { it.second }.toTypedArray() runOnDefaultDispatcher { groups.forEach { it.deleteRecursively() } } } } val updating = AtomicInteger() inner class AssetHolder(val binding: LayoutAssetItemBinding) : RecyclerView.ViewHolder(binding.root) { lateinit var file: File fun bind(file: File) { this.file = file binding.assetName.text = file.name val versionFile = File(file.parentFile, "${file.nameWithoutExtension}.version.txt") val localVersion = if (file.isFile) { if (versionFile.isFile) { try { versionFile.readText().trim() } catch (e: Throwable) { snackbar(e.readableMessage) "" } } else { "Unknown-" + DateFormat.getDateFormat(app).format(Date(file.lastModified())) } } else { "" } binding.assetStatus.text = getString(R.string.route_asset_status, localVersion) binding.rulesUpdate.isInvisible = file.name !in assetNames binding.rulesUpdate.setOnClickListener { updating.incrementAndGet() layout.refreshLayout.isEnabled = false binding.subscriptionUpdateProgress.isInvisible = false binding.rulesUpdate.isInvisible = true runOnDefaultDispatcher { runCatching { updateAsset(file, versionFile, localVersion) }.onFailure { onMainDispatcher { alert(it.readableMessage).tryToShow() } } onMainDispatcher { binding.rulesUpdate.isInvisible = false binding.subscriptionUpdateProgress.isInvisible = true if (updating.decrementAndGet() == 0) { layout.refreshLayout.isEnabled = true } } } } } } private val rulesProviders = listOf( RuleAssetsProvider( "SagerNet/sing-geoip", "SagerNet/sing-geosite", ), RuleAssetsProvider( "soffchen/sing-geoip", "soffchen/sing-geosite", ), RuleAssetsProvider( "Chocolate4U/Iran-sing-box-rules" ), RuleAssetsProvider( "L11R/antizapret-sing-box-geo" ), ) suspend fun updateAsset(file: File, versionFile: File, localVersion: String) { var fileName = file.name val ruleProvider = rulesProviders[DataStore.rulesProvider] val repo = ruleProvider.repoByFileName[fileName] val client = Libcore.newHttpClient().apply { modernTLS() keepAlive() trySocks5(DataStore.mixedPort) } try { var response = client.newRequest().apply { setURL("https://api.github.com/repos/$repo/releases/latest") }.execute() val release = JSONObject(Util.getStringBox(response.contentString)) val tagName = release.optString("tag_name") if (tagName == localVersion) { onMainDispatcher { snackbar(R.string.route_asset_no_update).show() } return } val releaseAssets = release.getJSONArray("assets").filterIsInstance() val assetToDownload = releaseAssets.find { it.getStr("name") == fileName } ?: error("File $fileName not found in release ${release["url"]}") val browserDownloadUrl = assetToDownload.getStr("browser_download_url") response = client.newRequest().apply { setURL(browserDownloadUrl) }.execute() val cacheFile = File(file.parentFile, file.name + ".tmp") cacheFile.parentFile?.mkdirs() response.writeTo(cacheFile.canonicalPath) if (fileName.endsWith(".xz")) { Libcore.unxz(cacheFile.absolutePath, file.absolutePath) cacheFile.delete() } else { cacheFile.renameTo(file) } versionFile.writeText(tagName) adapter.reloadAssets() onMainDispatcher { snackbar(R.string.route_asset_updated).show() } } finally { client.close() } } override fun onSupportNavigateUp(): Boolean { finish() return true } override fun onBackPressed() { finish() } override fun onResume() { super.onResume() if (::adapter.isInitialized) { adapter.reloadAssets() } } private data class RuleAssetsProvider( val repoByFileName: Map ) { constructor( geoipRepo: String, geositeRepo: String = geoipRepo, ) : this( mapOf( "geoip.db" to geoipRepo, "geosite.db" to geositeRepo, ) ) } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ui/BackupFragment.kt ================================================ package io.nekohasekai.sagernet.ui import android.content.Intent import android.net.Uri import android.os.Bundle import android.os.Parcel import android.os.Parcelable import android.provider.OpenableColumns import android.view.View import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AlertDialog import androidx.core.content.FileProvider import androidx.core.view.isVisible import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.jakewharton.processphoenix.ProcessPhoenix import io.nekohasekai.sagernet.BuildConfig import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.SagerNet import io.nekohasekai.sagernet.bg.Executable import io.nekohasekai.sagernet.database.* import io.nekohasekai.sagernet.database.preference.KeyValuePair import io.nekohasekai.sagernet.database.preference.PublicDatabase import io.nekohasekai.sagernet.databinding.LayoutBackupBinding import io.nekohasekai.sagernet.databinding.LayoutImportBinding import io.nekohasekai.sagernet.databinding.LayoutProgressBinding import io.nekohasekai.sagernet.ktx.* import kotlinx.coroutines.delay import moe.matsuri.nb4a.utils.Util import org.json.JSONArray import org.json.JSONObject import java.io.File import java.util.* class BackupFragment : NamedFragment(R.layout.layout_backup) { override fun name0() = app.getString(R.string.backup) var content = "" private val exportSettings = registerForActivityResult(ActivityResultContracts.CreateDocument()) { data -> if (data != null) { runOnDefaultDispatcher { try { requireActivity().contentResolver.openOutputStream( data )!!.bufferedWriter().use { it.write(content) } onMainDispatcher { snackbar(getString(R.string.action_export_msg)).show() } } catch (e: Exception) { Logs.w(e) onMainDispatcher { snackbar(e.readableMessage).show() } } } } } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) val binding = LayoutBackupBinding.bind(view) binding.resetSettings.setOnClickListener { MaterialAlertDialogBuilder(requireContext()).setTitle(R.string.confirm) .setMessage(R.string.reset_settings_message) .setNegativeButton(R.string.no, null) .setPositiveButton(R.string.yes) { _, _ -> DataStore.configurationStore.reset() triggerFullRestart(requireContext()) } .show() } binding.actionExport.setOnClickListener { runOnDefaultDispatcher { content = doBackup( binding.backupConfigurations.isChecked, binding.backupRules.isChecked, binding.backupSettings.isChecked ) onMainDispatcher { startFilesForResult( exportSettings, "nekobox_backup_${Date().toLocaleString()}.json" ) } } } binding.actionShare.setOnClickListener { runOnDefaultDispatcher { content = doBackup( binding.backupConfigurations.isChecked, binding.backupRules.isChecked, binding.backupSettings.isChecked ) app.cacheDir.mkdirs() val cacheFile = File( app.cacheDir, "nekobox_backup_${Date().toLocaleString()}.json" ) cacheFile.writeText(content) onMainDispatcher { startActivity( Intent.createChooser( Intent(Intent.ACTION_SEND).setType("application/json") .setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) .putExtra( Intent.EXTRA_STREAM, FileProvider.getUriForFile( app, BuildConfig.APPLICATION_ID + ".cache", cacheFile ) ), app.getString(R.string.abc_shareactionprovider_share_with) ) ) } } } binding.actionImportFile.setOnClickListener { startFilesForResult(importFile, "*/*") } } fun Parcelable.toBase64Str(): String { val parcel = Parcel.obtain() writeToParcel(parcel, 0) try { return Util.b64EncodeUrlSafe(parcel.marshall()) } finally { parcel.recycle() } } fun doBackup(profile: Boolean, rule: Boolean, setting: Boolean): String { val out = JSONObject().apply { put("version", 1) if (profile) { put("profiles", JSONArray().apply { SagerDatabase.proxyDao.getAll().forEach { put(it.toBase64Str()) } }) put("groups", JSONArray().apply { SagerDatabase.groupDao.allGroups().forEach { put(it.toBase64Str()) } }) } if (rule) { put("rules", JSONArray().apply { SagerDatabase.rulesDao.allRules().forEach { put(it.toBase64Str()) } }) } if (setting) { put("settings", JSONArray().apply { PublicDatabase.kvPairDao.all().forEach { put(it.toBase64Str()) } }) } } return out.toStringPretty() } val importFile = registerForActivityResult(ActivityResultContracts.GetContent()) { file -> if (file != null) { runOnDefaultDispatcher { startImport(file) } } } suspend fun startImport(file: Uri) { val fileName = requireContext().contentResolver.query(file, null, null, null, null) ?.use { cursor -> cursor.moveToFirst() cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME).let(cursor::getString) } ?.takeIf { it.isNotBlank() } ?: file.pathSegments.last() .substringAfterLast('/') .substringAfter(':') if (!fileName.endsWith(".json")) { onMainDispatcher { snackbar(getString(R.string.backup_not_file, fileName)).show() } return } suspend fun invalid() = onMainDispatcher { onMainDispatcher { snackbar(getString(R.string.invalid_backup_file)).show() } } val content = try { JSONObject((requireContext().contentResolver.openInputStream(file) ?: return).use { it.bufferedReader().readText() }) } catch (e: Exception) { Logs.w(e) invalid() return } val version = content.optInt("version", 0) if (version < 1 || version > 1) { invalid() return } onMainDispatcher { val import = LayoutImportBinding.inflate(layoutInflater) if (!content.has("profiles")) { import.backupConfigurations.isVisible = false } if (!content.has("rules")) { import.backupRules.isVisible = false } if (!content.has("settings")) { import.backupSettings.isVisible = false } MaterialAlertDialogBuilder(requireContext()).setTitle(R.string.backup_import) .setView(import.root) .setPositiveButton(R.string.backup_import) { _, _ -> SagerNet.stopService() val binding = LayoutProgressBinding.inflate(layoutInflater) binding.content.text = getString(R.string.backup_importing) val dialog = AlertDialog.Builder(requireContext()) .setView(binding.root) .setCancelable(false) .show() runOnDefaultDispatcher { runCatching { finishImport( content, import.backupConfigurations.isChecked, import.backupRules.isChecked, import.backupSettings.isChecked ) triggerFullRestart(requireContext()) }.onFailure { Logs.w(it) onMainDispatcher { alert(it.readableMessage).tryToShow() } } onMainDispatcher { dialog.dismiss() } } } .setNegativeButton(android.R.string.cancel, null) .show() } } fun finishImport( content: JSONObject, profile: Boolean, rule: Boolean, setting: Boolean ) { if (profile && content.has("profiles")) { val profiles = mutableListOf() val jsonProfiles = content.getJSONArray("profiles") for (i in 0 until jsonProfiles.length()) { val data = Util.b64Decode(jsonProfiles[i] as String) val parcel = Parcel.obtain() parcel.unmarshall(data, 0, data.size) parcel.setDataPosition(0) profiles.add(ProxyEntity.CREATOR.createFromParcel(parcel)) parcel.recycle() } SagerDatabase.proxyDao.reset() SagerDatabase.proxyDao.insert(profiles) val groups = mutableListOf() val jsonGroups = content.getJSONArray("groups") for (i in 0 until jsonGroups.length()) { val data = Util.b64Decode(jsonGroups[i] as String) val parcel = Parcel.obtain() parcel.unmarshall(data, 0, data.size) parcel.setDataPosition(0) groups.add(ProxyGroup.CREATOR.createFromParcel(parcel)) parcel.recycle() } SagerDatabase.groupDao.reset() SagerDatabase.groupDao.insert(groups) } if (rule && content.has("rules")) { val rules = mutableListOf() val jsonRules = content.getJSONArray("rules") for (i in 0 until jsonRules.length()) { val data = Util.b64Decode(jsonRules[i] as String) val parcel = Parcel.obtain() parcel.unmarshall(data, 0, data.size) parcel.setDataPosition(0) rules.add(ParcelizeBridge.createRule(parcel)) parcel.recycle() } SagerDatabase.rulesDao.reset() SagerDatabase.rulesDao.insert(rules) } if (setting && content.has("settings")) { val settings = mutableListOf() val jsonSettings = content.getJSONArray("settings") for (i in 0 until jsonSettings.length()) { val data = Util.b64Decode(jsonSettings[i] as String) val parcel = Parcel.obtain() parcel.unmarshall(data, 0, data.size) parcel.setDataPosition(0) settings.add(KeyValuePair.CREATOR.createFromParcel(parcel)) parcel.recycle() } PublicDatabase.kvPairDao.reset() PublicDatabase.kvPairDao.insert(settings) } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ui/BlankActivity.kt ================================================ package io.nekohasekai.sagernet.ui import android.os.Bundle import androidx.appcompat.app.AppCompatActivity import moe.matsuri.nb4a.utils.SendLog class BlankActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // process crash log intent?.getStringExtra("sendLog")?.apply { SendLog.sendLog(this@BlankActivity, this) } finish() } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt ================================================ package io.nekohasekai.sagernet.ui import android.annotation.SuppressLint import android.content.Intent import android.graphics.Color import android.os.Bundle import android.os.SystemClock import android.provider.OpenableColumns import android.text.SpannableStringBuilder import android.text.Spanned.SPAN_EXCLUSIVE_EXCLUSIVE import android.text.format.Formatter import android.text.style.ForegroundColorSpan import android.view.KeyEvent import android.view.LayoutInflater import android.view.MenuItem import android.view.View import android.view.ViewGroup import android.widget.ImageView import android.widget.LinearLayout import android.widget.TextView import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.widget.PopupMenu import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.Toolbar import androidx.core.net.toUri import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.core.view.size import androidx.fragment.app.Fragment import androidx.preference.PreferenceDataStore import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.viewpager2.adapter.FragmentStateAdapter import androidx.viewpager2.widget.ViewPager2 import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayoutMediator import io.nekohasekai.sagernet.GroupOrder import io.nekohasekai.sagernet.GroupType import io.nekohasekai.sagernet.Key import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.SagerNet import io.nekohasekai.sagernet.aidl.TrafficData import io.nekohasekai.sagernet.bg.BaseService import io.nekohasekai.sagernet.bg.proto.UrlTest import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.database.GroupManager import io.nekohasekai.sagernet.database.ProfileManager import io.nekohasekai.sagernet.database.ProxyEntity import io.nekohasekai.sagernet.database.ProxyGroup import io.nekohasekai.sagernet.database.SagerDatabase import io.nekohasekai.sagernet.database.preference.OnPreferenceDataStoreChangeListener import io.nekohasekai.sagernet.databinding.LayoutProfileListBinding import io.nekohasekai.sagernet.databinding.LayoutProgressListBinding import io.nekohasekai.sagernet.fmt.AbstractBean import io.nekohasekai.sagernet.fmt.toUniversalLink import io.nekohasekai.sagernet.group.GroupUpdater import io.nekohasekai.sagernet.group.RawUpdater import io.nekohasekai.sagernet.ktx.FixedLinearLayoutManager import io.nekohasekai.sagernet.ktx.Logs import io.nekohasekai.sagernet.ktx.SubscriptionFoundException import io.nekohasekai.sagernet.ktx.alert import io.nekohasekai.sagernet.ktx.app import io.nekohasekai.sagernet.ktx.dp2px import io.nekohasekai.sagernet.ktx.getColorAttr import io.nekohasekai.sagernet.ktx.getColour import io.nekohasekai.sagernet.ktx.isIpAddress import io.nekohasekai.sagernet.ktx.onMainDispatcher import io.nekohasekai.sagernet.ktx.readableMessage import io.nekohasekai.sagernet.ktx.runOnDefaultDispatcher import io.nekohasekai.sagernet.ktx.runOnLifecycleDispatcher import io.nekohasekai.sagernet.ktx.runOnMainDispatcher import io.nekohasekai.sagernet.ktx.scrollTo import io.nekohasekai.sagernet.ktx.showAllowingStateLoss import io.nekohasekai.sagernet.ktx.snackbar import io.nekohasekai.sagernet.ktx.startFilesForResult import io.nekohasekai.sagernet.ktx.tryToShow import io.nekohasekai.sagernet.plugin.PluginManager import io.nekohasekai.sagernet.ui.profile.ChainSettingsActivity import io.nekohasekai.sagernet.ui.profile.HttpSettingsActivity import io.nekohasekai.sagernet.ui.profile.HysteriaSettingsActivity import io.nekohasekai.sagernet.ui.profile.MieruSettingsActivity import io.nekohasekai.sagernet.ui.profile.NaiveSettingsActivity import io.nekohasekai.sagernet.ui.profile.SSHSettingsActivity import io.nekohasekai.sagernet.ui.profile.ShadowsocksSettingsActivity import io.nekohasekai.sagernet.ui.profile.SocksSettingsActivity import io.nekohasekai.sagernet.ui.profile.TrojanGoSettingsActivity import io.nekohasekai.sagernet.ui.profile.TrojanSettingsActivity import io.nekohasekai.sagernet.ui.profile.TuicSettingsActivity import io.nekohasekai.sagernet.ui.profile.VMessSettingsActivity import io.nekohasekai.sagernet.ui.profile.WireGuardSettingsActivity import io.nekohasekai.sagernet.widget.QRCodeDialog import io.nekohasekai.sagernet.widget.UndoSnackbarManager import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.isActive import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import moe.matsuri.nb4a.Protocols import moe.matsuri.nb4a.Protocols.getProtocolColor import moe.matsuri.nb4a.proxy.anytls.AnyTLSSettingsActivity import moe.matsuri.nb4a.proxy.config.ConfigSettingActivity import moe.matsuri.nb4a.proxy.shadowtls.ShadowTLSSettingsActivity import moe.matsuri.nb4a.ui.ConnectionTestNotification import okhttp3.internal.closeQuietly import java.net.InetSocketAddress import java.net.Socket import java.net.UnknownHostException import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentLinkedQueue import java.util.concurrent.atomic.AtomicInteger import java.util.zip.ZipInputStream class ConfigurationFragment @JvmOverloads constructor( val select: Boolean = false, val selectedItem: ProxyEntity? = null, val titleRes: Int = 0 ) : ToolbarFragment(R.layout.layout_group_list), PopupMenu.OnMenuItemClickListener, Toolbar.OnMenuItemClickListener, SearchView.OnQueryTextListener, OnPreferenceDataStoreChangeListener { interface SelectCallback { fun returnProfile(profileId: Long) } lateinit var adapter: GroupPagerAdapter lateinit var tabLayout: TabLayout lateinit var groupPager: ViewPager2 val alwaysShowAddress by lazy { DataStore.alwaysShowAddress } fun getCurrentGroupFragment(): GroupFragment? { return try { childFragmentManager.findFragmentByTag("f" + DataStore.selectedGroup) as GroupFragment? } catch (e: Exception) { Logs.e(e) null } } val updateSelectedCallback = object : ViewPager2.OnPageChangeCallback() { override fun onPageScrolled( position: Int, positionOffset: Float, positionOffsetPixels: Int ) { if (adapter.groupList.size > position) { DataStore.selectedGroup = adapter.groupList[position].id } } } override fun onQueryTextChange(query: String): Boolean { getCurrentGroupFragment()?.adapter?.filter(query) return false } override fun onQueryTextSubmit(query: String): Boolean = false @SuppressLint("DetachAndAttachSameFragment") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) if (savedInstanceState != null) { parentFragmentManager.beginTransaction() .setReorderingAllowed(false) .detach(this) .attach(this) .commit() } } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) if (!select) { toolbar.inflateMenu(R.menu.add_profile_menu) toolbar.setOnMenuItemClickListener(this) } else { toolbar.setTitle(titleRes) toolbar.setNavigationIcon(R.drawable.ic_navigation_close) toolbar.setNavigationOnClickListener { requireActivity().finish() } } val searchView = toolbar.findViewById(R.id.action_search) if (searchView != null) { searchView.setOnQueryTextListener(this) searchView.maxWidth = Int.MAX_VALUE searchView.setOnQueryTextFocusChangeListener { _, hasFocus -> if (!hasFocus) { cancelSearch(searchView) } } } groupPager = view.findViewById(R.id.group_pager) tabLayout = view.findViewById(R.id.group_tab) adapter = GroupPagerAdapter() ProfileManager.addListener(adapter) GroupManager.addListener(adapter) groupPager.adapter = adapter groupPager.offscreenPageLimit = 2 TabLayoutMediator(tabLayout, groupPager) { tab, position -> if (adapter.groupList.size > position) { tab.text = adapter.groupList[position].displayName() } tab.view.setOnLongClickListener { // clear toast true } }.attach() toolbar.setOnClickListener { val fragment = getCurrentGroupFragment() if (fragment != null) { val selectedProxy = selectedItem?.id ?: DataStore.selectedProxy val selectedProfileIndex = fragment.adapter!!.configurationIdList.indexOf(selectedProxy) if (selectedProfileIndex != -1) { val layoutManager = fragment.layoutManager val first = layoutManager.findFirstVisibleItemPosition() val last = layoutManager.findLastVisibleItemPosition() if (selectedProfileIndex !in first..last) { fragment.configurationListView.scrollTo(selectedProfileIndex, true) return@setOnClickListener } } fragment.configurationListView.scrollTo(0) } } DataStore.profileCacheStore.registerChangeListener(this) } override fun onPreferenceDataStoreChanged(store: PreferenceDataStore, key: String) { runOnMainDispatcher { // editingGroup if (key == Key.PROFILE_GROUP) { val targetId = DataStore.editingGroup if (targetId > 0 && targetId != DataStore.selectedGroup) { DataStore.selectedGroup = targetId val targetIndex = adapter.groupList.indexOfFirst { it.id == targetId } if (targetIndex >= 0) { groupPager.setCurrentItem(targetIndex, false) } else { adapter.reload() } } } } } override fun onDestroy() { DataStore.profileCacheStore.unregisterChangeListener(this) if (::adapter.isInitialized) { GroupManager.removeListener(adapter) ProfileManager.removeListener(adapter) } super.onDestroy() } override fun onKeyDown(ketCode: Int, event: KeyEvent): Boolean { val fragment = getCurrentGroupFragment() fragment?.configurationListView?.apply { if (!hasFocus()) requestFocus() } return super.onKeyDown(ketCode, event) } private val importFile = registerForActivityResult(ActivityResultContracts.GetContent()) { file -> if (file != null) runOnDefaultDispatcher { try { val fileName = requireContext().contentResolver.query(file, null, null, null, null) ?.use { cursor -> cursor.moveToFirst() cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME) .let(cursor::getString) } val proxies = mutableListOf() if (fileName != null && fileName.endsWith(".zip")) { // try parse wireguard zip val zip = ZipInputStream(requireContext().contentResolver.openInputStream(file)!!) while (true) { val entry = zip.nextEntry ?: break if (entry.isDirectory) continue val fileText = zip.bufferedReader().readText() RawUpdater.parseRaw(fileText, entry.name) ?.let { pl -> proxies.addAll(pl) } zip.closeEntry() } zip.closeQuietly() } else { val fileText = requireContext().contentResolver.openInputStream(file)!!.use { it.bufferedReader().readText() } RawUpdater.parseRaw(fileText, fileName ?: "") ?.let { pl -> proxies.addAll(pl) } } if (proxies.isEmpty()) onMainDispatcher { snackbar(getString(R.string.no_proxies_found_in_file)).show() } else import(proxies) } catch (e: SubscriptionFoundException) { (requireActivity() as MainActivity).importSubscription(e.link.toUri()) } catch (e: Exception) { Logs.w(e) onMainDispatcher { snackbar(e.readableMessage).show() } } } } suspend fun import(proxies: List) { val targetId = DataStore.selectedGroupForImport() for (proxy in proxies) { ProfileManager.createProfile(targetId, proxy) } onMainDispatcher { DataStore.editingGroup = targetId snackbar( requireContext().resources.getQuantityString( R.plurals.added, proxies.size, proxies.size ) ).show() } } override fun onMenuItemClick(item: MenuItem): Boolean { when (item.itemId) { R.id.action_scan_qr_code -> { startActivity(Intent(context, ScannerActivity::class.java)) } R.id.action_import_clipboard -> { val text = SagerNet.getClipboardText() if (text.isBlank()) { snackbar(getString(R.string.clipboard_empty)).show() } else runOnDefaultDispatcher { try { val proxies = RawUpdater.parseRaw(text) if (proxies.isNullOrEmpty()) onMainDispatcher { snackbar(getString(R.string.no_proxies_found_in_clipboard)).show() } else import(proxies) } catch (e: SubscriptionFoundException) { (requireActivity() as MainActivity).importSubscription(e.link.toUri()) } catch (e: Exception) { Logs.w(e) onMainDispatcher { snackbar(e.readableMessage).show() } } } } R.id.action_import_file -> { startFilesForResult(importFile, "*/*") } R.id.action_new_socks -> { startActivity(Intent(requireActivity(), SocksSettingsActivity::class.java)) } R.id.action_new_http -> { startActivity(Intent(requireActivity(), HttpSettingsActivity::class.java)) } R.id.action_new_ss -> { startActivity(Intent(requireActivity(), ShadowsocksSettingsActivity::class.java)) } R.id.action_new_vmess -> { startActivity(Intent(requireActivity(), VMessSettingsActivity::class.java)) } R.id.action_new_vless -> { startActivity(Intent(requireActivity(), VMessSettingsActivity::class.java).apply { putExtra("vless", true) }) } R.id.action_new_trojan -> { startActivity(Intent(requireActivity(), TrojanSettingsActivity::class.java)) } R.id.action_new_trojan_go -> { startActivity(Intent(requireActivity(), TrojanGoSettingsActivity::class.java)) } R.id.action_new_mieru -> { startActivity(Intent(requireActivity(), MieruSettingsActivity::class.java)) } R.id.action_new_naive -> { startActivity(Intent(requireActivity(), NaiveSettingsActivity::class.java)) } R.id.action_new_hysteria -> { startActivity(Intent(requireActivity(), HysteriaSettingsActivity::class.java)) } R.id.action_new_tuic -> { startActivity(Intent(requireActivity(), TuicSettingsActivity::class.java)) } R.id.action_new_ssh -> { startActivity(Intent(requireActivity(), SSHSettingsActivity::class.java)) } R.id.action_new_wg -> { startActivity(Intent(requireActivity(), WireGuardSettingsActivity::class.java)) } R.id.action_new_shadowtls -> { startActivity(Intent(requireActivity(), ShadowTLSSettingsActivity::class.java)) } R.id.action_new_anytls -> { startActivity(Intent(requireActivity(), AnyTLSSettingsActivity::class.java)) } R.id.action_new_config -> { startActivity(Intent(requireActivity(), ConfigSettingActivity::class.java)) } R.id.action_new_chain -> { startActivity(Intent(requireActivity(), ChainSettingsActivity::class.java)) } R.id.action_update_subscription -> { val group = DataStore.currentGroup() if (group.type != GroupType.SUBSCRIPTION) { snackbar(R.string.group_not_subscription).show() Logs.e("onMenuItemClick: Group(${group.displayName()}) is not subscription") } else { runOnLifecycleDispatcher { GroupUpdater.startUpdate(group, true) } } } R.id.action_clear_traffic_statistics -> { runOnDefaultDispatcher { val profiles = SagerDatabase.proxyDao.getByGroup(DataStore.currentGroupId()) val toClear = mutableListOf() if (profiles.isNotEmpty()) for (profile in profiles) { if (profile.tx != 0L || profile.rx != 0L) { profile.tx = 0 profile.rx = 0 toClear.add(profile) } } if (toClear.isNotEmpty()) { ProfileManager.updateProfile(toClear) } } } R.id.action_connection_test_clear_results -> { runOnDefaultDispatcher { val profiles = SagerDatabase.proxyDao.getByGroup(DataStore.currentGroupId()) val toClear = mutableListOf() if (profiles.isNotEmpty()) for (profile in profiles) { if (profile.status != 0) { profile.status = 0 profile.ping = 0 profile.error = null toClear.add(profile) } } if (toClear.isNotEmpty()) { ProfileManager.updateProfile(toClear) } } } R.id.action_connection_test_delete_unavailable -> { runOnDefaultDispatcher { val profiles = SagerDatabase.proxyDao.getByGroup(DataStore.currentGroupId()) val toClear = mutableListOf() if (profiles.isNotEmpty()) for (profile in profiles) { if (profile.status != 0 && profile.status != 1) { toClear.add(profile) } } if (toClear.isNotEmpty()) { onMainDispatcher { MaterialAlertDialogBuilder(requireContext()).setTitle(R.string.confirm) .setMessage(R.string.delete_confirm_prompt) .setPositiveButton(R.string.yes) { _, _ -> for (profile in toClear) { adapter.groupFragments[DataStore.selectedGroup]?.adapter?.apply { val index = configurationIdList.indexOf(profile.id) if (index >= 0) { configurationIdList.removeAt(index) configurationList.remove(profile.id) notifyItemRemoved(index) } } } runOnDefaultDispatcher { for (profile in toClear) { ProfileManager.deleteProfile2( profile.groupId, profile.id ) } } } .setNegativeButton(R.string.no, null) .show() } } } } R.id.action_remove_duplicate -> { runOnDefaultDispatcher { val profiles = SagerDatabase.proxyDao.getByGroup(DataStore.currentGroupId()) val toClear = mutableListOf() val uniqueProxies = LinkedHashSet() for (pf in profiles) { val proxy = Protocols.Deduplication(pf.requireBean(), pf.displayType()) if (!uniqueProxies.add(proxy)) { toClear += pf } } if (toClear.isNotEmpty()) { onMainDispatcher { MaterialAlertDialogBuilder(requireContext()).setTitle(R.string.confirm) .setMessage( getString(R.string.delete_confirm_prompt) + "\n" + toClear.mapIndexedNotNull { index, proxyEntity -> if (index < 20) { proxyEntity.displayName() } else if (index == 20) { "......" } else { null } }.joinToString("\n") ) .setPositiveButton(R.string.yes) { _, _ -> for (profile in toClear) { adapter.groupFragments[DataStore.selectedGroup]?.adapter?.apply { val index = configurationIdList.indexOf(profile.id) if (index >= 0) { configurationIdList.removeAt(index) configurationList.remove(profile.id) notifyItemRemoved(index) } } } runOnDefaultDispatcher { for (profile in toClear) { ProfileManager.deleteProfile2( profile.groupId, profile.id ) } } } .setNegativeButton(R.string.no, null) .show() } } } } R.id.action_connection_tcp_ping -> { pingTest(false) } R.id.action_connection_url_test -> { urlTest() } } return true } inner class TestDialog { val binding = LayoutProgressListBinding.inflate(layoutInflater) val builder = MaterialAlertDialogBuilder(requireContext()).setView(binding.root) .setPositiveButton(R.string.minimize) { _, _ -> minimize() } .setNegativeButton(android.R.string.cancel) { _, _ -> cancel() } .setCancelable(false) lateinit var cancel: () -> Unit lateinit var minimize: () -> Unit val dialogStatus = AtomicInteger(0) // 1: hidden 2: cancelled var notification: ConnectionTestNotification? = null val results: MutableSet = ConcurrentHashMap.newKeySet() var proxyN = 0 val finishedN = AtomicInteger(0) fun update(profile: ProxyEntity) { if (dialogStatus.get() != 2) { results.add(profile) } runOnMainDispatcher { val context = context ?: return@runOnMainDispatcher val progress = finishedN.addAndGet(1) val status = dialogStatus.get() notification?.updateNotification( progress, proxyN, progress >= proxyN || status == 2 ) if (status >= 1) return@runOnMainDispatcher if (!isAdded) return@runOnMainDispatcher // refresh dialog var profileStatusText: String? = null var profileStatusColor = 0 when (profile.status) { -1 -> { profileStatusText = profile.error profileStatusColor = context.getColorAttr(android.R.attr.textColorSecondary) } 0 -> { profileStatusText = getString(R.string.connection_test_testing) profileStatusColor = context.getColorAttr(android.R.attr.textColorSecondary) } 1 -> { profileStatusText = getString(R.string.available, profile.ping) profileStatusColor = context.getColour(R.color.material_green_500) } 2 -> { profileStatusText = profile.error profileStatusColor = context.getColour(R.color.material_red_500) } 3 -> { val err = profile.error ?: "" val msg = Protocols.genFriendlyMsg(err) profileStatusText = if (msg != err) msg else getString(R.string.unavailable) profileStatusColor = context.getColour(R.color.material_red_500) } } val text = SpannableStringBuilder().apply { append("\n" + profile.displayName()) append("\n") append( profile.displayType(), ForegroundColorSpan(context.getProtocolColor(profile.type)), SPAN_EXCLUSIVE_EXCLUSIVE ) append(" ") append( profileStatusText, ForegroundColorSpan(profileStatusColor), SPAN_EXCLUSIVE_EXCLUSIVE ) append("\n") } binding.nowTesting.text = text binding.progress.text = "$progress / $proxyN" } } } @OptIn(DelicateCoroutinesApi::class) @Suppress("EXPERIMENTAL_API_USAGE") fun pingTest(icmpPing: Boolean) { if (DataStore.runningTest) return else DataStore.runningTest = true val test = TestDialog() val dialog = test.builder.show() val testJobs = mutableListOf() val group = DataStore.currentGroup() val mainJob = runOnDefaultDispatcher { val profilesList = SagerDatabase.proxyDao.getByGroup(group.id).filter { if (icmpPing) { if (it.requireBean().canICMPing()) { return@filter true } } else { if (it.requireBean().canTCPing()) { return@filter true } } return@filter false } test.proxyN = profilesList.size val profiles = ConcurrentLinkedQueue(profilesList) repeat(DataStore.connectionTestConcurrent) { testJobs.add(launch(Dispatchers.IO) { while (isActive) { val profile = profiles.poll() ?: break profile.status = 0 var address = profile.requireBean().serverAddress if (!address.isIpAddress()) { try { SagerNet.underlyingNetwork!!.getAllByName(address).apply { if (isNotEmpty()) { address = this[0].hostAddress } } } catch (ignored: UnknownHostException) { } } if (!isActive) break if (!address.isIpAddress()) { profile.status = 2 profile.error = app.getString(R.string.connection_test_domain_not_found) test.update(profile) continue } try { if (icmpPing) { // removed } else { val socket = SagerNet.underlyingNetwork?.socketFactory?.createSocket() ?: Socket() try { socket.soTimeout = 3000 socket.bind(InetSocketAddress(0)) val start = SystemClock.elapsedRealtime() socket.connect( InetSocketAddress( address, profile.requireBean().serverPort ), 3000 ) if (!isActive) break profile.status = 1 profile.ping = (SystemClock.elapsedRealtime() - start).toInt() test.update(profile) } finally { socket.closeQuietly() } } } catch (e: Exception) { if (!isActive) break val message = e.readableMessage if (icmpPing) { profile.status = 2 profile.error = getString(R.string.connection_test_unreachable) } else { profile.status = 2 when { !message.contains("failed:") -> profile.error = getString(R.string.connection_test_timeout) else -> when { message.contains("ECONNREFUSED") -> { profile.error = getString(R.string.connection_test_refused) } message.contains("ENETUNREACH") -> { profile.error = getString(R.string.connection_test_unreachable) } else -> { profile.status = 3 profile.error = message } } } } test.update(profile) } } }) } testJobs.joinAll() runOnMainDispatcher { test.cancel() } } test.cancel = { test.dialogStatus.set(2) dialog.dismiss() runOnDefaultDispatcher { mainJob.cancel() testJobs.forEach { it.cancel() } test.results.forEach { try { ProfileManager.updateProfile(it) } catch (e: Exception) { Logs.w(e) } } GroupManager.postReload(DataStore.currentGroupId()) DataStore.runningTest = false } } test.minimize = { test.dialogStatus.set(1) test.notification = ConnectionTestNotification( dialog.context, "[${group.displayName()}] ${getString(R.string.connection_test)}" ) dialog.hide() } } @OptIn(DelicateCoroutinesApi::class) fun urlTest() { if (DataStore.runningTest) return else DataStore.runningTest = true val test = TestDialog() val dialog = test.builder.show() val testJobs = mutableListOf() val group = DataStore.currentGroup() val mainJob = runOnDefaultDispatcher { val profilesList = SagerDatabase.proxyDao.getByGroup(group.id) test.proxyN = profilesList.size val profiles = ConcurrentLinkedQueue(profilesList) repeat(DataStore.connectionTestConcurrent) { testJobs.add(launch(Dispatchers.IO) { val urlTest = UrlTest() // note: this is NOT in bg process while (isActive) { val profile = profiles.poll() ?: break profile.status = 0 try { val result = urlTest.doTest(profile) profile.status = 1 profile.ping = result } catch (e: PluginManager.PluginNotFoundException) { profile.status = 2 profile.error = e.readableMessage } catch (e: Exception) { profile.status = 3 profile.error = e.readableMessage } test.update(profile) } }) } testJobs.joinAll() runOnMainDispatcher { test.cancel() } } test.cancel = { test.dialogStatus.set(2) dialog.dismiss() runOnDefaultDispatcher { mainJob.cancel() testJobs.forEach { it.cancel() } test.results.forEach { try { ProfileManager.updateProfile(it) } catch (e: Exception) { Logs.w(e) } } GroupManager.postReload(DataStore.currentGroupId()) DataStore.runningTest = false } } test.minimize = { test.dialogStatus.set(1) test.notification = ConnectionTestNotification( dialog.context, "[${group.displayName()}] ${getString(R.string.connection_test)}" ) dialog.hide() } } inner class GroupPagerAdapter : FragmentStateAdapter(this), ProfileManager.Listener, GroupManager.Listener { var selectedGroupIndex = 0 var groupList: ArrayList = ArrayList() var groupFragments: HashMap = HashMap() fun reload(now: Boolean = false) { if (!select) { groupPager.unregisterOnPageChangeCallback(updateSelectedCallback) } runOnDefaultDispatcher { var newGroupList = ArrayList(SagerDatabase.groupDao.allGroups()) if (newGroupList.isEmpty()) { SagerDatabase.groupDao.createGroup(ProxyGroup(ungrouped = true)) newGroupList = ArrayList(SagerDatabase.groupDao.allGroups()) } newGroupList.find { it.ungrouped }?.let { if (SagerDatabase.proxyDao.countByGroup(it.id) == 0L) { newGroupList.remove(it) } } var selectedGroup = selectedItem?.groupId ?: DataStore.currentGroupId() var set = false if (selectedGroup > 0L) { selectedGroupIndex = newGroupList.indexOfFirst { it.id == selectedGroup } set = true } else if (groupList.size == 1) { selectedGroup = groupList[0].id if (DataStore.selectedGroup != selectedGroup) { DataStore.selectedGroup = selectedGroup } } val runFunc = if (now) activity?.let { it::runOnUiThread } else groupPager::post if (runFunc != null) { runFunc { groupList = newGroupList notifyDataSetChanged() if (set) groupPager.setCurrentItem(selectedGroupIndex, false) val hideTab = groupList.size < 2 tabLayout.isGone = hideTab toolbar.elevation = if (hideTab) 0F else dp2px(4).toFloat() if (!select) { groupPager.registerOnPageChangeCallback(updateSelectedCallback) } } } } } init { reload(true) } override fun getItemCount(): Int { return groupList.size } override fun createFragment(position: Int): Fragment { return GroupFragment().apply { proxyGroup = groupList[position] groupFragments[proxyGroup.id] = this if (position == selectedGroupIndex) { selected = true } } } override fun getItemId(position: Int): Long { return groupList[position].id } override fun containsItem(itemId: Long): Boolean { return groupList.any { it.id == itemId } } override suspend fun groupAdd(group: ProxyGroup) { tabLayout.post { groupList.add(group) if (groupList.any { !it.ungrouped }) tabLayout.post { tabLayout.visibility = View.VISIBLE } notifyItemInserted(groupList.size - 1) tabLayout.getTabAt(groupList.size - 1)?.select() } } override suspend fun groupRemoved(groupId: Long) { val index = groupList.indexOfFirst { it.id == groupId } if (index == -1) return tabLayout.post { groupList.removeAt(index) notifyItemRemoved(index) } } override suspend fun groupUpdated(group: ProxyGroup) { val index = groupList.indexOfFirst { it.id == group.id } if (index == -1) return tabLayout.post { tabLayout.getTabAt(index)?.text = group.displayName() } } override suspend fun groupUpdated(groupId: Long) = Unit override suspend fun onAdd(profile: ProxyEntity) { if (groupList.find { it.id == profile.groupId } == null) { DataStore.selectedGroup = profile.groupId reload() } } override suspend fun onUpdated(data: TrafficData) = Unit override suspend fun onUpdated(profile: ProxyEntity, noTraffic: Boolean) = Unit override suspend fun onRemoved(groupId: Long, profileId: Long) { val group = groupList.find { it.id == groupId } ?: return if (group.ungrouped && SagerDatabase.proxyDao.countByGroup(groupId) == 0L) { reload() } } } class GroupFragment : Fragment() { lateinit var proxyGroup: ProxyGroup var selected = false override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, ): View { return LayoutProfileListBinding.inflate(inflater).root } lateinit var undoManager: UndoSnackbarManager var adapter: ConfigurationAdapter? = null override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) if (::proxyGroup.isInitialized) { outState.putParcelable("proxyGroup", proxyGroup) } } override fun onViewStateRestored(savedInstanceState: Bundle?) { super.onViewStateRestored(savedInstanceState) savedInstanceState?.getParcelable("proxyGroup")?.also { proxyGroup = it onViewCreated(requireView(), null) } } private val isEnabled: Boolean get() { return DataStore.serviceState.let { it.canStop || it == BaseService.State.Stopped } } lateinit var layoutManager: LinearLayoutManager lateinit var configurationListView: RecyclerView val select by lazy { try { (parentFragment as ConfigurationFragment).select } catch (e: Exception) { Logs.e(e) false } } val selectedItem by lazy { try { (parentFragment as ConfigurationFragment).selectedItem } catch (e: Exception) { Logs.e(e) null } } override fun onResume() { super.onResume() if (::configurationListView.isInitialized && configurationListView.size == 0) { configurationListView.adapter = adapter runOnDefaultDispatcher { adapter?.reloadProfiles() } } else if (!::configurationListView.isInitialized) { onViewCreated(requireView(), null) } checkOrderMenu() configurationListView.requestFocus() } fun checkOrderMenu() { if (select) return val pf = requireParentFragment() as? ToolbarFragment ?: return val menu = pf.toolbar.menu val origin = menu.findItem(R.id.action_order_origin) val byName = menu.findItem(R.id.action_order_by_name) val byDelay = menu.findItem(R.id.action_order_by_delay) when (proxyGroup.order) { GroupOrder.ORIGIN -> { origin.isChecked = true } GroupOrder.BY_NAME -> { byName.isChecked = true } GroupOrder.BY_DELAY -> { byDelay.isChecked = true } } fun updateTo(order: Int) { if (proxyGroup.order == order) return runOnDefaultDispatcher { proxyGroup.order = order GroupManager.updateGroup(proxyGroup) } } origin.setOnMenuItemClickListener { it.isChecked = true updateTo(GroupOrder.ORIGIN) true } byName.setOnMenuItemClickListener { it.isChecked = true updateTo(GroupOrder.BY_NAME) true } byDelay.setOnMenuItemClickListener { it.isChecked = true updateTo(GroupOrder.BY_DELAY) true } } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { if (!::proxyGroup.isInitialized) return configurationListView = view.findViewById(R.id.configuration_list) layoutManager = FixedLinearLayoutManager(configurationListView) configurationListView.layoutManager = layoutManager adapter = ConfigurationAdapter() ProfileManager.addListener(adapter!!) GroupManager.addListener(adapter!!) configurationListView.adapter = adapter configurationListView.setItemViewCacheSize(20) if (!select) { undoManager = UndoSnackbarManager(activity as MainActivity, adapter!!) ItemTouchHelper(object : ItemTouchHelper.SimpleCallback( ItemTouchHelper.UP or ItemTouchHelper.DOWN, ItemTouchHelper.START ) { override fun getSwipeDirs( recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, ): Int { return 0 } override fun getDragDirs( recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, ) = if (isEnabled) super.getDragDirs(recyclerView, viewHolder) else 0 override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { } override fun onMove( recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder, ): Boolean { adapter?.move( viewHolder.bindingAdapterPosition, target.bindingAdapterPosition ) return true } override fun clearView( recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, ) { super.clearView(recyclerView, viewHolder) adapter?.commitMove() } }).attachToRecyclerView(configurationListView) } } override fun onDestroy() { adapter?.let { ProfileManager.removeListener(it) GroupManager.removeListener(it) } super.onDestroy() if (!::undoManager.isInitialized) return undoManager.flush() } inner class ConfigurationAdapter : RecyclerView.Adapter(), ProfileManager.Listener, GroupManager.Listener, UndoSnackbarManager.Interface { init { setHasStableIds(true) } var configurationIdList: MutableList = mutableListOf() val configurationList = HashMap() private fun getItem(profileId: Long): ProxyEntity { var profile = configurationList[profileId] if (profile == null) { profile = ProfileManager.getProfile(profileId) if (profile != null) { configurationList[profileId] = profile } } return profile!! } private fun getItemAt(index: Int) = getItem(configurationIdList[index]) override fun onCreateViewHolder( parent: ViewGroup, viewType: Int, ): ConfigurationHolder { return ConfigurationHolder( LayoutInflater.from(parent.context) .inflate(R.layout.layout_profile, parent, false) ) } override fun getItemId(position: Int): Long { return configurationIdList[position] } override fun onBindViewHolder(holder: ConfigurationHolder, position: Int) { try { holder.bind(getItemAt(position)) } catch (ignored: NullPointerException) { // when group deleted } } override fun getItemCount(): Int { return configurationIdList.size } private val updated = HashSet() fun filter(name: String) { if (name.isEmpty()) { reloadProfiles() return } configurationIdList.clear() val lower = name.lowercase() configurationIdList.addAll(configurationList.filter { it.value.displayName().lowercase().contains(lower) || it.value.displayType().lowercase().contains(lower) || it.value.displayAddress().lowercase().contains(lower) }.keys) notifyDataSetChanged() } fun move(from: Int, to: Int) { val first = getItemAt(from) var previousOrder = first.userOrder val (step, range) = if (from < to) Pair(1, from until to) else Pair( -1, to + 1 downTo from ) for (i in range) { val next = getItemAt(i + step) val order = next.userOrder next.userOrder = previousOrder previousOrder = order configurationIdList[i] = next.id updated.add(next) } first.userOrder = previousOrder configurationIdList[to] = first.id updated.add(first) notifyItemMoved(from, to) } fun commitMove() = runOnDefaultDispatcher { updated.forEach { SagerDatabase.proxyDao.updateProxy(it) } updated.clear() } fun remove(pos: Int) { if (pos < 0) return configurationIdList.removeAt(pos) notifyItemRemoved(pos) } override fun undo(actions: List>) { for ((index, item) in actions) { configurationListView.post { configurationList[item.id] = item configurationIdList.add(index, item.id) notifyItemInserted(index) } } } override fun commit(actions: List>) { val profiles = actions.map { it.second } runOnDefaultDispatcher { for (entity in profiles) { ProfileManager.deleteProfile(entity.groupId, entity.id) } } } override suspend fun onAdd(profile: ProxyEntity) { if (profile.groupId != proxyGroup.id) return configurationListView.post { if (::undoManager.isInitialized) { undoManager.flush() } val pos = itemCount configurationList[profile.id] = profile configurationIdList.add(profile.id) notifyItemInserted(pos) } } override suspend fun onUpdated(profile: ProxyEntity, noTraffic: Boolean) { if (profile.groupId != proxyGroup.id) return val index = configurationIdList.indexOf(profile.id) if (index < 0) return configurationListView.post { if (::undoManager.isInitialized) { undoManager.flush() } configurationList[profile.id] = profile notifyItemChanged(index) // val oldProfile = configurationList[profile.id] if (noTraffic && oldProfile != null) { runOnDefaultDispatcher { onUpdated( TrafficData( id = profile.id, rx = oldProfile.rx, tx = oldProfile.tx ) ) } } } } override suspend fun onUpdated(data: TrafficData) { try { val index = configurationIdList.indexOf(data.id) if (index != -1) { val holder = layoutManager.findViewByPosition(index) ?.let { configurationListView.getChildViewHolder(it) } as ConfigurationHolder? if (holder != null) { onMainDispatcher { holder.bind(holder.entity, data) } } } } catch (e: Exception) { Logs.w(e) } } override suspend fun onRemoved(groupId: Long, profileId: Long) { if (groupId != proxyGroup.id) return val index = configurationIdList.indexOf(profileId) if (index < 0) return configurationListView.post { configurationIdList.removeAt(index) configurationList.remove(profileId) notifyItemRemoved(index) } } override suspend fun groupAdd(group: ProxyGroup) = Unit override suspend fun groupRemoved(groupId: Long) = Unit override suspend fun groupUpdated(group: ProxyGroup) { if (group.id != proxyGroup.id) return proxyGroup = group reloadProfiles() } override suspend fun groupUpdated(groupId: Long) { if (groupId != proxyGroup.id) return proxyGroup = SagerDatabase.groupDao.getById(groupId)!! reloadProfiles() } fun reloadProfiles() { var newProfiles = SagerDatabase.proxyDao.getByGroup(proxyGroup.id) when (proxyGroup.order) { GroupOrder.BY_NAME -> { newProfiles = newProfiles.sortedBy { it.displayName() } } GroupOrder.BY_DELAY -> { newProfiles = newProfiles.sortedBy { if (it.status == 1) it.ping else 114514 } } } configurationList.clear() configurationList.putAll(newProfiles.associateBy { it.id }) val newProfileIds = newProfiles.map { it.id } var selectedProfileIndex = -1 if (selected) { val selectedProxy = selectedItem?.id ?: DataStore.selectedProxy selectedProfileIndex = newProfileIds.indexOf(selectedProxy) } configurationListView.post { configurationIdList.clear() configurationIdList.addAll(newProfileIds) notifyDataSetChanged() if (selectedProfileIndex != -1) { configurationListView.scrollTo(selectedProfileIndex, true) } else if (newProfiles.isNotEmpty()) { configurationListView.scrollTo(0, true) } } } } val profileAccess = Mutex() val reloadAccess = Mutex() inner class ConfigurationHolder(val view: View) : RecyclerView.ViewHolder(view), PopupMenu.OnMenuItemClickListener { lateinit var entity: ProxyEntity val profileName: TextView = view.findViewById(R.id.profile_name) val profileType: TextView = view.findViewById(R.id.profile_type) val profileAddress: TextView = view.findViewById(R.id.profile_address) val profileStatus: TextView = view.findViewById(R.id.profile_status) val trafficText: TextView = view.findViewById(R.id.traffic_text) val selectedView: LinearLayout = view.findViewById(R.id.selected_view) val editButton: ImageView = view.findViewById(R.id.edit) val shareLayout: LinearLayout = view.findViewById(R.id.share) val shareLayer: LinearLayout = view.findViewById(R.id.share_layer) val shareButton: ImageView = view.findViewById(R.id.shareIcon) val removeButton: ImageView = view.findViewById(R.id.remove) fun bind(proxyEntity: ProxyEntity, trafficData: TrafficData? = null) { val pf = parentFragment as? ConfigurationFragment ?: return entity = proxyEntity if (select) { view.setOnClickListener { (requireActivity() as SelectCallback).returnProfile(proxyEntity.id) } } else { view.setOnClickListener { runOnDefaultDispatcher { var update: Boolean var lastSelected: Long profileAccess.withLock { update = DataStore.selectedProxy != proxyEntity.id lastSelected = DataStore.selectedProxy DataStore.selectedProxy = proxyEntity.id onMainDispatcher { selectedView.visibility = View.VISIBLE } } if (update) { ProfileManager.postUpdate(lastSelected) if (DataStore.serviceState.canStop && reloadAccess.tryLock()) { SagerNet.reloadService() reloadAccess.unlock() } } else if (SagerNet.isTv) { if (DataStore.serviceState.started) { SagerNet.stopService() } else { SagerNet.startService() } } } } } profileName.text = proxyEntity.displayName() profileType.text = proxyEntity.displayType() profileType.setTextColor(requireContext().getProtocolColor(proxyEntity.type)) var rx = proxyEntity.rx var tx = proxyEntity.tx if (trafficData != null) { // use new data tx = trafficData.tx rx = trafficData.rx } val showTraffic = rx + tx != 0L trafficText.isVisible = showTraffic if (showTraffic) { trafficText.text = view.context.getString( R.string.traffic, Formatter.formatFileSize(view.context, tx), Formatter.formatFileSize(view.context, rx) ) } var address = proxyEntity.displayAddress() if (showTraffic && address.length >= 30) { address = address.substring(0, 27) + "..." } if (proxyEntity.requireBean().name.isBlank() || !pf.alwaysShowAddress) { address = "" } profileAddress.text = address (trafficText.parent as View).isGone = (!showTraffic || proxyEntity.status <= 0) && address.isBlank() if (proxyEntity.status <= 0) { if (showTraffic) { profileStatus.text = trafficText.text profileStatus.setTextColor(requireContext().getColorAttr(android.R.attr.textColorSecondary)) trafficText.text = "" } else { profileStatus.text = "" } } else if (proxyEntity.status == 1) { profileStatus.text = getString(R.string.available, proxyEntity.ping) profileStatus.setTextColor(requireContext().getColour(R.color.material_green_500)) } else { profileStatus.setTextColor(requireContext().getColour(R.color.material_red_500)) if (proxyEntity.status == 2) { profileStatus.text = proxyEntity.error } } if (proxyEntity.status == 3) { val err = proxyEntity.error ?: "" val msg = Protocols.genFriendlyMsg(err) profileStatus.text = if (msg != err) msg else getString(R.string.unavailable) profileStatus.setOnClickListener { alert(err).tryToShow() } } else { profileStatus.setOnClickListener(null) } editButton.setOnClickListener { it.context.startActivity( proxyEntity.settingIntent( it.context, proxyGroup.type == GroupType.SUBSCRIPTION ) ) } removeButton.setOnClickListener { adapter?.let { val index = it.configurationIdList.indexOf(proxyEntity.id) it.remove(index) undoManager.remove(index to proxyEntity) } } val selectOrChain = select || proxyEntity.type == ProxyEntity.TYPE_CHAIN shareLayout.isGone = selectOrChain editButton.isGone = select removeButton.isGone = select proxyEntity.nekoBean?.apply { shareLayout.isGone = true } runOnDefaultDispatcher { val selected = (selectedItem?.id ?: DataStore.selectedProxy) == proxyEntity.id val started = selected && DataStore.serviceState.started && DataStore.currentProfile == proxyEntity.id onMainDispatcher { editButton.isEnabled = !started removeButton.isEnabled = !started selectedView.visibility = if (selected) View.VISIBLE else View.INVISIBLE } fun showShare(anchor: View) { val popup = PopupMenu(requireContext(), anchor) popup.menuInflater.inflate(R.menu.profile_share_menu, popup.menu) when { !proxyEntity.haveStandardLink() -> { popup.menu.findItem(R.id.action_group_qr).subMenu?.removeItem(R.id.action_standard_qr) popup.menu.findItem(R.id.action_group_clipboard).subMenu?.removeItem( R.id.action_standard_clipboard ) } !proxyEntity.haveLink() -> { popup.menu.removeItem(R.id.action_group_qr) popup.menu.removeItem(R.id.action_group_clipboard) } } if (proxyEntity.nekoBean != null) { popup.menu.removeItem(R.id.action_group_configuration) } popup.setOnMenuItemClickListener(this@ConfigurationHolder) popup.show() } if (!(select || proxyEntity.type == ProxyEntity.TYPE_CHAIN)) { onMainDispatcher { shareLayer.setBackgroundColor(Color.TRANSPARENT) shareButton.setImageResource(R.drawable.ic_social_share) shareButton.setColorFilter(Color.GRAY) shareButton.isVisible = true shareLayout.setOnClickListener { showShare(it) } } } } } var currentName = "" fun showCode(link: String) { QRCodeDialog(link, currentName).showAllowingStateLoss(parentFragmentManager) } fun export(link: String) { val success = SagerNet.trySetPrimaryClip(link) (activity as MainActivity).snackbar(if (success) R.string.action_export_msg else R.string.action_export_err) .show() } override fun onMenuItemClick(item: MenuItem): Boolean { try { currentName = entity.displayName()!! when (item.itemId) { R.id.action_standard_qr -> showCode(entity.toStdLink()) R.id.action_standard_clipboard -> export(entity.toStdLink()) R.id.action_universal_qr -> showCode(entity.requireBean().toUniversalLink()) R.id.action_universal_clipboard -> export( entity.requireBean().toUniversalLink() ) R.id.action_config_export_clipboard -> export(entity.exportConfig().first) R.id.action_config_export_file -> { val cfg = entity.exportConfig() DataStore.serverConfig = cfg.first startFilesForResult( (parentFragment as ConfigurationFragment).exportConfig, cfg.second ) } } } catch (e: Exception) { Logs.w(e) (activity as MainActivity).snackbar(e.readableMessage).show() return true } return true } } } private val exportConfig = registerForActivityResult(ActivityResultContracts.CreateDocument()) { data -> if (data != null) { runOnDefaultDispatcher { try { (requireActivity() as MainActivity).contentResolver.openOutputStream(data)!! .bufferedWriter() .use { it.write(DataStore.serverConfig) } onMainDispatcher { snackbar(getString(R.string.action_export_msg)).show() } } catch (e: Exception) { Logs.w(e) onMainDispatcher { snackbar(e.readableMessage).show() } } } } } private fun cancelSearch(searchView: SearchView) { searchView.onActionViewCollapsed() searchView.clearFocus() } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ui/GroupFragment.kt ================================================ package io.nekohasekai.sagernet.ui import android.content.Intent import android.os.Bundle import android.text.format.Formatter import android.view.MenuItem import android.view.View import android.view.ViewGroup import android.widget.LinearLayout import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.widget.PopupMenu import androidx.appcompat.widget.Toolbar import androidx.core.view.* import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.google.android.material.dialog.MaterialAlertDialogBuilder import io.nekohasekai.sagernet.GroupType import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.SagerNet import io.nekohasekai.sagernet.database.* import io.nekohasekai.sagernet.databinding.LayoutGroupItemBinding import io.nekohasekai.sagernet.fmt.toUniversalLink import io.nekohasekai.sagernet.group.GroupUpdater import io.nekohasekai.sagernet.ktx.* import io.nekohasekai.sagernet.widget.ListListener import io.nekohasekai.sagernet.widget.QRCodeDialog import io.nekohasekai.sagernet.widget.UndoSnackbarManager import kotlinx.coroutines.delay import moe.matsuri.nb4a.utils.Util import moe.matsuri.nb4a.utils.toBytesString import java.lang.NumberFormatException import java.util.* class GroupFragment : ToolbarFragment(R.layout.layout_group), Toolbar.OnMenuItemClickListener { lateinit var activity: MainActivity lateinit var groupListView: RecyclerView lateinit var layoutManager: LinearLayoutManager lateinit var groupAdapter: GroupAdapter lateinit var undoManager: UndoSnackbarManager override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) activity = requireActivity() as MainActivity ViewCompat.setOnApplyWindowInsetsListener(view, ListListener) toolbar.setTitle(R.string.menu_group) toolbar.inflateMenu(R.menu.add_group_menu) toolbar.setOnMenuItemClickListener(this) groupListView = view.findViewById(R.id.group_list) layoutManager = FixedLinearLayoutManager(groupListView) groupListView.layoutManager = layoutManager groupAdapter = GroupAdapter() GroupManager.addListener(groupAdapter) groupListView.adapter = groupAdapter undoManager = UndoSnackbarManager(activity, groupAdapter) ItemTouchHelper(object : ItemTouchHelper.SimpleCallback( ItemTouchHelper.UP or ItemTouchHelper.DOWN, ItemTouchHelper.START ) { override fun getSwipeDirs( recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder ): Int { val proxyGroup = (viewHolder as GroupHolder).proxyGroup if (proxyGroup.ungrouped || proxyGroup.id in GroupUpdater.updating) { return 0 } return super.getSwipeDirs(recyclerView, viewHolder) } override fun getDragDirs( recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder ): Int { val proxyGroup = (viewHolder as GroupHolder).proxyGroup if (proxyGroup.ungrouped || proxyGroup.id in GroupUpdater.updating) { return 0 } return super.getDragDirs(recyclerView, viewHolder) } override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { val index = viewHolder.bindingAdapterPosition groupAdapter.remove(index) undoManager.remove(index to (viewHolder as GroupHolder).proxyGroup) } override fun onMove( recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder, ): Boolean { groupAdapter.move(viewHolder.bindingAdapterPosition, target.bindingAdapterPosition) return true } override fun clearView( recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, ) { super.clearView(recyclerView, viewHolder) groupAdapter.commitMove() } }).attachToRecyclerView(groupListView) } override fun onMenuItemClick(item: MenuItem): Boolean { when (item.itemId) { R.id.action_new_group -> { startActivity(Intent(context, GroupSettingsActivity::class.java)) } R.id.action_update_all -> { MaterialAlertDialogBuilder(requireContext()).setTitle(R.string.confirm) .setMessage(R.string.update_all_subscription) .setPositiveButton(R.string.yes) { _, _ -> SagerDatabase.groupDao.allGroups() .filter { it.type == GroupType.SUBSCRIPTION } .forEach { GroupUpdater.startUpdate(it, true) } } .setNegativeButton(R.string.no, null) .show() } } return true } private lateinit var selectedGroup: ProxyGroup private val exportProfiles = registerForActivityResult(ActivityResultContracts.CreateDocument()) { data -> if (data != null) { runOnDefaultDispatcher { val profiles = SagerDatabase.proxyDao.getByGroup(selectedGroup.id) val links = profiles.joinToString("\n") { it.toStdLink(compact = true) } try { (requireActivity() as MainActivity).contentResolver.openOutputStream( data )!!.bufferedWriter().use { it.write(links) } onMainDispatcher { snackbar(getString(R.string.action_export_msg)).show() } } catch (e: Exception) { Logs.w(e) onMainDispatcher { snackbar(e.readableMessage).show() } } } } } inner class GroupAdapter : RecyclerView.Adapter(), GroupManager.Listener, UndoSnackbarManager.Interface { val groupList = ArrayList() suspend fun reload() { val groups = SagerDatabase.groupDao.allGroups().toMutableList() if (groups.size > 1 && SagerDatabase.proxyDao.countByGroup(groups.find { it.ungrouped }!!.id) == 0L) groups.removeAll { it.ungrouped } groupList.clear() groupList.addAll(groups) groupListView.post { notifyDataSetChanged() } } init { setHasStableIds(true) runOnDefaultDispatcher { reload() } } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GroupHolder { return GroupHolder(LayoutGroupItemBinding.inflate(layoutInflater, parent, false)) } override fun onBindViewHolder(holder: GroupHolder, position: Int) { holder.bind(groupList[position]) } override fun getItemCount(): Int { return groupList.size } override fun getItemId(position: Int): Long { return groupList[position].id } private val updated = HashSet() fun move(from: Int, to: Int) { val first = groupList[from] var previousOrder = first.userOrder val (step, range) = if (from < to) Pair(1, from until to) else Pair( -1, to + 1 downTo from ) for (i in range) { val next = groupList[i + step] val order = next.userOrder next.userOrder = previousOrder previousOrder = order groupList[i] = next updated.add(next) } first.userOrder = previousOrder groupList[to] = first updated.add(first) notifyItemMoved(from, to) } fun commitMove() = runOnDefaultDispatcher { updated.forEach { SagerDatabase.groupDao.updateGroup(it) } updated.clear() } fun remove(index: Int) { groupList.removeAt(index) notifyItemRemoved(index) } override fun undo(actions: List>) { for ((index, item) in actions) { groupList.add(index, item) notifyItemInserted(index) } } override fun commit(actions: List>) { val groups = actions.map { it.second } runOnDefaultDispatcher { GroupManager.deleteGroup(groups) reload() } } override suspend fun groupAdd(group: ProxyGroup) { groupList.add(group) delay(300L) onMainDispatcher { undoManager.flush() notifyItemInserted(groupList.size - 1) if (group.type == GroupType.SUBSCRIPTION) { GroupUpdater.startUpdate(group, true) } } } override suspend fun groupRemoved(groupId: Long) { val index = groupList.indexOfFirst { it.id == groupId } if (index == -1) return onMainDispatcher { undoManager.flush() if (SagerDatabase.groupDao.allGroups().size <= 2) { runOnDefaultDispatcher { reload() } } else { groupList.removeAt(index) notifyItemRemoved(index) } } } override suspend fun groupUpdated(group: ProxyGroup) { val index = groupList.indexOfFirst { it.id == group.id } if (index == -1) { reload() return } groupList[index] = group onMainDispatcher { undoManager.flush() notifyItemChanged(index) } } override suspend fun groupUpdated(groupId: Long) { val index = groupList.indexOfFirst { it.id == groupId } if (index == -1) { reload() return } onMainDispatcher { notifyItemChanged(index) } } } override fun onDestroy() { if (::groupAdapter.isInitialized) { GroupManager.removeListener(groupAdapter) } super.onDestroy() if (!::undoManager.isInitialized) return undoManager.flush() } inner class GroupHolder(binding: LayoutGroupItemBinding) : RecyclerView.ViewHolder(binding.root), PopupMenu.OnMenuItemClickListener { lateinit var proxyGroup: ProxyGroup val groupName = binding.groupName val groupStatus = binding.groupStatus val groupTraffic = binding.groupTraffic val groupUser = binding.groupUser val editButton = binding.edit val optionsButton = binding.options val updateButton = binding.groupUpdate val subscriptionUpdateProgress = binding.subscriptionUpdateProgress override fun onMenuItemClick(item: MenuItem): Boolean { fun export(link: String) { val success = SagerNet.trySetPrimaryClip(link) activity.snackbar(if (success) R.string.action_export_msg else R.string.action_export_err) .show() } when (item.itemId) { R.id.action_universal_qr -> { QRCodeDialog( proxyGroup.toUniversalLink(), proxyGroup.displayName() ).showAllowingStateLoss(parentFragmentManager) } R.id.action_universal_clipboard -> { export(proxyGroup.toUniversalLink()) } R.id.action_export_clipboard -> { runOnDefaultDispatcher { val profiles = SagerDatabase.proxyDao.getByGroup(selectedGroup.id) val links = profiles.joinToString("\n") { it.toStdLink(compact = true) } onMainDispatcher { SagerNet.trySetPrimaryClip(links) snackbar(getString(R.string.copy_toast_msg)).show() } } } R.id.action_export_file -> { startFilesForResult(exportProfiles, "profiles_${proxyGroup.displayName()}.txt") } R.id.action_clear -> { MaterialAlertDialogBuilder(requireContext()).setTitle(R.string.confirm) .setMessage(R.string.clear_profiles_message) .setPositiveButton(R.string.yes) { _, _ -> runOnDefaultDispatcher { GroupManager.clearGroup(proxyGroup.id) } } .setNegativeButton(android.R.string.cancel, null) .show() } } return true } fun bind(group: ProxyGroup) { proxyGroup = group itemView.setOnClickListener { } editButton.isGone = proxyGroup.ungrouped updateButton.isInvisible = proxyGroup.type != GroupType.SUBSCRIPTION groupName.text = proxyGroup.displayName() editButton.setOnClickListener { startActivity(Intent(it.context, GroupSettingsActivity::class.java).apply { putExtra(GroupSettingsActivity.EXTRA_GROUP_ID, group.id) }) } updateButton.setOnClickListener { GroupUpdater.startUpdate(proxyGroup, true) } optionsButton.setOnClickListener { selectedGroup = proxyGroup val popup = PopupMenu(requireContext(), it) popup.menuInflater.inflate(R.menu.group_action_menu, popup.menu) if (proxyGroup.type != GroupType.SUBSCRIPTION) { popup.menu.removeItem(R.id.action_share_subscription) } popup.setOnMenuItemClickListener(this) popup.show() } if (proxyGroup.id in GroupUpdater.updating) { (groupName.parent as LinearLayout).apply { setPadding(paddingLeft, dp2px(11), paddingRight, paddingBottom) } subscriptionUpdateProgress.isVisible = true if (!GroupUpdater.progress.containsKey(proxyGroup.id)) { subscriptionUpdateProgress.isIndeterminate = true } else { subscriptionUpdateProgress.isIndeterminate = false GroupUpdater.progress[proxyGroup.id]?.let { subscriptionUpdateProgress.max = it.max subscriptionUpdateProgress.progress = it.progress } } updateButton.isInvisible = true editButton.isGone = true } else { (groupName.parent as LinearLayout).apply { setPadding(paddingLeft, dp2px(15), paddingRight, paddingBottom) } subscriptionUpdateProgress.isVisible = false updateButton.isInvisible = proxyGroup.type != GroupType.SUBSCRIPTION editButton.isGone = proxyGroup.ungrouped } val subscription = proxyGroup.subscription if (subscription != null && subscription.bytesUsed > 0L) { // SIP008 & Open Online Config groupTraffic.isVisible = true groupTraffic.text = if (subscription.bytesRemaining > 0L) { app.getString( R.string.subscription_traffic, Formatter.formatFileSize( app, subscription.bytesUsed ), Formatter.formatFileSize( app, subscription.bytesRemaining ) ) } else { app.getString( R.string.subscription_used, Formatter.formatFileSize( app, subscription.bytesUsed ) ) } groupStatus.setPadding(0) } else if (subscription != null && !subscription.subscriptionUserinfo.isNullOrBlank()) { // Raw var text = "" fun get(regex: String): String? { return regex.toRegex().findAll(subscription.subscriptionUserinfo).mapNotNull { if (it.groupValues.size > 1) it.groupValues[1] else null }.firstOrNull() } try { var used: Long = 0 get("upload=([0-9]+)")?.apply { used += toLong() } get("download=([0-9]+)")?.apply { used += toLong() } val total = get("total=([0-9]+)")?.toLong() ?: 0 val remain = total - used if (used > 0 || total > 0) { text += if (remain > 0) { getString( R.string.subscription_traffic, used.toBytesString(), remain.toBytesString() ) } else { getString(R.string.subscription_used, used.toBytesString()) } } get("expire=([0-9]+)")?.apply { text += "\n" text += getString( R.string.subscription_expire, Util.timeStamp2Text(this.toLong() * 1000) ) } } catch (_: NumberFormatException) { // ignore } if (text.isNotEmpty()) { groupTraffic.isVisible = true groupTraffic.text = text groupStatus.setPadding(0) } } else { groupTraffic.isVisible = false groupStatus.setPadding(0, 0, 0, dp2px(4)) } groupUser.text = subscription?.username ?: "" runOnDefaultDispatcher { val size = SagerDatabase.proxyDao.countByGroup(group.id) onMainDispatcher { @Suppress("DEPRECATION") when (group.type) { GroupType.BASIC -> { if (size == 0L) { groupStatus.setText(R.string.group_status_empty) } else { groupStatus.text = getString(R.string.group_status_proxies, size) } } GroupType.SUBSCRIPTION -> { groupStatus.text = if (size == 0L) { getString(R.string.group_status_empty_subscription) } else { val date = Date(group.subscription!!.lastUpdated * 1000L) getString( R.string.group_status_proxies_subscription, size, "${date.month + 1} - ${date.date}" ) } } } } } } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ui/GroupSettingsActivity.kt ================================================ package io.nekohasekai.sagernet.ui import android.annotation.SuppressLint import android.app.Activity import android.content.DialogInterface import android.content.Intent import android.os.Bundle import android.os.Parcelable import android.view.Menu import android.view.MenuItem import android.view.View import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.LayoutRes import androidx.appcompat.app.AlertDialog import androidx.core.view.ViewCompat import androidx.preference.* import com.github.shadowsocks.plugin.Empty import com.github.shadowsocks.plugin.fragment.AlertDialogFragment import io.nekohasekai.sagernet.GroupType import io.nekohasekai.sagernet.Key import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.SagerNet import io.nekohasekai.sagernet.database.* import io.nekohasekai.sagernet.database.preference.OnPreferenceDataStoreChangeListener import io.nekohasekai.sagernet.ktx.Logs import io.nekohasekai.sagernet.ktx.applyDefaultValues import io.nekohasekai.sagernet.ktx.onMainDispatcher import io.nekohasekai.sagernet.ktx.runOnDefaultDispatcher import io.nekohasekai.sagernet.widget.ListListener import io.nekohasekai.sagernet.widget.OutboundPreference import kotlinx.parcelize.Parcelize import moe.matsuri.nb4a.ui.SimpleMenuPreference @Suppress("UNCHECKED_CAST") class GroupSettingsActivity( @LayoutRes resId: Int = R.layout.layout_config_settings, ) : ThemedActivity(resId), OnPreferenceDataStoreChangeListener { private lateinit var frontProxyPreference: OutboundPreference private lateinit var landingProxyPreference: OutboundPreference fun ProxyGroup.init() { DataStore.groupName = name ?: "" DataStore.groupType = type DataStore.groupOrder = order DataStore.groupIsSelector = isSelector DataStore.frontProxy = frontProxy DataStore.landingProxy = landingProxy DataStore.frontProxyTmp = if (frontProxy >= 0) 3 else 0 DataStore.landingProxyTmp = if (landingProxy >= 0) 3 else 0 val subscription = subscription ?: SubscriptionBean().applyDefaultValues() DataStore.subscriptionLink = subscription.link DataStore.subscriptionForceResolve = subscription.forceResolve DataStore.subscriptionDeduplication = subscription.deduplication DataStore.subscriptionUpdateWhenConnectedOnly = subscription.updateWhenConnectedOnly DataStore.subscriptionUserAgent = subscription.customUserAgent DataStore.subscriptionAutoUpdate = subscription.autoUpdate DataStore.subscriptionAutoUpdateDelay = subscription.autoUpdateDelay } fun ProxyGroup.serialize() { name = DataStore.groupName.takeIf { it.isNotBlank() } ?: "My group" type = DataStore.groupType order = DataStore.groupOrder isSelector = DataStore.groupIsSelector frontProxy = if (DataStore.frontProxyTmp == 3) DataStore.frontProxy else -1 landingProxy = if (DataStore.landingProxyTmp == 3) DataStore.landingProxy else -1 val isSubscription = type == GroupType.SUBSCRIPTION if (isSubscription) { subscription = (subscription ?: SubscriptionBean().applyDefaultValues()).apply { link = DataStore.subscriptionLink forceResolve = DataStore.subscriptionForceResolve deduplication = DataStore.subscriptionDeduplication updateWhenConnectedOnly = DataStore.subscriptionUpdateWhenConnectedOnly customUserAgent = DataStore.subscriptionUserAgent autoUpdate = DataStore.subscriptionAutoUpdate autoUpdateDelay = DataStore.subscriptionAutoUpdateDelay } } } fun needSave(): Boolean { if (!DataStore.dirty) return false return true } fun PreferenceFragmentCompat.createPreferences( savedInstanceState: Bundle?, rootKey: String?, ) { addPreferencesFromResource(R.xml.group_preferences) frontProxyPreference = findPreference(Key.GROUP_FRONT_PROXY)!! frontProxyPreference.apply { setEntries(R.array.front_proxy_entry) setEntryValues(R.array.front_proxy_value) setOnPreferenceChangeListener { _, newValue -> if (newValue.toString() == "3") { selectProfileForAddFront.launch( Intent(this@GroupSettingsActivity, ProfileSelectActivity::class.java) ) false } else { true } } } landingProxyPreference = findPreference(Key.GROUP_LANDING_PROXY)!! landingProxyPreference.apply { setEntries(R.array.front_proxy_entry) setEntryValues(R.array.front_proxy_value) setOnPreferenceChangeListener { _, newValue -> if (newValue.toString() == "3") { selectProfileForAddLanding.launch( Intent(this@GroupSettingsActivity, ProfileSelectActivity::class.java) ) false } else { true } } } val groupType = findPreference(Key.GROUP_TYPE)!! val groupSubscription = findPreference(Key.GROUP_SUBSCRIPTION)!! val subscriptionUpdate = findPreference(Key.SUBSCRIPTION_UPDATE)!! fun updateGroupType(groupType: Int = DataStore.groupType) { val isSubscription = groupType == GroupType.SUBSCRIPTION groupSubscription.isVisible = isSubscription subscriptionUpdate.isVisible = isSubscription } updateGroupType() groupType.setOnPreferenceChangeListener { _, newValue -> updateGroupType((newValue as String).toInt()) true } val subscriptionAutoUpdate = findPreference(Key.SUBSCRIPTION_AUTO_UPDATE)!! val subscriptionAutoUpdateDelay = findPreference(Key.SUBSCRIPTION_AUTO_UPDATE_DELAY)!! subscriptionAutoUpdateDelay.isEnabled = subscriptionAutoUpdate.isChecked subscriptionAutoUpdateDelay.setOnPreferenceChangeListener { _, newValue -> val delay = (newValue as String).toIntOrNull() if (delay == null) { false } else { delay >= 15 } } subscriptionAutoUpdate.setOnPreferenceChangeListener { _, newValue -> subscriptionAutoUpdateDelay.isEnabled = (newValue as Boolean) true } } class UnsavedChangesDialogFragment : AlertDialogFragment() { override fun AlertDialog.Builder.prepare(listener: DialogInterface.OnClickListener) { setTitle(R.string.unsaved_changes_prompt) setPositiveButton(R.string.yes) { _, _ -> runOnDefaultDispatcher { (requireActivity() as GroupSettingsActivity).saveAndExit() } } setNegativeButton(R.string.no) { _, _ -> requireActivity().finish() } setNeutralButton(android.R.string.cancel, null) } } @Parcelize data class GroupIdArg(val groupId: Long) : Parcelable class DeleteConfirmationDialogFragment : AlertDialogFragment() { override fun AlertDialog.Builder.prepare(listener: DialogInterface.OnClickListener) { setTitle(R.string.delete_group_prompt) setPositiveButton(R.string.yes) { _, _ -> runOnDefaultDispatcher { GroupManager.deleteGroup(arg.groupId) } requireActivity().finish() } setNegativeButton(R.string.no, null) } } companion object { const val EXTRA_GROUP_ID = "id" } @SuppressLint("CommitTransaction") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setSupportActionBar(findViewById(R.id.toolbar)) supportActionBar?.apply { setTitle(R.string.group_settings) setDisplayHomeAsUpEnabled(true) setHomeAsUpIndicator(R.drawable.ic_navigation_close) } if (savedInstanceState == null) { val editingId = intent.getLongExtra(EXTRA_GROUP_ID, 0L) DataStore.editingId = editingId runOnDefaultDispatcher { if (editingId == 0L) { ProxyGroup().init() } else { val entity = SagerDatabase.groupDao.getById(editingId) if (entity == null) { onMainDispatcher { finish() } return@runOnDefaultDispatcher } entity.init() } onMainDispatcher { supportFragmentManager.beginTransaction() .replace(R.id.settings, MyPreferenceFragmentCompat()) .commit() DataStore.dirty = false DataStore.profileCacheStore.registerChangeListener(this@GroupSettingsActivity) } } } } suspend fun saveAndExit() { val editingId = DataStore.editingId if (editingId == 0L) { GroupManager.createGroup(ProxyGroup().apply { serialize() }) } else if (needSave()) { val entity = SagerDatabase.groupDao.getById(DataStore.editingId) if (entity == null) { finish() return } val keepUserInfo = (entity.type == GroupType.SUBSCRIPTION && DataStore.groupType == GroupType.SUBSCRIPTION && entity.subscription?.link == DataStore.subscriptionLink) if (!keepUserInfo) { entity.subscription?.subscriptionUserinfo = ""; } GroupManager.updateGroup(entity.apply { serialize() }) } finish() } val child by lazy { supportFragmentManager.findFragmentById(R.id.settings) as MyPreferenceFragmentCompat } override fun onCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.profile_config_menu, menu) return true } override fun onOptionsItemSelected(item: MenuItem) = child.onOptionsItemSelected(item) override fun onBackPressed() { if (needSave()) { UnsavedChangesDialogFragment().apply { key() }.show(supportFragmentManager, null) } else super.onBackPressed() } override fun onSupportNavigateUp(): Boolean { if (!super.onSupportNavigateUp()) finish() return true } override fun onDestroy() { DataStore.profileCacheStore.unregisterChangeListener(this) super.onDestroy() } override fun onPreferenceDataStoreChanged(store: PreferenceDataStore, key: String) { if (key != Key.PROFILE_DIRTY) { DataStore.dirty = true } } class MyPreferenceFragmentCompat : PreferenceFragmentCompat() { var activity: GroupSettingsActivity? = null override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { preferenceManager.preferenceDataStore = DataStore.profileCacheStore try { activity = (requireActivity() as GroupSettingsActivity).apply { createPreferences(savedInstanceState, rootKey) } } catch (e: Exception) { Toast.makeText( SagerNet.application, "Error on createPreferences, please try again.", Toast.LENGTH_SHORT ).show() Logs.e(e) } } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) ViewCompat.setOnApplyWindowInsetsListener(listView, ListListener) } override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) { R.id.action_delete -> { if (DataStore.editingId == 0L) { requireActivity().finish() } else { DeleteConfirmationDialogFragment().apply { arg(GroupIdArg(DataStore.editingId)) key() }.show(parentFragmentManager, null) } true } R.id.action_apply -> { runOnDefaultDispatcher { activity?.saveAndExit() } true } else -> false } } object PasswordSummaryProvider : Preference.SummaryProvider { override fun provideSummary(preference: EditTextPreference): CharSequence { val text = preference.text return if (text.isNullOrBlank()) { preference.context.getString(androidx.preference.R.string.not_set) } else { "\u2022".repeat(text.length) } } } val selectProfileForAddFront = registerForActivityResult( ActivityResultContracts.StartActivityForResult() ) { if (it.resultCode == Activity.RESULT_OK) runOnDefaultDispatcher { val profile = ProfileManager.getProfile( it.data!!.getLongExtra(ProfileSelectActivity.EXTRA_PROFILE_ID, 0) ) ?: return@runOnDefaultDispatcher DataStore.frontProxy = profile.id onMainDispatcher { frontProxyPreference.value = "3" } } } val selectProfileForAddLanding = registerForActivityResult( ActivityResultContracts.StartActivityForResult() ) { if (it.resultCode == Activity.RESULT_OK) runOnDefaultDispatcher { val profile = ProfileManager.getProfile( it.data!!.getLongExtra(ProfileSelectActivity.EXTRA_PROFILE_ID, 0) ) ?: return@runOnDefaultDispatcher DataStore.landingProxy = profile.id onMainDispatcher { landingProxyPreference.value = "3" } } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ui/LogcatFragment.kt ================================================ package io.nekohasekai.sagernet.ui import android.annotation.SuppressLint import android.graphics.Color import android.os.Build import android.os.Bundle import android.text.SpannableString import android.text.Spanned.SPAN_EXCLUSIVE_EXCLUSIVE import android.text.style.ForegroundColorSpan import android.view.MenuItem import android.view.View import android.view.ViewGroup import androidx.appcompat.widget.Toolbar import androidx.core.view.ViewCompat import androidx.core.view.doOnLayout import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.databinding.LayoutLogcatBinding import io.nekohasekai.sagernet.ktx.* import io.nekohasekai.sagernet.widget.ListListener import libcore.Libcore import moe.matsuri.nb4a.utils.SendLog class LogcatFragment : ToolbarFragment(R.layout.layout_logcat), Toolbar.OnMenuItemClickListener { lateinit var binding: LayoutLogcatBinding @SuppressLint("RestrictedApi", "WrongConstant") override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) toolbar.setTitle(R.string.menu_log) toolbar.inflateMenu(R.menu.logcat_menu) toolbar.setOnMenuItemClickListener(this) binding = LayoutLogcatBinding.bind(view) if (Build.VERSION.SDK_INT >= 23) { binding.textview.breakStrategy = 0 // simple } ViewCompat.setOnApplyWindowInsetsListener(binding.root, ListListener) reloadSession() } private fun getColorForLine(line: String): ForegroundColorSpan { var color = ForegroundColorSpan(Color.GRAY) when { line.contains("INFO[") || line.contains(" [Info]") -> { color = ForegroundColorSpan((0xFF86C166).toInt()) } line.contains("ERROR[") || line.contains(" [Error]") -> { color = ForegroundColorSpan(Color.RED) } line.contains("WARN[") || line.contains(" [Warning]") -> { color = ForegroundColorSpan(Color.RED) } } return color } private fun reloadSession() { val span = SpannableString( String(SendLog.getNekoLog(50 * 1024)) ) var offset = 0 for (line in span.lines()) { val color = getColorForLine(line) span.setSpan( color, offset, offset + line.length, SPAN_EXCLUSIVE_EXCLUSIVE ) offset += line.length + 1 } binding.textview.text = span binding.textview.clearFocus() // 等 textview 完成最终 layout 再滚动到底部 binding.textview.doOnLayout { binding.scroolview.scrollTo(0, binding.textview.height) } } override fun onMenuItemClick(item: MenuItem): Boolean { when (item.itemId) { R.id.action_clear_logcat -> { runOnDefaultDispatcher { try { Libcore.nekoLogClear() Runtime.getRuntime().exec("/system/bin/logcat -c") } catch (e: Exception) { onMainDispatcher { snackbar(e.readableMessage).show() } return@runOnDefaultDispatcher } onMainDispatcher { binding.textview.text = "" } } } R.id.action_send_logcat -> { val context = requireContext() runOnDefaultDispatcher { SendLog.sendLog(context, "NB4A") } } R.id.action_refresh -> { reloadSession() } } return true } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ui/MainActivity.kt ================================================ package io.nekohasekai.sagernet.ui import android.Manifest.permission.POST_NOTIFICATIONS import android.annotation.SuppressLint import android.content.Intent import android.content.pm.PackageManager import android.net.Uri import android.os.Build import android.os.Bundle import android.os.RemoteException import android.view.KeyEvent import android.view.MenuItem import androidx.activity.addCallback import androidx.annotation.IdRes import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import androidx.preference.PreferenceDataStore import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.navigation.NavigationView import com.google.android.material.snackbar.Snackbar import io.nekohasekai.sagernet.BuildConfig import io.nekohasekai.sagernet.GroupType import io.nekohasekai.sagernet.Key import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.SagerNet import io.nekohasekai.sagernet.aidl.ISagerNetService import io.nekohasekai.sagernet.aidl.SpeedDisplayData import io.nekohasekai.sagernet.aidl.TrafficData import io.nekohasekai.sagernet.bg.BaseService import io.nekohasekai.sagernet.bg.SagerConnection import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.database.GroupManager import io.nekohasekai.sagernet.database.ProfileManager import io.nekohasekai.sagernet.database.ProxyGroup import io.nekohasekai.sagernet.database.SubscriptionBean import io.nekohasekai.sagernet.database.preference.OnPreferenceDataStoreChangeListener import io.nekohasekai.sagernet.databinding.LayoutMainBinding import io.nekohasekai.sagernet.fmt.AbstractBean import io.nekohasekai.sagernet.fmt.KryoConverters import io.nekohasekai.sagernet.fmt.PluginEntry import io.nekohasekai.sagernet.group.GroupInterfaceAdapter import io.nekohasekai.sagernet.group.GroupUpdater import io.nekohasekai.sagernet.ktx.alert import io.nekohasekai.sagernet.ktx.isPlay import io.nekohasekai.sagernet.ktx.isPreview import io.nekohasekai.sagernet.ktx.launchCustomTab import io.nekohasekai.sagernet.ktx.onMainDispatcher import io.nekohasekai.sagernet.ktx.parseProxies import io.nekohasekai.sagernet.ktx.readableMessage import io.nekohasekai.sagernet.ktx.runOnDefaultDispatcher import moe.matsuri.nb4a.utils.Util class MainActivity : ThemedActivity(), SagerConnection.Callback, OnPreferenceDataStoreChangeListener, NavigationView.OnNavigationItemSelectedListener { lateinit var binding: LayoutMainBinding lateinit var navigation: NavigationView override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = LayoutMainBinding.inflate(layoutInflater) binding.fab.initProgress(binding.fabProgress) if (themeResId !in intArrayOf( R.style.Theme_SagerNet_Black ) ) { navigation = binding.navView binding.drawerLayout.removeView(binding.navViewBlack) } else { navigation = binding.navViewBlack binding.drawerLayout.removeView(binding.navView) } navigation.setNavigationItemSelectedListener(this) if (savedInstanceState == null) { displayFragmentWithId(R.id.nav_configuration) } onBackPressedDispatcher.addCallback { if (supportFragmentManager.findFragmentById(R.id.fragment_holder) is ConfigurationFragment) { moveTaskToBack(true) } else { displayFragmentWithId(R.id.nav_configuration) } } binding.fab.setOnClickListener { if (DataStore.serviceState.canStop) SagerNet.stopService() else connect.launch( null ) } binding.stats.setOnClickListener { if (DataStore.serviceState.connected) binding.stats.testConnection() } setContentView(binding.root) changeState(BaseService.State.Idle) connection.connect(this, this) DataStore.configurationStore.registerChangeListener(this) GroupManager.userInterface = GroupInterfaceAdapter(this) if (intent?.action == Intent.ACTION_VIEW) { onNewIntent(intent) } refreshNavMenu(DataStore.enableClashAPI) // sdk 33 notification if (Build.VERSION.SDK_INT >= 33) { val checkPermission = ContextCompat.checkSelfPermission(this@MainActivity, POST_NOTIFICATIONS) if (checkPermission != PackageManager.PERMISSION_GRANTED) { //动态申请 ActivityCompat.requestPermissions( this@MainActivity, arrayOf(POST_NOTIFICATIONS), 0 ) } } if (isPreview) { MaterialAlertDialogBuilder(this) .setTitle(BuildConfig.PRE_VERSION_NAME) .setMessage(R.string.preview_version_hint) .setPositiveButton(android.R.string.ok, null) .show() } } fun refreshNavMenu(clashApi: Boolean) { if (::navigation.isInitialized) { navigation.menu.findItem(R.id.nav_traffic)?.isVisible = clashApi navigation.menu.findItem(R.id.nav_tuiguang)?.isVisible = !isPlay } } override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) val uri = intent.data ?: return runOnDefaultDispatcher { if (uri.scheme == "sn" && uri.host == "subscription" || uri.scheme == "clash") { importSubscription(uri) } else { importProfile(uri) } } } fun urlTest(): Int { if (!DataStore.serviceState.connected || connection.service == null) { error("not started") } return connection.service!!.urlTest() } suspend fun importSubscription(uri: Uri) { val group: ProxyGroup val url = uri.getQueryParameter("url") if (!url.isNullOrBlank()) { group = ProxyGroup(type = GroupType.SUBSCRIPTION) val subscription = SubscriptionBean() group.subscription = subscription // cleartext format subscription.link = url group.name = uri.getQueryParameter("name") } else { val data = uri.encodedQuery.takeIf { !it.isNullOrBlank() } ?: return try { group = KryoConverters.deserialize( ProxyGroup().apply { export = true }, Util.zlibDecompress(Util.b64Decode(data)) ).apply { export = false } } catch (e: Exception) { onMainDispatcher { alert(e.readableMessage).show() } return } } val name = group.name.takeIf { !it.isNullOrBlank() } ?: group.subscription?.link ?: group.subscription?.token if (name.isNullOrBlank()) return group.name = group.name.takeIf { !it.isNullOrBlank() } ?: ("Subscription #" + System.currentTimeMillis()) onMainDispatcher { displayFragmentWithId(R.id.nav_group) MaterialAlertDialogBuilder(this@MainActivity).setTitle(R.string.subscription_import) .setMessage(getString(R.string.subscription_import_message, name)) .setPositiveButton(R.string.yes) { _, _ -> runOnDefaultDispatcher { finishImportSubscription(group) } } .setNegativeButton(android.R.string.cancel, null) .show() } } private suspend fun finishImportSubscription(subscription: ProxyGroup) { GroupManager.createGroup(subscription) GroupUpdater.startUpdate(subscription, true) } suspend fun importProfile(uri: Uri) { val profile = try { parseProxies(uri.toString()).getOrNull(0) ?: error(getString(R.string.no_proxies_found)) } catch (e: Exception) { onMainDispatcher { alert(e.readableMessage).show() } return } onMainDispatcher { MaterialAlertDialogBuilder(this@MainActivity).setTitle(R.string.profile_import) .setMessage(getString(R.string.profile_import_message, profile.displayName())) .setPositiveButton(R.string.yes) { _, _ -> runOnDefaultDispatcher { finishImportProfile(profile) } } .setNegativeButton(android.R.string.cancel, null) .show() } } private suspend fun finishImportProfile(profile: AbstractBean) { val targetId = DataStore.selectedGroupForImport() ProfileManager.createProfile(targetId, profile) onMainDispatcher { displayFragmentWithId(R.id.nav_configuration) snackbar(resources.getQuantityString(R.plurals.added, 1, 1)).show() } } override fun missingPlugin(profileName: String, pluginName: String) { val pluginEntity = PluginEntry.find(pluginName) // unknown exe or neko plugin if (pluginEntity == null) { snackbar(getString(R.string.plugin_unknown, pluginName)).show() return } // official exe MaterialAlertDialogBuilder(this).setTitle(R.string.missing_plugin) .setMessage( getString( R.string.profile_requiring_plugin, profileName, pluginEntity.displayName ) ) .setPositiveButton(R.string.action_download) { _, _ -> showDownloadDialog(pluginEntity) } .setNeutralButton(android.R.string.cancel, null) .setNeutralButton(R.string.action_learn_more) { _, _ -> launchCustomTab("https://matsuridayo.github.io/nb4a-plugin/") } .show() } private fun showDownloadDialog(pluginEntry: PluginEntry) { var index = 0 var playIndex = -1 var fdroidIndex = -1 val items = mutableListOf() if (pluginEntry.downloadSource.playStore) { items.add(getString(R.string.install_from_play_store)) playIndex = index++ } if (pluginEntry.downloadSource.fdroid) { items.add(getString(R.string.install_from_fdroid)) fdroidIndex = index++ } items.add(getString(R.string.download)) val downloadIndex = index MaterialAlertDialogBuilder(this).setTitle(pluginEntry.name) .setItems(items.toTypedArray()) { _, which -> when (which) { playIndex -> launchCustomTab("https://play.google.com/store/apps/details?id=${pluginEntry.packageName}") fdroidIndex -> launchCustomTab("https://f-droid.org/packages/${pluginEntry.packageName}/") downloadIndex -> launchCustomTab(pluginEntry.downloadSource.downloadLink) } } .show() } override fun onNavigationItemSelected(item: MenuItem): Boolean { if (item.isChecked) binding.drawerLayout.closeDrawers() else { return displayFragmentWithId(item.itemId) } return true } @SuppressLint("CommitTransaction") fun displayFragment(fragment: ToolbarFragment) { if (fragment is ConfigurationFragment) { binding.stats.allowShow = true binding.fab.show() } else if (!DataStore.showBottomBar) { binding.stats.allowShow = false binding.stats.performHide() binding.fab.hide() } supportFragmentManager.beginTransaction() .replace(R.id.fragment_holder, fragment) .commitAllowingStateLoss() binding.drawerLayout.closeDrawers() } fun displayFragmentWithId(@IdRes id: Int): Boolean { when (id) { R.id.nav_configuration -> { displayFragment(ConfigurationFragment()) } R.id.nav_group -> displayFragment(GroupFragment()) R.id.nav_route -> displayFragment(RouteFragment()) R.id.nav_settings -> displayFragment(SettingsFragment()) R.id.nav_traffic -> displayFragment(WebviewFragment()) R.id.nav_tools -> displayFragment(ToolsFragment()) R.id.nav_logcat -> displayFragment(LogcatFragment()) R.id.nav_faq -> { launchCustomTab("https://matsuridayo.github.io/") return false } R.id.nav_about -> displayFragment(AboutFragment()) R.id.nav_tuiguang -> { launchCustomTab("https://neko-box.pages.dev/喵") return false } else -> return false } navigation.menu.findItem(id).isChecked = true return true } private fun changeState( state: BaseService.State, msg: String? = null, animate: Boolean = false, ) { DataStore.serviceState = state binding.fab.changeState(state, DataStore.serviceState, animate) binding.stats.changeState(state) if (msg != null) snackbar(getString(R.string.vpn_error, msg)).show() } override fun snackbarInternal(text: CharSequence): Snackbar { return Snackbar.make(binding.coordinator, text, Snackbar.LENGTH_LONG).apply { if (binding.fab.isShown) { anchorView = binding.fab } // TODO } } override fun stateChanged(state: BaseService.State, profileName: String?, msg: String?) { changeState(state, msg, true) } val connection = SagerConnection(SagerConnection.CONNECTION_ID_MAIN_ACTIVITY_FOREGROUND, true) override fun onServiceConnected(service: ISagerNetService) = changeState( try { BaseService.State.values()[service.state] } catch (_: RemoteException) { BaseService.State.Idle } ) override fun onServiceDisconnected() = changeState(BaseService.State.Idle) override fun onBinderDied() { connection.disconnect(this) connection.connect(this, this) } private val connect = registerForActivityResult(VpnRequestActivity.StartService()) { if (it) snackbar(R.string.vpn_permission_denied).show() } // may NOT called when app is in background // ONLY do UI update here, write DB in bg process override fun cbSpeedUpdate(stats: SpeedDisplayData) { binding.stats.updateSpeed(stats.txRateProxy, stats.rxRateProxy) } override fun cbTrafficUpdate(data: TrafficData) { runOnDefaultDispatcher { ProfileManager.postUpdate(data) } } override fun cbSelectorUpdate(id: Long) { val old = DataStore.selectedProxy DataStore.selectedProxy = id DataStore.currentProfile = id runOnDefaultDispatcher { ProfileManager.postUpdate(old, true) ProfileManager.postUpdate(id, true) } } override fun onPreferenceDataStoreChanged(store: PreferenceDataStore, key: String) { when (key) { Key.SERVICE_MODE -> onBinderDied() Key.PROXY_APPS, Key.BYPASS_MODE, Key.INDIVIDUAL -> { if (DataStore.serviceState.canStop) { snackbar(getString(R.string.need_reload)).setAction(R.string.apply) { SagerNet.reloadService() }.show() } } } } override fun onStart() { connection.updateConnectionId(SagerConnection.CONNECTION_ID_MAIN_ACTIVITY_FOREGROUND) super.onStart() } override fun onStop() { connection.updateConnectionId(SagerConnection.CONNECTION_ID_MAIN_ACTIVITY_BACKGROUND) super.onStop() } override fun onDestroy() { super.onDestroy() GroupManager.userInterface = null DataStore.configurationStore.unregisterChangeListener(this) connection.disconnect(this) } override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { when (keyCode) { KeyEvent.KEYCODE_DPAD_LEFT -> { if (super.onKeyDown(keyCode, event)) return true binding.drawerLayout.open() navigation.requestFocus() } KeyEvent.KEYCODE_DPAD_RIGHT -> { if (binding.drawerLayout.isOpen) { binding.drawerLayout.close() return true } } } if (super.onKeyDown(keyCode, event)) return true if (binding.drawerLayout.isOpen) return false val fragment = supportFragmentManager.findFragmentById(R.id.fragment_holder) as? ToolbarFragment return fragment != null && fragment.onKeyDown(keyCode, event) } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ui/NamedFragment.kt ================================================ package io.nekohasekai.sagernet.ui import androidx.fragment.app.Fragment abstract class NamedFragment : Fragment { constructor() : super() constructor(contentLayoutId: Int) : super(contentLayoutId) private val name by lazy { name0() } fun name() = name protected abstract fun name0(): String } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ui/NetworkFragment.kt ================================================ package io.nekohasekai.sagernet.ui import android.content.Intent import android.os.Bundle import android.view.View import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.databinding.LayoutNetworkBinding import io.nekohasekai.sagernet.ktx.app class NetworkFragment : NamedFragment(R.layout.layout_network) { override fun name0() = app.getString(R.string.tools_network) override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) val binding = LayoutNetworkBinding.bind(view) binding.stunTest.setOnClickListener { startActivity(Intent(requireContext(), StunActivity::class.java)) } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ui/ProfileSelectActivity.kt ================================================ package io.nekohasekai.sagernet.ui import android.content.Intent import android.os.Bundle import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.database.ProxyEntity class ProfileSelectActivity : ThemedActivity(R.layout.layout_empty), ConfigurationFragment.SelectCallback { companion object { const val EXTRA_SELECTED = "selected" const val EXTRA_PROFILE_ID = "id" } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val selected = intent.getParcelableExtra(EXTRA_SELECTED) supportFragmentManager.beginTransaction() .replace( R.id.fragment_holder, ConfigurationFragment(true, selected, R.string.select_profile) ) .commitAllowingStateLoss() } override fun returnProfile(profileId: Long) { setResult(RESULT_OK, Intent().apply { putExtra(EXTRA_PROFILE_ID, profileId) }) finish() } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ui/QuickDisableShortcut.kt ================================================ /******************************************************************************* * * * Copyright (C) 2017 by Max Lv * * Copyright (C) 2017 by Mygod Studio <[Email1]> * * * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . * * * *******************************************************************************/ package io.nekohasekai.sagernet.ui import android.app.Activity import android.content.pm.ShortcutManager import android.os.Build import android.os.Bundle import androidx.core.content.getSystemService import io.nekohasekai.sagernet.SagerNet import io.nekohasekai.sagernet.aidl.ISagerNetService import io.nekohasekai.sagernet.bg.BaseService import io.nekohasekai.sagernet.bg.SagerConnection class QuickDisableShortcut : Activity(), SagerConnection.Callback { private val connection = SagerConnection(SagerConnection.CONNECTION_ID_SHORTCUT) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) connection.connect(this, this) if (Build.VERSION.SDK_INT >= 25) { getSystemService()!!.reportShortcutUsed("disable") } } override fun onServiceConnected(service: ISagerNetService) { val state = BaseService.State.values()[service.state] if (state.canStop) { SagerNet.stopService() } finish() } override fun stateChanged(state: BaseService.State, profileName: String?, msg: String?) {} override fun onDestroy() { connection.disconnect(this) super.onDestroy() } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ui/QuickEnableShortcut.kt ================================================ /******************************************************************************* * * * Copyright (C) 2017 by Max Lv <[Email0]> * * Copyright (C) 2017 by Mygod Studio <[Email1]> * * * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . * * * *******************************************************************************/ package io.nekohasekai.sagernet.ui import android.app.Activity import android.content.pm.ShortcutManager import android.os.Build import android.os.Bundle import androidx.core.content.getSystemService import io.nekohasekai.sagernet.SagerNet import io.nekohasekai.sagernet.aidl.ISagerNetService import io.nekohasekai.sagernet.bg.BaseService import io.nekohasekai.sagernet.bg.SagerConnection class QuickEnableShortcut : Activity(), SagerConnection.Callback { private val connection = SagerConnection(SagerConnection.CONNECTION_ID_SHORTCUT) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) connection.connect(this, this) if (Build.VERSION.SDK_INT >= 25) { getSystemService()!!.reportShortcutUsed("enable") } } override fun onServiceConnected(service: ISagerNetService) { val state = BaseService.State.values()[service.state] if (state == BaseService.State.Stopped) { SagerNet.startService() } finish() } override fun stateChanged(state: BaseService.State, profileName: String?, msg: String?) {} override fun onDestroy() { connection.disconnect(this) super.onDestroy() } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ui/RouteFragment.kt ================================================ package io.nekohasekai.sagernet.ui import android.content.Intent import android.os.Bundle import android.view.MenuItem import android.view.View import android.view.ViewGroup import androidx.appcompat.widget.Toolbar import androidx.core.view.ViewCompat import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.RecyclerView import com.google.android.material.dialog.MaterialAlertDialogBuilder import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.database.ProfileManager import io.nekohasekai.sagernet.database.RuleEntity import io.nekohasekai.sagernet.database.SagerDatabase import io.nekohasekai.sagernet.databinding.LayoutEmptyRouteBinding import io.nekohasekai.sagernet.databinding.LayoutRouteItemBinding import io.nekohasekai.sagernet.ktx.* import io.nekohasekai.sagernet.widget.ListListener import io.nekohasekai.sagernet.widget.UndoSnackbarManager class RouteFragment : ToolbarFragment(R.layout.layout_route), Toolbar.OnMenuItemClickListener { lateinit var activity: MainActivity lateinit var ruleListView: RecyclerView lateinit var ruleAdapter: RuleAdapter lateinit var undoManager: UndoSnackbarManager override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) activity = requireActivity() as MainActivity ViewCompat.setOnApplyWindowInsetsListener(view, ListListener) toolbar.setTitle(R.string.menu_route) toolbar.inflateMenu(R.menu.add_route_menu) toolbar.setOnMenuItemClickListener(this) ruleListView = view.findViewById(R.id.route_list) ruleListView.layoutManager = FixedLinearLayoutManager(ruleListView) ruleAdapter = RuleAdapter() ProfileManager.addListener(ruleAdapter) ruleListView.adapter = ruleAdapter undoManager = UndoSnackbarManager(activity, ruleAdapter) ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP or ItemTouchHelper.DOWN, ItemTouchHelper.START) { override fun getSwipeDirs( recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, ) = if (viewHolder is RuleAdapter.DocumentHolder) { 0 } else { super.getSwipeDirs(recyclerView, viewHolder) } override fun getDragDirs( recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, ) = if (viewHolder is RuleAdapter.DocumentHolder) { 0 } else { super.getDragDirs(recyclerView, viewHolder) } override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { val index = viewHolder.bindingAdapterPosition ruleAdapter.remove(index) undoManager.remove(index to (viewHolder as RuleAdapter.RuleHolder).rule) } override fun onMove( recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder, ): Boolean { return if (target is RuleAdapter.DocumentHolder) { false } else { ruleAdapter.move(viewHolder.bindingAdapterPosition, target.bindingAdapterPosition) true } } override fun clearView( recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, ) { super.clearView(recyclerView, viewHolder) ruleAdapter.commitMove() } }).attachToRecyclerView(ruleListView) } override fun onDestroy() { if (::ruleAdapter.isInitialized) { ProfileManager.removeListener(ruleAdapter) } super.onDestroy() } override fun onMenuItemClick(item: MenuItem): Boolean { when (item.itemId) { R.id.action_new_route -> { startActivity(Intent(context, RouteSettingsActivity::class.java)) } R.id.action_reset_route -> { MaterialAlertDialogBuilder(activity).setTitle(R.string.confirm) .setMessage(R.string.clear_profiles_message) .setPositiveButton(R.string.yes) { _, _ -> runOnDefaultDispatcher { SagerDatabase.rulesDao.reset() DataStore.rulesFirstCreate = false ruleAdapter.reload() } } .setNegativeButton(R.string.no, null) .show() } R.id.action_manage_assets -> { startActivity(Intent(requireContext(), AssetsActivity::class.java)) } } return true } inner class RuleAdapter : RecyclerView.Adapter(), ProfileManager.RuleListener, UndoSnackbarManager.Interface { val ruleList = ArrayList() suspend fun reload() { val rules = ProfileManager.getRules() ruleListView.post { ruleList.clear() ruleList.addAll(rules) ruleAdapter.notifyDataSetChanged() } } init { runOnDefaultDispatcher { reload() } } override fun onCreateViewHolder( parent: ViewGroup, viewType: Int, ): RecyclerView.ViewHolder { return if (viewType == 0) { DocumentHolder(LayoutEmptyRouteBinding.inflate(layoutInflater, parent, false)) } else { RuleHolder(LayoutRouteItemBinding.inflate(layoutInflater, parent, false)) } } override fun getItemViewType(position: Int): Int { if (position == 0) return 0 return 1 } override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { if (holder is DocumentHolder) { holder.bind() } else if (holder is RuleHolder) { holder.bind(ruleList[position - 1]) } } override fun getItemCount(): Int { return ruleList.size + 1 } override fun getItemId(position: Int): Long { if (position == 0) return 0L return ruleList[position - 1].id } private val updated = HashSet() fun move(from: Int, to: Int) { val first = ruleList[from - 1] var previousOrder = first.userOrder val (step, range) = if (from < to) Pair(1, from - 1 until to - 1) else Pair(-1, to downTo from - 1) for (i in range) { val next = ruleList[i + step] val order = next.userOrder next.userOrder = previousOrder previousOrder = order ruleList[i] = next updated.add(next) } first.userOrder = previousOrder ruleList[to - 1] = first updated.add(first) notifyItemMoved(from, to) } fun commitMove() = runOnDefaultDispatcher { if (updated.isNotEmpty()) { SagerDatabase.rulesDao.updateRules(updated.toList()) updated.clear() needReload() } } fun remove(index: Int) { ruleList.removeAt(index - 1) notifyItemRemoved(index) } override fun undo(actions: List>) { for ((index, item) in actions) { ruleList.add(index - 1, item) notifyItemInserted(index) } } override fun commit(actions: List>) { val rules = actions.map { it.second } runOnDefaultDispatcher { ProfileManager.deleteRules(rules) } } override suspend fun onAdd(rule: RuleEntity) { ruleListView.post { ruleList.add(rule) ruleAdapter.notifyItemInserted(ruleList.size) needReload() } } override suspend fun onUpdated(rule: RuleEntity) { val index = ruleList.indexOfFirst { it.id == rule.id } if (index == -1) return ruleListView.post { ruleList[index] = rule ruleAdapter.notifyItemChanged(index + 1) needReload() } } override suspend fun onRemoved(ruleId: Long) { val index = ruleList.indexOfFirst { it.id == ruleId } if (index == -1) { onMainDispatcher { needReload() } } else ruleListView.post { ruleList.removeAt(index) ruleAdapter.notifyItemRemoved(index + 1) needReload() } } override suspend fun onCleared() { ruleListView.post { ruleList.clear() ruleAdapter.notifyDataSetChanged() needReload() } } inner class DocumentHolder(binding: LayoutEmptyRouteBinding) : RecyclerView.ViewHolder(binding.root) { fun bind() { itemView.setOnClickListener { it.context.launchCustomTab("https://matsuridayo.github.io/nb4a-route/") } } } inner class RuleHolder(binding: LayoutRouteItemBinding) : RecyclerView.ViewHolder(binding.root) { lateinit var rule: RuleEntity val profileName = binding.profileName val profileType = binding.profileType val routeOutbound = binding.routeOutbound val editButton = binding.edit val shareLayout = binding.share val enableSwitch = binding.enable fun bind(ruleEntity: RuleEntity) { rule = ruleEntity profileName.text = rule.displayName() profileType.text = rule.mkSummary() routeOutbound.text = rule.displayOutbound() itemView.setOnClickListener { enableSwitch.performClick() } enableSwitch.isChecked = rule.enabled enableSwitch.setOnCheckedChangeListener { _, isChecked -> runOnDefaultDispatcher { rule.enabled = isChecked SagerDatabase.rulesDao.updateRule(rule) onMainDispatcher { needReload() } } } editButton.setOnClickListener { startActivity(Intent(it.context, RouteSettingsActivity::class.java).apply { putExtra(RouteSettingsActivity.EXTRA_ROUTE_ID, rule.id) }) } } } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ui/RouteSettingsActivity.kt ================================================ package io.nekohasekai.sagernet.ui import android.app.Activity import android.content.DialogInterface import android.content.Intent import android.os.Bundle import android.os.Parcelable import android.view.Menu import android.view.MenuItem import android.view.View import android.widget.Toast import androidx.activity.result.component1 import androidx.activity.result.component2 import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.LayoutRes import androidx.appcompat.app.AlertDialog import androidx.core.view.ViewCompat import androidx.preference.EditTextPreference import androidx.preference.Preference import androidx.preference.PreferenceDataStore import androidx.preference.PreferenceFragmentCompat import com.github.shadowsocks.plugin.Empty import com.github.shadowsocks.plugin.fragment.AlertDialogFragment import com.google.android.material.dialog.MaterialAlertDialogBuilder import io.nekohasekai.sagernet.Key import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.SagerNet import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.database.ProfileManager import io.nekohasekai.sagernet.database.RuleEntity import io.nekohasekai.sagernet.database.SagerDatabase import io.nekohasekai.sagernet.database.preference.OnPreferenceDataStoreChangeListener import io.nekohasekai.sagernet.ktx.Logs import io.nekohasekai.sagernet.ktx.app import io.nekohasekai.sagernet.ktx.onMainDispatcher import io.nekohasekai.sagernet.ktx.runOnDefaultDispatcher import io.nekohasekai.sagernet.utils.PackageCache import io.nekohasekai.sagernet.widget.AppListPreference import io.nekohasekai.sagernet.widget.ListListener import io.nekohasekai.sagernet.widget.OutboundPreference import kotlinx.parcelize.Parcelize import moe.matsuri.nb4a.ui.EditConfigPreference @Suppress("UNCHECKED_CAST") class RouteSettingsActivity( @LayoutRes resId: Int = R.layout.layout_settings_activity, ) : ThemedActivity(resId), OnPreferenceDataStoreChangeListener { fun init(packageName: String?) { RuleEntity().apply { if (!packageName.isNullOrBlank()) { packages = setOf(packageName) name = app.getString(R.string.route_for, PackageCache.loadLabel(packageName)) } }.init() } fun RuleEntity.init() { DataStore.routeName = name DataStore.serverConfig = config DataStore.routeDomain = domains DataStore.routeIP = ip DataStore.routePort = port DataStore.routeSourcePort = sourcePort DataStore.routeNetwork = network DataStore.routeSource = source DataStore.routeProtocol = protocol DataStore.routeOutboundRule = outbound DataStore.routeOutbound = when (outbound) { 0L -> 0 -1L -> 1 -2L -> 2 else -> 3 } DataStore.routePackages = packages.joinToString("\n") } fun RuleEntity.serialize() { name = DataStore.routeName config = DataStore.serverConfig domains = DataStore.routeDomain ip = DataStore.routeIP port = DataStore.routePort sourcePort = DataStore.routeSourcePort network = DataStore.routeNetwork source = DataStore.routeSource protocol = DataStore.routeProtocol outbound = when (DataStore.routeOutbound) { 0 -> 0L 1 -> -1L 2 -> -2L else -> DataStore.routeOutboundRule } packages = DataStore.routePackages.split("\n").filter { it.isNotBlank() }.toSet() if (DataStore.editingId == 0L) { enabled = true } } private lateinit var editConfigPreference: EditConfigPreference fun needSave(): Boolean { return DataStore.dirty } fun PreferenceFragmentCompat.createPreferences( savedInstanceState: Bundle?, rootKey: String?, ) { addPreferencesFromResource(R.xml.route_preferences) editConfigPreference = findPreference(Key.SERVER_CONFIG)!! } override fun onResume() { super.onResume() if (::editConfigPreference.isInitialized) { editConfigPreference.notifyChanged() } } val selectProfileForAdd = registerForActivityResult( ActivityResultContracts.StartActivityForResult() ) { (resultCode, data) -> if (resultCode == Activity.RESULT_OK) runOnDefaultDispatcher { val profile = ProfileManager.getProfile( data!!.getLongExtra( ProfileSelectActivity.EXTRA_PROFILE_ID, 0 ) ) ?: return@runOnDefaultDispatcher DataStore.routeOutboundRule = profile.id onMainDispatcher { outbound.value = "3" } } } val selectAppList = registerForActivityResult( ActivityResultContracts.StartActivityForResult() ) { (_, _) -> apps.postUpdate() } lateinit var outbound: OutboundPreference lateinit var apps: AppListPreference fun PreferenceFragmentCompat.viewCreated(view: View, savedInstanceState: Bundle?) { outbound = findPreference(Key.ROUTE_OUTBOUND)!! apps = findPreference(Key.ROUTE_PACKAGES)!! outbound.setOnPreferenceChangeListener { _, newValue -> if (newValue.toString() == "3") { selectProfileForAdd.launch( Intent( this@RouteSettingsActivity, ProfileSelectActivity::class.java ) ) false } else { true } } apps.setOnPreferenceClickListener { selectAppList.launch( Intent( this@RouteSettingsActivity, AppListActivity::class.java ) ) true } } fun displayPreferenceDialog(preference: Preference): Boolean { return false } class UnsavedChangesDialogFragment : AlertDialogFragment() { override fun AlertDialog.Builder.prepare(listener: DialogInterface.OnClickListener) { setTitle(R.string.unsaved_changes_prompt) setPositiveButton(R.string.yes) { _, _ -> runOnDefaultDispatcher { (requireActivity() as RouteSettingsActivity).saveAndExit() } } setNegativeButton(R.string.no) { _, _ -> requireActivity().finish() } setNeutralButton(android.R.string.cancel, null) } } @Parcelize data class ProfileIdArg(val ruleId: Long) : Parcelable class DeleteConfirmationDialogFragment : AlertDialogFragment() { override fun AlertDialog.Builder.prepare(listener: DialogInterface.OnClickListener) { setTitle(R.string.delete_route_prompt) setPositiveButton(R.string.yes) { _, _ -> runOnDefaultDispatcher { ProfileManager.deleteRule(arg.ruleId) } requireActivity().finish() } setNegativeButton(R.string.no, null) } } companion object { const val EXTRA_ROUTE_ID = "id" const val EXTRA_PACKAGE_NAME = "pkg" } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setSupportActionBar(findViewById(R.id.toolbar)) supportActionBar?.apply { setTitle(R.string.cag_route) setDisplayHomeAsUpEnabled(true) setHomeAsUpIndicator(R.drawable.ic_navigation_close) } if (savedInstanceState == null) { val editingId = intent.getLongExtra(EXTRA_ROUTE_ID, 0L) DataStore.editingId = editingId runOnDefaultDispatcher { if (editingId == 0L) { init(intent.getStringExtra(EXTRA_PACKAGE_NAME)) } else { val ruleEntity = SagerDatabase.rulesDao.getById(editingId) if (ruleEntity == null) { onMainDispatcher { finish() } return@runOnDefaultDispatcher } ruleEntity.init() } onMainDispatcher { supportFragmentManager.beginTransaction() .replace(R.id.settings, MyPreferenceFragmentCompat()) .commit() DataStore.dirty = false DataStore.profileCacheStore.registerChangeListener(this@RouteSettingsActivity) } } } } suspend fun saveAndExit() { if (!needSave()) { onMainDispatcher { MaterialAlertDialogBuilder(this@RouteSettingsActivity).setTitle(R.string.empty_route) .setMessage(R.string.empty_route_notice) .setPositiveButton(android.R.string.ok, null) .show() } return } val editingId = DataStore.editingId if (editingId == 0L) { if (intent.hasExtra(EXTRA_PACKAGE_NAME)) { setResult(RESULT_OK, Intent()) } ProfileManager.createRule(RuleEntity().apply { serialize() }) } else { val entity = SagerDatabase.rulesDao.getById(DataStore.editingId) if (entity == null) { finish() return } ProfileManager.updateRule(entity.apply { serialize() }) } finish() } val child by lazy { supportFragmentManager.findFragmentById(R.id.settings) as MyPreferenceFragmentCompat } override fun onCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.profile_config_menu, menu) return true } override fun onOptionsItemSelected(item: MenuItem) = child.onOptionsItemSelected(item) override fun onBackPressed() { if (needSave()) { UnsavedChangesDialogFragment().apply { key() }.show(supportFragmentManager, null) } else super.onBackPressed() } override fun onSupportNavigateUp(): Boolean { if (!super.onSupportNavigateUp()) finish() return true } override fun onDestroy() { DataStore.profileCacheStore.unregisterChangeListener(this) super.onDestroy() } override fun onPreferenceDataStoreChanged(store: PreferenceDataStore, key: String) { if (key != Key.PROFILE_DIRTY) { DataStore.dirty = true } } class MyPreferenceFragmentCompat : PreferenceFragmentCompat() { var activity: RouteSettingsActivity? = null override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { preferenceManager.preferenceDataStore = DataStore.profileCacheStore try { activity = (requireActivity() as RouteSettingsActivity).apply { createPreferences(savedInstanceState, rootKey) } } catch (e: Exception) { Toast.makeText( SagerNet.application, "Error on createPreferences, please try again.", Toast.LENGTH_SHORT ).show() Logs.e(e) } } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) ViewCompat.setOnApplyWindowInsetsListener(listView, ListListener) activity?.apply { viewCreated(view, savedInstanceState) } } override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) { R.id.action_delete -> { if (DataStore.editingId == 0L) { requireActivity().finish() } else { DeleteConfirmationDialogFragment().apply { arg(ProfileIdArg(DataStore.editingId)) key() }.show(parentFragmentManager, null) } true } R.id.action_apply -> { runOnDefaultDispatcher { activity?.saveAndExit() } true } else -> false } override fun onDisplayPreferenceDialog(preference: Preference) { activity?.apply { if (displayPreferenceDialog(preference)) return } super.onDisplayPreferenceDialog(preference) } } object PasswordSummaryProvider : Preference.SummaryProvider { override fun provideSummary(preference: EditTextPreference): CharSequence { val text = preference.text return if (text.isNullOrBlank()) { preference.context.getString(androidx.preference.R.string.not_set) } else { "\u2022".repeat(text.length) } } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ui/ScannerActivity.kt ================================================ package io.nekohasekai.sagernet.ui import android.Manifest import android.content.Intent import android.content.pm.ShortcutManager import android.graphics.ImageDecoder import android.os.Build import android.os.Bundle import android.provider.MediaStore import android.view.Menu import android.view.MenuItem import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts import androidx.core.content.getSystemService import androidx.core.net.toUri import com.google.zxing.Result import com.king.zxing.CameraScan import com.king.zxing.DefaultCameraScan import com.king.zxing.analyze.QRCodeAnalyzer import com.king.zxing.util.CodeUtils import com.king.zxing.util.LogUtils import com.king.zxing.util.PermissionUtils import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.database.ProfileManager import io.nekohasekai.sagernet.databinding.LayoutScannerBinding import io.nekohasekai.sagernet.group.RawUpdater import io.nekohasekai.sagernet.ktx.* import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicInteger class ScannerActivity : ThemedActivity(), CameraScan.OnScanResultCallback { lateinit var binding: LayoutScannerBinding lateinit var cameraScan: CameraScan override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) if (Build.VERSION.SDK_INT >= 25) getSystemService()!!.reportShortcutUsed("scan") binding = LayoutScannerBinding.inflate(layoutInflater) setContentView(binding.root) setSupportActionBar(findViewById(R.id.toolbar)) supportActionBar?.apply { setDisplayHomeAsUpEnabled(true) setHomeAsUpIndicator(R.drawable.ic_navigation_close) } // 二维码库 initCameraScan() startCamera() binding.ivFlashlight.setOnClickListener { toggleTorchState() } } override fun onCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.scanner_menu, menu) return true } val importCodeFile = registerForActivityResult(ActivityResultContracts.GetMultipleContents()) { runOnDefaultDispatcher { try { it.forEachTry { uri -> val bitmap = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { ImageDecoder.decodeBitmap( ImageDecoder.createSource( contentResolver, uri ) ) { decoder, _, _ -> decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE decoder.isMutableRequired = true } } else { @Suppress("DEPRECATION") MediaStore.Images.Media.getBitmap( contentResolver, uri ) } val result = CodeUtils.parseCodeResult(bitmap) onMainDispatcher { onScanResultCallback(result, true) } } finish() } catch (e: Exception) { Logs.w(e) onMainDispatcher { Toast.makeText(app, e.readableMessage, Toast.LENGTH_LONG).show() } } } } override fun onOptionsItemSelected(item: MenuItem): Boolean { return if (item.itemId == R.id.action_import_file) { startFilesForResult(importCodeFile, "image/*") true } else { super.onOptionsItemSelected(item) } } var finished = AtomicBoolean(false) var importedN = AtomicInteger(0) /** * 接收扫码结果回调 * @param result 扫码结果 * @return 返回true表示拦截,将不自动执行后续逻辑,为false表示不拦截,默认不拦截 */ override fun onScanResultCallback(result: Result?): Boolean { return onScanResultCallback(result, false) } fun onScanResultCallback(result: Result?, multi: Boolean): Boolean { if (!multi && finished.getAndSet(true)) return true if (!multi) finish() runOnDefaultDispatcher { try { val text = result?.text ?: throw Exception("QR code not found") val results = RawUpdater.parseRaw(text) if (!results.isNullOrEmpty()) { val currentGroupId = DataStore.selectedGroupForImport() if (DataStore.selectedGroup != currentGroupId) { DataStore.selectedGroup = currentGroupId } for (profile in results) { ProfileManager.createProfile(currentGroupId, profile) importedN.addAndGet(1) } } else { onMainDispatcher { Toast.makeText(app, R.string.action_import_err, Toast.LENGTH_SHORT).show() } } } catch (e: SubscriptionFoundException) { startActivity(Intent(this@ScannerActivity, MainActivity::class.java).apply { action = Intent.ACTION_VIEW data = e.link.toUri() }) } catch (e: Throwable) { Logs.w(e) onMainDispatcher { var text = getString(R.string.action_import_err) text += "\n" + e.readableMessage Toast.makeText(app, text, Toast.LENGTH_SHORT).show() } } } return true } /** * 初始化CameraScan */ fun initCameraScan() { cameraScan = DefaultCameraScan(this, binding.previewView) cameraScan.setAnalyzer(QRCodeAnalyzer()) cameraScan.setOnScanResultCallback(this) cameraScan.setNeedAutoZoom(true) } /** * 启动相机预览 */ fun startCamera() { if (PermissionUtils.checkPermission(this, Manifest.permission.CAMERA)) { cameraScan.startCamera() } else { LogUtils.d("checkPermissionResult != PERMISSION_GRANTED") PermissionUtils.requestPermission( this, Manifest.permission.CAMERA, CAMERA_PERMISSION_REQUEST_CODE ) } } /** * 释放相机 */ private fun releaseCamera() { cameraScan.release() } /** * 切换闪光灯状态(开启/关闭) */ protected fun toggleTorchState() { val isTorch = cameraScan.isTorchEnabled cameraScan.enableTorch(!isTorch) binding.ivFlashlight.isSelected = !isTorch } val CAMERA_PERMISSION_REQUEST_CODE = 0X86 override fun onRequestPermissionsResult( requestCode: Int, permissions: Array, grantResults: IntArray ) { super.onRequestPermissionsResult(requestCode, permissions, grantResults) if (requestCode == CAMERA_PERMISSION_REQUEST_CODE) { requestCameraPermissionResult(permissions, grantResults) } } /** * 请求Camera权限回调结果 * @param permissions * @param grantResults */ fun requestCameraPermissionResult(permissions: Array, grantResults: IntArray) { if (PermissionUtils.requestPermissionsResult( Manifest.permission.CAMERA, permissions, grantResults ) ) { startCamera() } else { finish() } } override fun onDestroy() { releaseCamera() super.onDestroy() if (importedN.get() > 0) { var text = getString(R.string.action_import_msg) text += "\n" + importedN.get() + " profile(s)" Toast.makeText(app, text, Toast.LENGTH_LONG).show() } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ui/SettingsFragment.kt ================================================ package io.nekohasekai.sagernet.ui import android.os.Bundle import android.view.View import androidx.core.view.ViewCompat import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.widget.ListListener class SettingsFragment : ToolbarFragment(R.layout.layout_config_settings) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) ViewCompat.setOnApplyWindowInsetsListener(view, ListListener) toolbar.setTitle(R.string.settings) parentFragmentManager.beginTransaction() .replace(R.id.settings, SettingsPreferenceFragment()) .commitAllowingStateLoss() } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ui/SettingsPreferenceFragment.kt ================================================ package io.nekohasekai.sagernet.ui import android.content.Intent import android.os.Build import android.os.Bundle import android.view.View import android.view.inputmethod.EditorInfo import android.widget.EditText import androidx.core.app.ActivityCompat import androidx.preference.* import com.google.android.material.dialog.MaterialAlertDialogBuilder import io.nekohasekai.sagernet.Key import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.SagerNet import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.database.preference.EditTextPreferenceModifiers import io.nekohasekai.sagernet.ktx.* import io.nekohasekai.sagernet.utils.Theme import moe.matsuri.nb4a.ui.* class SettingsPreferenceFragment : PreferenceFragmentCompat() { private lateinit var isProxyApps: SwitchPreference private lateinit var globalCustomConfig: EditConfigPreference override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) listView.layoutManager = FixedLinearLayoutManager(listView) } private val reloadListener = Preference.OnPreferenceChangeListener { _, _ -> needReload() true } override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { preferenceManager.preferenceDataStore = DataStore.configurationStore DataStore.initGlobal() addPreferencesFromResource(R.xml.global_preferences) val appTheme = findPreference(Key.APP_THEME)!! appTheme.setOnPreferenceChangeListener { _, newTheme -> if (DataStore.serviceState.started) { SagerNet.reloadService() } val theme = Theme.getTheme(newTheme as Int) app.setTheme(theme) requireActivity().apply { setTheme(theme) ActivityCompat.recreate(this) } true } val nightTheme = findPreference(Key.NIGHT_THEME)!! nightTheme.setOnPreferenceChangeListener { _, newTheme -> Theme.currentNightMode = (newTheme as String).toInt() Theme.applyNightTheme() true } val mixedPort = findPreference(Key.MIXED_PORT)!! val serviceMode = findPreference(Key.SERVICE_MODE)!! val allowAccess = findPreference(Key.ALLOW_ACCESS)!! val appendHttpProxy = findPreference(Key.APPEND_HTTP_PROXY)!! val showDirectSpeed = findPreference(Key.SHOW_DIRECT_SPEED)!! val ipv6Mode = findPreference(Key.IPV6_MODE)!! val trafficSniffing = findPreference(Key.TRAFFIC_SNIFFING)!! val bypassLan = findPreference(Key.BYPASS_LAN)!! val bypassLanInCore = findPreference(Key.BYPASS_LAN_IN_CORE)!! val remoteDns = findPreference(Key.REMOTE_DNS)!! val directDns = findPreference(Key.DIRECT_DNS)!! val enableDnsRouting = findPreference(Key.ENABLE_DNS_ROUTING)!! val enableFakeDns = findPreference(Key.ENABLE_FAKEDNS)!! val logLevel = findPreference(Key.LOG_LEVEL)!! val mtu = findPreference(Key.MTU)!! globalCustomConfig = findPreference(Key.GLOBAL_CUSTOM_CONFIG)!! globalCustomConfig.useConfigStore(Key.GLOBAL_CUSTOM_CONFIG) logLevel.dialogLayoutResource = R.layout.layout_loglevel_help logLevel.setOnPreferenceChangeListener { _, _ -> needRestart() true } logLevel.setOnLongClickListener { if (context == null) return@setOnLongClickListener true val view = EditText(context).apply { inputType = EditorInfo.TYPE_CLASS_NUMBER var size = DataStore.logBufSize if (size == 0) size = 50 setText(size.toString()) } MaterialAlertDialogBuilder(requireContext()).setTitle("Log buffer size (kb)") .setView(view) .setPositiveButton(android.R.string.ok) { _, _ -> DataStore.logBufSize = view.text.toString().toInt() if (DataStore.logBufSize <= 0) DataStore.logBufSize = 50 needRestart() } .setNegativeButton(android.R.string.cancel, null) .show() true } mixedPort.setOnBindEditTextListener(EditTextPreferenceModifiers.Port) val metedNetwork = findPreference(Key.METERED_NETWORK)!! if (Build.VERSION.SDK_INT < 28) { metedNetwork.remove() } isProxyApps = findPreference(Key.PROXY_APPS)!! isProxyApps.setOnPreferenceChangeListener { _, newValue -> startActivity(Intent(activity, AppManagerActivity::class.java)) if (newValue as Boolean) DataStore.dirty = true newValue } val profileTrafficStatistics = findPreference(Key.PROFILE_TRAFFIC_STATISTICS)!! val speedInterval = findPreference(Key.SPEED_INTERVAL)!! profileTrafficStatistics.isEnabled = speedInterval.value.toString() != "0" speedInterval.setOnPreferenceChangeListener { _, newValue -> profileTrafficStatistics.isEnabled = newValue.toString() != "0" needReload() true } serviceMode.setOnPreferenceChangeListener { _, _ -> if (DataStore.serviceState.started) SagerNet.stopService() true } val tunImplementation = findPreference(Key.TUN_IMPLEMENTATION)!! val resolveDestination = findPreference(Key.RESOLVE_DESTINATION)!! val acquireWakeLock = findPreference(Key.ACQUIRE_WAKE_LOCK)!! val enableClashAPI = findPreference(Key.ENABLE_CLASH_API)!! enableClashAPI.setOnPreferenceChangeListener { _, newValue -> (activity as MainActivity?)?.refreshNavMenu(newValue as Boolean) needReload() true } mixedPort.onPreferenceChangeListener = reloadListener appendHttpProxy.onPreferenceChangeListener = reloadListener showDirectSpeed.onPreferenceChangeListener = reloadListener trafficSniffing.onPreferenceChangeListener = reloadListener bypassLan.onPreferenceChangeListener = reloadListener bypassLanInCore.onPreferenceChangeListener = reloadListener mtu.onPreferenceChangeListener = reloadListener enableFakeDns.onPreferenceChangeListener = reloadListener remoteDns.onPreferenceChangeListener = reloadListener directDns.onPreferenceChangeListener = reloadListener enableDnsRouting.onPreferenceChangeListener = reloadListener ipv6Mode.onPreferenceChangeListener = reloadListener allowAccess.onPreferenceChangeListener = reloadListener resolveDestination.onPreferenceChangeListener = reloadListener tunImplementation.onPreferenceChangeListener = reloadListener acquireWakeLock.onPreferenceChangeListener = reloadListener globalCustomConfig.onPreferenceChangeListener = reloadListener } override fun onResume() { super.onResume() if (::isProxyApps.isInitialized) { isProxyApps.isChecked = DataStore.proxyApps } if (::globalCustomConfig.isInitialized) { globalCustomConfig.notifyChanged() } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ui/StunActivity.kt ================================================ package io.nekohasekai.sagernet.ui import android.os.Bundle import androidx.appcompat.app.AlertDialog import androidx.core.view.isVisible import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.databinding.LayoutStunBinding import io.nekohasekai.sagernet.ktx.onMainDispatcher import io.nekohasekai.sagernet.ktx.readableMessage import io.nekohasekai.sagernet.ktx.runOnDefaultDispatcher import libcore.Libcore class StunActivity : ThemedActivity() { private lateinit var binding: LayoutStunBinding override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = LayoutStunBinding.inflate(layoutInflater) setContentView(binding.root) setSupportActionBar(findViewById(R.id.toolbar)) supportActionBar?.apply { setTitle(R.string.stun_test) setDisplayHomeAsUpEnabled(true) setHomeAsUpIndicator(R.drawable.baseline_arrow_back_24) } binding.stunTest.setOnClickListener { doTest() } } fun doTest() { binding.waitLayout.isVisible = true runOnDefaultDispatcher { val result = try { val _result = Libcore.stunTest(binding.natStunServer.text.toString()) if (_result!!.success) { _result.text } else { throw Exception(_result.text) } } catch (e: Exception) { onMainDispatcher { AlertDialog.Builder(this@StunActivity) .setTitle(R.string.error_title) .setMessage(e.readableMessage) .setPositiveButton(android.R.string.ok) { _, _ -> finish() } .setOnCancelListener { finish() } .runCatching { show() } } return@runOnDefaultDispatcher } onMainDispatcher { binding.waitLayout.isVisible = false binding.natResult.text = result } } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ui/SwitchActivity.kt ================================================ package io.nekohasekai.sagernet.ui import android.os.Bundle import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.SagerNet import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.database.ProfileManager import io.nekohasekai.sagernet.ktx.runOnMainDispatcher class SwitchActivity : ThemedActivity(R.layout.layout_empty), ConfigurationFragment.SelectCallback { override val isDialog = true override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) supportFragmentManager.beginTransaction() .replace( R.id.fragment_holder, ConfigurationFragment(true, null, R.string.action_switch) ) .commitAllowingStateLoss() } override fun returnProfile(profileId: Long) { val old = DataStore.selectedProxy DataStore.selectedProxy = profileId runOnMainDispatcher { ProfileManager.postUpdate(old, true) ProfileManager.postUpdate(profileId, true) } SagerNet.reloadService() finish() } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ui/ThemedActivity.kt ================================================ package io.nekohasekai.sagernet.ui import android.content.res.Configuration import android.os.Build import android.os.Bundle import android.widget.TextView import androidx.annotation.StringRes import androidx.appcompat.app.AppCompatActivity import androidx.core.app.ActivityCompat import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.updatePadding import com.google.android.material.appbar.AppBarLayout import com.google.android.material.snackbar.Snackbar import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.utils.Theme abstract class ThemedActivity : AppCompatActivity { constructor() : super() constructor(contentLayoutId: Int) : super(contentLayoutId) var themeResId = 0 var uiMode = 0 open val isDialog = false override fun onCreate(savedInstanceState: Bundle?) { if (!isDialog) { Theme.apply(this) } else { Theme.applyDialog(this) } Theme.applyNightTheme() super.onCreate(savedInstanceState) uiMode = resources.configuration.uiMode if (Build.VERSION.SDK_INT >= 35) { ViewCompat.setOnApplyWindowInsetsListener(findViewById(android.R.id.content)) { _, insets -> val top = insets.getInsets(WindowInsetsCompat.Type.systemBars()).top findViewById(R.id.appbar)?.apply { updatePadding(top = top) // Logs.w("appbar $top") } // findViewById(R.id.nav_view)?.apply { // updatePadding(top = top) // } insets } } } override fun setTheme(resId: Int) { super.setTheme(resId) themeResId = resId } override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) if (newConfig.uiMode != uiMode) { uiMode = newConfig.uiMode ActivityCompat.recreate(this) } } fun snackbar(@StringRes resId: Int): Snackbar = snackbar("").setText(resId) fun snackbar(text: CharSequence): Snackbar = snackbarInternal(text).apply { view.findViewById(com.google.android.material.R.id.snackbar_text).apply { maxLines = 10 } } internal open fun snackbarInternal(text: CharSequence): Snackbar = throw NotImplementedError() } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ui/ToolbarFragment.kt ================================================ package io.nekohasekai.sagernet.ui import android.os.Bundle import android.view.KeyEvent import android.view.View import androidx.appcompat.widget.Toolbar import androidx.core.view.GravityCompat import androidx.fragment.app.Fragment import io.nekohasekai.sagernet.R open class ToolbarFragment : Fragment { constructor() : super() constructor(contentLayoutId: Int) : super(contentLayoutId) lateinit var toolbar: Toolbar override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) toolbar = view.findViewById(R.id.toolbar) toolbar.setNavigationIcon(R.drawable.ic_navigation_menu) toolbar.setNavigationOnClickListener { (activity as MainActivity).binding.drawerLayout.openDrawer(GravityCompat.START) } } open fun onKeyDown(ketCode: Int, event: KeyEvent) = false open fun onBackPressed(): Boolean = false } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ui/ToolsFragment.kt ================================================ package io.nekohasekai.sagernet.ui import android.os.Bundle import android.view.View import androidx.fragment.app.Fragment import androidx.viewpager2.adapter.FragmentStateAdapter import com.google.android.material.tabs.TabLayoutMediator import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.databinding.LayoutToolsBinding class ToolsFragment : ToolbarFragment(R.layout.layout_tools) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) toolbar.setTitle(R.string.menu_tools) val tools = mutableListOf() tools.add(NetworkFragment()) tools.add(BackupFragment()) val binding = LayoutToolsBinding.bind(view) binding.toolsPager.adapter = ToolsAdapter(tools) TabLayoutMediator(binding.toolsTab, binding.toolsPager) { tab, position -> tab.text = tools[position].name() tab.view.setOnLongClickListener { // clear toast true } }.attach() } inner class ToolsAdapter(val tools: List) : FragmentStateAdapter(this) { override fun getItemCount() = tools.size override fun createFragment(position: Int) = tools[position] } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ui/VpnRequestActivity.kt ================================================ package io.nekohasekai.sagernet.ui import android.app.Activity import android.app.KeyguardManager import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter import android.net.VpnService import android.os.Build.VERSION.SDK_INT import android.os.Bundle import android.widget.Toast import androidx.activity.result.contract.ActivityResultContract import androidx.appcompat.app.AppCompatActivity import androidx.core.content.getSystemService import io.nekohasekai.sagernet.Key import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.SagerNet import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.ktx.Logs import io.nekohasekai.sagernet.ktx.broadcastReceiver class VpnRequestActivity : AppCompatActivity() { private var receiver: BroadcastReceiver? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) if (getSystemService()!!.isKeyguardLocked) { receiver = broadcastReceiver { _, _ -> connect.launch(null) } if (SDK_INT >= 33) { registerReceiver( receiver, IntentFilter(Intent.ACTION_USER_PRESENT), Context.RECEIVER_EXPORTED ) } else { registerReceiver(receiver, IntentFilter(Intent.ACTION_USER_PRESENT)) } } else connect.launch(null) } private val connect = registerForActivityResult(StartService()) { if (it) Toast.makeText(this, R.string.vpn_permission_denied, Toast.LENGTH_LONG).show() finish() } override fun onDestroy() { super.onDestroy() if (receiver != null) unregisterReceiver(receiver) } class StartService : ActivityResultContract() { private var cachedIntent: Intent? = null override fun getSynchronousResult( context: Context, input: Void?, ): SynchronousResult? { if (DataStore.serviceMode == Key.MODE_VPN) VpnService.prepare(context)?.let { intent -> cachedIntent = intent return null } SagerNet.startService() return SynchronousResult(false) } override fun createIntent(context: Context, input: Void?) = cachedIntent!!.also { cachedIntent = null } override fun parseResult(resultCode: Int, intent: Intent?) = if (resultCode == Activity.RESULT_OK) { SagerNet.startService() false } else { Logs.e("Failed to start VpnService: $intent") true } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ui/WebviewFragment.kt ================================================ package io.nekohasekai.sagernet.ui import android.annotation.SuppressLint import android.os.Bundle import android.text.InputType import android.view.MenuItem import android.view.View import android.webkit.* import android.widget.EditText import androidx.appcompat.widget.Toolbar import com.google.android.material.dialog.MaterialAlertDialogBuilder import io.nekohasekai.sagernet.BuildConfig import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.databinding.LayoutWebviewBinding import moe.matsuri.nb4a.utils.WebViewUtil // Fragment必须有一个无参public的构造函数,否则在数据恢复的时候,会报crash class WebviewFragment : ToolbarFragment(R.layout.layout_webview), Toolbar.OnMenuItemClickListener { lateinit var mWebView: WebView @SuppressLint("SetJavaScriptEnabled") override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) // layout toolbar.setTitle(R.string.menu_dashboard) toolbar.inflateMenu(R.menu.yacd_menu) toolbar.setOnMenuItemClickListener(this) val binding = LayoutWebviewBinding.bind(view) // webview WebView.setWebContentsDebuggingEnabled(BuildConfig.DEBUG) mWebView = binding.webview mWebView.settings.domStorageEnabled = true mWebView.settings.javaScriptEnabled = true mWebView.webViewClient = object : WebViewClient() { override fun onReceivedError( view: WebView?, request: WebResourceRequest?, error: WebResourceError? ) { WebViewUtil.onReceivedError(view, request, error) } override fun onPageFinished(view: WebView?, url: String?) { super.onPageFinished(view, url) } } mWebView.loadUrl(DataStore.yacdURL) } @SuppressLint("CheckResult") override fun onMenuItemClick(item: MenuItem): Boolean { when (item.itemId) { R.id.action_set_url -> { val view = EditText(context).apply { inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_URI setText(DataStore.yacdURL) } MaterialAlertDialogBuilder(requireContext()).setTitle(R.string.set_panel_url) .setView(view) .setPositiveButton(android.R.string.ok) { _, _ -> DataStore.yacdURL = view.text.toString() mWebView.loadUrl(DataStore.yacdURL) } .setNegativeButton(android.R.string.cancel, null) .show() } R.id.close -> { mWebView.onPause() mWebView.removeAllViews() mWebView.destroy() } } return true } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ui/profile/ChainSettingsActivity.kt ================================================ package io.nekohasekai.sagernet.ui.profile import android.annotation.SuppressLint import android.app.Activity import android.content.Intent import android.os.Bundle import android.text.format.Formatter import android.view.View import android.view.ViewGroup import android.widget.LinearLayout import android.widget.TextView import androidx.activity.result.component1 import androidx.activity.result.component2 import androidx.activity.result.contract.ActivityResultContracts import androidx.core.view.isVisible import androidx.preference.PreferenceFragmentCompat import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.google.android.material.dialog.MaterialAlertDialogBuilder import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.database.ProfileManager import io.nekohasekai.sagernet.database.ProxyEntity import io.nekohasekai.sagernet.databinding.LayoutAddEntityBinding import io.nekohasekai.sagernet.databinding.LayoutProfileBinding import io.nekohasekai.sagernet.fmt.internal.ChainBean import io.nekohasekai.sagernet.ktx.* import io.nekohasekai.sagernet.ui.ProfileSelectActivity import moe.matsuri.nb4a.Protocols.getProtocolColor class ChainSettingsActivity : ProfileSettingsActivity(R.layout.layout_chain_settings) { override fun createEntity() = ChainBean() val proxyList = ArrayList() override fun ChainBean.init() { DataStore.profileName = name DataStore.serverProtocol = proxies.joinToString(",") } override fun ChainBean.serialize() { name = DataStore.profileName proxies = proxyList.map { it.id } initializeDefaultValues() } override fun PreferenceFragmentCompat.createPreferences( savedInstanceState: Bundle?, rootKey: String?, ) { addPreferencesFromResource(R.xml.name_preferences) } lateinit var configurationList: RecyclerView lateinit var configurationAdapter: ProxiesAdapter lateinit var layoutManager: LinearLayoutManager @SuppressLint("InlinedApi") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) supportActionBar!!.setTitle(R.string.chain_settings) configurationList = findViewById(R.id.configuration_list) layoutManager = FixedLinearLayoutManager(configurationList) configurationList.layoutManager = layoutManager configurationAdapter = ProxiesAdapter() configurationList.adapter = configurationAdapter ItemTouchHelper(object : ItemTouchHelper.SimpleCallback( ItemTouchHelper.UP or ItemTouchHelper.DOWN, ItemTouchHelper.START ) { override fun getSwipeDirs( recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, ) = if (viewHolder is ProfileHolder) { super.getSwipeDirs(recyclerView, viewHolder) } else 0 override fun getDragDirs( recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, ) = if (viewHolder is ProfileHolder) { super.getDragDirs(recyclerView, viewHolder) } else 0 override fun onMove( recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder, ): Boolean { return if (target !is ProfileHolder) false else { configurationAdapter.move( viewHolder.bindingAdapterPosition, target.bindingAdapterPosition ) true } } override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { configurationAdapter.remove(viewHolder.bindingAdapterPosition) } }).attachToRecyclerView(configurationList) } override fun PreferenceFragmentCompat.viewCreated(view: View, savedInstanceState: Bundle?) { view.rootView.findViewById(R.id.recycler_view).apply { (layoutParams ?: LinearLayout.LayoutParams(-1, -2)).apply { height = -2 layoutParams = this } } runOnDefaultDispatcher { configurationAdapter.reload() } } inner class ProxiesAdapter : RecyclerView.Adapter() { suspend fun reload() { val idList = DataStore.serverProtocol.split(",") .mapNotNull { it.takeIf { it.isNotBlank() }?.toLong() } if (idList.isNotEmpty()) { val profiles = ProfileManager.getProfiles(idList).map { it.id to it }.toMap() for (id in idList) { proxyList.add(profiles[id] ?: continue) } } onMainDispatcher { notifyDataSetChanged() } } fun move(from: Int, to: Int) { val toMove = proxyList[to - 1] proxyList[to - 1] = proxyList[from - 1] proxyList[from - 1] = toMove notifyItemMoved(from, to) DataStore.dirty = true } fun remove(index: Int) { proxyList.removeAt(index - 1) notifyItemRemoved(index) DataStore.dirty = true } override fun getItemId(position: Int): Long { return if (position == 0) 0 else proxyList[position - 1].id } override fun getItemViewType(position: Int): Int { return if (position == 0) 0 else 1 } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { return if (viewType == 0) { AddHolder(LayoutAddEntityBinding.inflate(layoutInflater, parent, false)) } else { ProfileHolder(LayoutProfileBinding.inflate(layoutInflater, parent, false)) } } override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { if (holder is AddHolder) { holder.bind() } else if (holder is ProfileHolder) { holder.bind(proxyList[position - 1]) } } override fun getItemCount(): Int { return proxyList.size + 1 } } fun testProfileAllowed(profile: ProxyEntity): Boolean { if (profile.id == DataStore.editingId) return false for (entity in proxyList) { if (testProfileContains(entity, profile)) return false } return true } fun testProfileContains(profile: ProxyEntity, anotherProfile: ProxyEntity): Boolean { if (profile.type != 8 || anotherProfile.type != 8) return false if (profile.id == anotherProfile.id) return true val proxies = profile.chainBean!!.proxies if (proxies.contains(anotherProfile.id)) return true if (proxies.isNotEmpty()) { for (entity in ProfileManager.getProfiles(proxies)) { if (testProfileContains(entity, anotherProfile)) { return true } } } return false } var replacing = 0 val selectProfileForAdd = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { (resultCode, data) -> if (resultCode == Activity.RESULT_OK) runOnDefaultDispatcher { DataStore.dirty = true val profile = ProfileManager.getProfile( data!!.getLongExtra( ProfileSelectActivity.EXTRA_PROFILE_ID, 0 ) )!! if (!testProfileAllowed(profile)) { onMainDispatcher { MaterialAlertDialogBuilder(this@ChainSettingsActivity).setTitle(R.string.circular_reference) .setMessage(R.string.circular_reference_sum) .setPositiveButton(android.R.string.ok, null).show() } } else { configurationList.post { if (replacing != 0) { proxyList[replacing - 1] = profile configurationAdapter.notifyItemChanged(replacing) } else { proxyList.add(profile) configurationAdapter.notifyItemInserted(proxyList.size) } } } } } inner class AddHolder(val binding: LayoutAddEntityBinding) : RecyclerView.ViewHolder(binding.root) { fun bind() { binding.root.setOnClickListener { replacing = 0 selectProfileForAdd.launch( Intent( this@ChainSettingsActivity, ProfileSelectActivity::class.java ) ) } } } inner class ProfileHolder(binding: LayoutProfileBinding) : RecyclerView.ViewHolder(binding.root) { val profileName = binding.profileName val profileType = binding.profileType val trafficText: TextView = binding.trafficText val editButton = binding.edit val shareLayout = binding.share fun bind(proxyEntity: ProxyEntity) { profileName.text = proxyEntity.displayName() profileType.text = proxyEntity.displayType() profileType.setTextColor(getProtocolColor(proxyEntity.type)) val rx = proxyEntity.rx val tx = proxyEntity.tx val showTraffic = rx + tx != 0L trafficText.isVisible = showTraffic if (showTraffic) { trafficText.text = itemView.context.getString( R.string.traffic, Formatter.formatFileSize(itemView.context, tx), Formatter.formatFileSize(itemView.context, rx) ) } editButton.setOnClickListener { replacing = bindingAdapterPosition selectProfileForAdd.launch(Intent( this@ChainSettingsActivity, ProfileSelectActivity::class.java ).apply { putExtra(ProfileSelectActivity.EXTRA_SELECTED, proxyEntity) }) } shareLayout.isVisible = false } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ui/profile/ConfigEditActivity.kt ================================================ package io.nekohasekai.sagernet.ui.profile import android.annotation.SuppressLint import android.content.DialogInterface import android.os.Bundle import android.view.Menu import android.view.MenuItem import android.view.ViewGroup.MarginLayoutParams import android.widget.LinearLayout import androidx.appcompat.app.AlertDialog import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.updateLayoutParams import androidx.core.widget.addTextChangedListener import com.blacksquircle.ui.editorkit.insert import com.blacksquircle.ui.language.json.JsonLanguage import com.github.shadowsocks.plugin.Empty import com.github.shadowsocks.plugin.fragment.AlertDialogFragment import com.google.android.material.dialog.MaterialAlertDialogBuilder import io.nekohasekai.sagernet.Key import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.databinding.LayoutEditConfigBinding import io.nekohasekai.sagernet.ktx.getColorAttr import io.nekohasekai.sagernet.ktx.readableMessage import io.nekohasekai.sagernet.ktx.toStringPretty import io.nekohasekai.sagernet.ui.ThemedActivity import io.nekohasekai.sagernet.widget.ListListener import moe.matsuri.nb4a.ui.ExtendedKeyboard import org.json.JSONObject class ConfigEditActivity : ThemedActivity() { var dirty = false var key = Key.SERVER_CONFIG var useConfigStore = false class UnsavedChangesDialogFragment : AlertDialogFragment() { override fun AlertDialog.Builder.prepare(listener: DialogInterface.OnClickListener) { setTitle(R.string.unsaved_changes_prompt) setPositiveButton(R.string.yes) { _, _ -> (requireActivity() as ConfigEditActivity).saveAndExit() } setNegativeButton(R.string.no) { _, _ -> requireActivity().finish() } setNeutralButton(android.R.string.cancel, null) } } lateinit var binding: LayoutEditConfigBinding @SuppressLint("InlinedApi") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) intent?.extras?.apply { getString("key")?.let { key = it } getString("useConfigStore")?.let { useConfigStore = true } } binding = LayoutEditConfigBinding.inflate(layoutInflater) setContentView(binding.root) setSupportActionBar(findViewById(R.id.toolbar)) supportActionBar?.apply { setTitle(R.string.config_settings) setDisplayHomeAsUpEnabled(true) setHomeAsUpIndicator(R.drawable.ic_navigation_close) } binding.editor.apply { language = JsonLanguage() setHorizontallyScrolling(true) if (useConfigStore) { setTextContent(DataStore.configurationStore.getString(key) ?: "") } else { setTextContent(DataStore.profileCacheStore.getString(key) ?: "") } addTextChangedListener { if (!dirty) { dirty = true DataStore.dirty = true } } } binding.actionTab.setOnClickListener { try { binding.editor.insert(binding.editor.tab()) } catch (e: Exception) { } } binding.actionUndo.setOnClickListener { try { binding.editor.undo() } catch (_: Exception) { } } binding.actionRedo.setOnClickListener { try { binding.editor.redo() } catch (_: Exception) { } } binding.actionFormat.setOnClickListener { formatText()?.let { binding.editor.setTextContent(it) } } val extendedKeyboard = findViewById(R.id.extended_keyboard) extendedKeyboard.setKeyListener { char -> try { binding.editor.insert(char) } catch (e: Exception) { } } extendedKeyboard.setHasFixedSize(true) extendedKeyboard.submitList("{},:_\"".map { it.toString() }) extendedKeyboard.setBackgroundColor(getColorAttr(R.attr.primaryOrTextPrimary)) val keyboardContainer = findViewById(R.id.keyboard_container) ViewCompat.setOnApplyWindowInsetsListener(keyboardContainer) { v, windowInsets -> val imeInsets = windowInsets.getInsets(WindowInsetsCompat.Type.ime()) val systemBarInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) val imeVisible = windowInsets.isVisible(WindowInsetsCompat.Type.ime()) v.updateLayoutParams { // systemBar insets are applied to the bottom of the keyboard if (imeVisible) { bottomMargin = imeInsets.bottom - systemBarInsets.bottom } else { bottomMargin = 0 } } WindowInsetsCompat.CONSUMED } ViewCompat.setOnApplyWindowInsetsListener(binding.root, ListListener) } fun formatText(): String? { try { val txt = binding.editor.text.toString() if (txt.isBlank()) { return "" } return JSONObject(txt).toStringPretty() } catch (e: Exception) { MaterialAlertDialogBuilder(this).setTitle(R.string.error_title) .setMessage(e.readableMessage).show() return null } } fun saveAndExit() { formatText()?.let { if (useConfigStore) { DataStore.configurationStore.putString(key, it) } else { DataStore.profileCacheStore.putString(key, it) } finish() } } override fun onBackPressed() { if (dirty) UnsavedChangesDialogFragment().apply { key() } .show(supportFragmentManager, null) else super.onBackPressed() } override fun onSupportNavigateUp(): Boolean { if (!super.onSupportNavigateUp()) finish() return true } override fun onCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.profile_apply_menu, menu) return true } override fun onOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { R.id.action_apply -> { saveAndExit() return true } } return super.onOptionsItemSelected(item) } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ui/profile/HttpSettingsActivity.kt ================================================ package io.nekohasekai.sagernet.ui.profile import io.nekohasekai.sagernet.fmt.http.HttpBean class HttpSettingsActivity : StandardV2RaySettingsActivity() { override fun createEntity() = HttpBean() } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ui/profile/HysteriaSettingsActivity.kt ================================================ package io.nekohasekai.sagernet.ui.profile import android.os.Bundle import androidx.preference.EditTextPreference import androidx.preference.PreferenceFragmentCompat import androidx.preference.SwitchPreference import io.nekohasekai.sagernet.Key import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.database.preference.EditTextPreferenceModifiers import io.nekohasekai.sagernet.fmt.hysteria.HysteriaBean import io.nekohasekai.sagernet.ktx.applyDefaultValues import moe.matsuri.nb4a.ui.SimpleMenuPreference class HysteriaSettingsActivity : ProfileSettingsActivity() { override fun createEntity() = HysteriaBean().applyDefaultValues() override fun HysteriaBean.init() { DataStore.profileName = name DataStore.protocolVersion = protocolVersion DataStore.serverAddress = serverAddress DataStore.serverPorts = serverPorts DataStore.serverObfs = obfuscation DataStore.serverAuthType = authPayloadType DataStore.serverProtocolInt = protocol DataStore.serverPassword = authPayload DataStore.serverSNI = sni DataStore.serverALPN = alpn DataStore.serverCertificates = caText DataStore.serverAllowInsecure = allowInsecure DataStore.serverUploadSpeed = uploadMbps DataStore.serverDownloadSpeed = downloadMbps DataStore.serverStreamReceiveWindow = streamReceiveWindow DataStore.serverConnectionReceiveWindow = connectionReceiveWindow DataStore.serverDisableMtuDiscovery = disableMtuDiscovery DataStore.serverHopInterval = hopInterval } override fun HysteriaBean.serialize() { name = DataStore.profileName protocolVersion = DataStore.protocolVersion serverAddress = DataStore.serverAddress serverPorts = DataStore.serverPorts obfuscation = DataStore.serverObfs authPayloadType = DataStore.serverAuthType authPayload = DataStore.serverPassword protocol = DataStore.serverProtocolInt sni = DataStore.serverSNI alpn = DataStore.serverALPN caText = DataStore.serverCertificates allowInsecure = DataStore.serverAllowInsecure uploadMbps = DataStore.serverUploadSpeed downloadMbps = DataStore.serverDownloadSpeed streamReceiveWindow = DataStore.serverStreamReceiveWindow connectionReceiveWindow = DataStore.serverConnectionReceiveWindow disableMtuDiscovery = DataStore.serverDisableMtuDiscovery hopInterval = DataStore.serverHopInterval } override fun PreferenceFragmentCompat.createPreferences( savedInstanceState: Bundle?, rootKey: String?, ) { addPreferencesFromResource(R.xml.hysteria_preferences) val authType = findPreference(Key.SERVER_AUTH_TYPE)!! val authPayload = findPreference(Key.SERVER_PASSWORD)!! authPayload.isVisible = authType.value != "${HysteriaBean.TYPE_NONE}" authType.setOnPreferenceChangeListener { _, newValue -> authPayload.isVisible = newValue != "${HysteriaBean.TYPE_NONE}" true } val protocol = findPreference(Key.SERVER_PROTOCOL)!! val alpn = findPreference(Key.SERVER_ALPN)!! fun updateVersion(v: Int) { if (v == 2) { authPayload.isVisible = true // authType.isVisible = false protocol.isVisible = false alpn.isVisible = false // findPreference(Key.SERVER_STREAM_RECEIVE_WINDOW)!!.isVisible = false findPreference(Key.SERVER_CONNECTION_RECEIVE_WINDOW)!!.isVisible = false findPreference(Key.SERVER_DISABLE_MTU_DISCOVERY)!!.isVisible = false // authPayload.title = resources.getString(R.string.password) } else { authType.isVisible = true authPayload.isVisible = true protocol.isVisible = true alpn.isVisible = true // findPreference(Key.SERVER_STREAM_RECEIVE_WINDOW)!!.isVisible = true findPreference(Key.SERVER_CONNECTION_RECEIVE_WINDOW)!!.isVisible = true findPreference(Key.SERVER_DISABLE_MTU_DISCOVERY)!!.isVisible = true // authPayload.title = resources.getString(R.string.hysteria_auth_payload) } } findPreference(Key.PROTOCOL_VERSION)!!.setOnPreferenceChangeListener { _, newValue -> updateVersion(newValue.toString().toIntOrNull() ?: 1) true } updateVersion(DataStore.protocolVersion) findPreference(Key.SERVER_UPLOAD_SPEED)!!.apply { setOnBindEditTextListener(EditTextPreferenceModifiers.Number) } findPreference(Key.SERVER_DOWNLOAD_SPEED)!!.apply { setOnBindEditTextListener(EditTextPreferenceModifiers.Number) } findPreference(Key.SERVER_STREAM_RECEIVE_WINDOW)!!.apply { setOnBindEditTextListener(EditTextPreferenceModifiers.Number) } findPreference(Key.SERVER_CONNECTION_RECEIVE_WINDOW)!!.apply { setOnBindEditTextListener(EditTextPreferenceModifiers.Number) } findPreference(Key.SERVER_PASSWORD)!!.apply { summaryProvider = PasswordSummaryProvider } findPreference(Key.SERVER_OBFS)!!.apply { summaryProvider = PasswordSummaryProvider } findPreference(Key.SERVER_HOP_INTERVAL)!!.apply { setOnBindEditTextListener(EditTextPreferenceModifiers.Number) } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ui/profile/MieruSettingsActivity.kt ================================================ /****************************************************************************** * * * Copyright (C) 2021 by nekohasekai * * * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . * * * ******************************************************************************/ package io.nekohasekai.sagernet.ui.profile import android.os.Bundle import androidx.preference.EditTextPreference import androidx.preference.PreferenceFragmentCompat import io.nekohasekai.sagernet.Key import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.database.preference.EditTextPreferenceModifiers import io.nekohasekai.sagernet.fmt.mieru.MieruBean import io.nekohasekai.sagernet.ktx.applyDefaultValues import moe.matsuri.nb4a.ui.SimpleMenuPreference class MieruSettingsActivity : ProfileSettingsActivity() { override fun createEntity() = MieruBean().applyDefaultValues() override fun MieruBean.init() { DataStore.profileName = name DataStore.serverAddress = serverAddress DataStore.serverPort = serverPort DataStore.serverProtocol = protocol DataStore.serverUsername = username DataStore.serverPassword = password DataStore.serverMTU = mtu } override fun MieruBean.serialize() { name = DataStore.profileName serverAddress = DataStore.serverAddress serverPort = DataStore.serverPort protocol = DataStore.serverProtocol username = DataStore.serverUsername password = DataStore.serverPassword mtu = DataStore.serverMTU } override fun PreferenceFragmentCompat.createPreferences( savedInstanceState: Bundle?, rootKey: String?, ) { addPreferencesFromResource(R.xml.mieru_preferences) findPreference(Key.SERVER_PORT)!!.apply { setOnBindEditTextListener(EditTextPreferenceModifiers.Port) } findPreference(Key.SERVER_PASSWORD)!!.apply { summaryProvider = PasswordSummaryProvider } val protocol = findPreference(Key.SERVER_PROTOCOL)!! val mtu = findPreference(Key.SERVER_MTU)!! mtu.isVisible = protocol.value.equals("UDP") protocol.setOnPreferenceChangeListener { _, newValue -> mtu.isVisible = newValue.equals("UDP") true } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ui/profile/NaiveSettingsActivity.kt ================================================ package io.nekohasekai.sagernet.ui.profile import android.os.Bundle import androidx.preference.EditTextPreference import androidx.preference.PreferenceFragmentCompat import io.nekohasekai.sagernet.Key import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.database.preference.EditTextPreferenceModifiers import io.nekohasekai.sagernet.fmt.naive.NaiveBean class NaiveSettingsActivity : ProfileSettingsActivity() { override fun createEntity() = NaiveBean() override fun NaiveBean.init() { DataStore.profileName = name DataStore.serverAddress = serverAddress DataStore.serverPort = serverPort DataStore.serverUsername = username DataStore.serverPassword = password DataStore.serverProtocol = proto DataStore.serverSNI = sni DataStore.serverCertificates = certificates DataStore.serverHeaders = extraHeaders DataStore.serverInsecureConcurrency = insecureConcurrency DataStore.profileCacheStore.putBoolean("sUoT", sUoT) } override fun NaiveBean.serialize() { name = DataStore.profileName serverAddress = DataStore.serverAddress serverPort = DataStore.serverPort username = DataStore.serverUsername password = DataStore.serverPassword proto = DataStore.serverProtocol sni = DataStore.serverSNI certificates = DataStore.serverCertificates extraHeaders = DataStore.serverHeaders.replace("\r\n", "\n") insecureConcurrency = DataStore.serverInsecureConcurrency sUoT = DataStore.profileCacheStore.getBoolean("sUoT") } override fun PreferenceFragmentCompat.createPreferences( savedInstanceState: Bundle?, rootKey: String?, ) { addPreferencesFromResource(R.xml.naive_preferences) findPreference(Key.SERVER_PORT)!!.apply { setOnBindEditTextListener(EditTextPreferenceModifiers.Port) } findPreference(Key.SERVER_PASSWORD)!!.apply { summaryProvider = PasswordSummaryProvider } findPreference(Key.SERVER_INSECURE_CONCURRENCY)!!.apply { setOnBindEditTextListener(EditTextPreferenceModifiers.Number) } } override fun finish() { if (DataStore.profileName == "喵要打开隐藏功能") { DataStore.isExpert = true } else if (DataStore.profileName == "喵要关闭隐藏功能") { DataStore.isExpert = false } super.finish() } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ui/profile/ProfileSettingsActivity.kt ================================================ package io.nekohasekai.sagernet.ui.profile import android.annotation.SuppressLint import android.content.DialogInterface import android.content.Intent import android.os.Build import android.os.Bundle import android.os.Parcelable import android.view.Menu import android.view.MenuItem import android.view.View import android.widget.LinearLayout import android.widget.ScrollView import android.widget.Toast import androidx.activity.result.component1 import androidx.activity.result.component2 import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.LayoutRes import androidx.appcompat.app.AlertDialog import androidx.core.content.pm.ShortcutInfoCompat import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.graphics.drawable.IconCompat import androidx.core.view.ViewCompat import androidx.core.view.isVisible import androidx.preference.EditTextPreference import androidx.preference.Preference import androidx.preference.PreferenceDataStore import androidx.preference.PreferenceFragmentCompat import com.github.shadowsocks.plugin.Empty import com.github.shadowsocks.plugin.fragment.AlertDialogFragment import com.google.android.material.dialog.MaterialAlertDialogBuilder import io.nekohasekai.sagernet.* import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.database.GroupManager import io.nekohasekai.sagernet.database.ProfileManager import io.nekohasekai.sagernet.database.SagerDatabase import io.nekohasekai.sagernet.database.preference.OnPreferenceDataStoreChangeListener import io.nekohasekai.sagernet.databinding.LayoutGroupItemBinding import io.nekohasekai.sagernet.fmt.AbstractBean import io.nekohasekai.sagernet.ktx.* import io.nekohasekai.sagernet.ui.ThemedActivity import io.nekohasekai.sagernet.widget.ListListener import kotlinx.parcelize.Parcelize import kotlin.properties.Delegates @Suppress("UNCHECKED_CAST") abstract class ProfileSettingsActivity( @LayoutRes resId: Int = R.layout.layout_config_settings, ) : ThemedActivity(resId), OnPreferenceDataStoreChangeListener { class UnsavedChangesDialogFragment : AlertDialogFragment() { override fun AlertDialog.Builder.prepare(listener: DialogInterface.OnClickListener) { setTitle(R.string.unsaved_changes_prompt) setPositiveButton(R.string.yes) { _, _ -> runOnDefaultDispatcher { (requireActivity() as ProfileSettingsActivity<*>).saveAndExit() } } setNegativeButton(R.string.no) { _, _ -> requireActivity().finish() } setNeutralButton(android.R.string.cancel, null) } } @Parcelize data class ProfileIdArg(val profileId: Long, val groupId: Long) : Parcelable class DeleteConfirmationDialogFragment : AlertDialogFragment() { override fun AlertDialog.Builder.prepare(listener: DialogInterface.OnClickListener) { setTitle(R.string.delete_confirm_prompt) setPositiveButton(R.string.yes) { _, _ -> runOnDefaultDispatcher { ProfileManager.deleteProfile(arg.groupId, arg.profileId) } requireActivity().finish() } setNegativeButton(R.string.no, null) } } companion object { const val EXTRA_PROFILE_ID = "id" const val EXTRA_IS_SUBSCRIPTION = "sub" } abstract fun createEntity(): T abstract fun T.init() abstract fun T.serialize() val proxyEntity by lazy { SagerDatabase.proxyDao.getById(DataStore.editingId) } protected var isSubscription by Delegates.notNull() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setSupportActionBar(findViewById(R.id.toolbar)) supportActionBar?.apply { setTitle(R.string.profile_config) setDisplayHomeAsUpEnabled(true) setHomeAsUpIndicator(R.drawable.ic_navigation_close) } if (savedInstanceState == null) { val editingId = intent.getLongExtra(EXTRA_PROFILE_ID, 0L) isSubscription = intent.getBooleanExtra(EXTRA_IS_SUBSCRIPTION, false) DataStore.editingId = editingId runOnDefaultDispatcher { if (editingId == 0L) { DataStore.editingGroup = DataStore.selectedGroupForImport() createEntity().applyDefaultValues().init() } else { if (proxyEntity == null) { onMainDispatcher { finish() } return@runOnDefaultDispatcher } DataStore.editingGroup = proxyEntity!!.groupId (proxyEntity!!.requireBean() as T).init() } onMainDispatcher { supportFragmentManager.beginTransaction() .replace(R.id.settings, MyPreferenceFragmentCompat()) .commit() } } } } open suspend fun saveAndExit() { val editingId = DataStore.editingId if (editingId == 0L) { val editingGroup = DataStore.editingGroup ProfileManager.createProfile(editingGroup, createEntity().apply { serialize() }) } else { if (proxyEntity == null) { finish() return } if (proxyEntity!!.id == DataStore.selectedProxy) { SagerNet.stopService() } ProfileManager.updateProfile(proxyEntity!!.apply { (requireBean() as T).serialize() }) } finish() } val child by lazy { supportFragmentManager.findFragmentById(R.id.settings) as MyPreferenceFragmentCompat } override fun onCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.profile_config_menu, menu) menu.findItem(R.id.action_move)?.apply { if (DataStore.editingId != 0L // not new profile && SagerDatabase.groupDao.getById(DataStore.editingGroup)?.type == GroupType.BASIC // not in subscription group && SagerDatabase.groupDao.allGroups() .filter { it.type == GroupType.BASIC }.size > 1 // have other basic group ) isVisible = true } menu.findItem(R.id.action_create_shortcut)?.apply { if (Build.VERSION.SDK_INT >= 26 && DataStore.editingId != 0L) { isVisible = true // not new profile } } // shared menu item menu.findItem(R.id.action_custom_outbound_json)?.isVisible = true menu.findItem(R.id.action_custom_config_json)?.isVisible = true return true } override fun onOptionsItemSelected(item: MenuItem) = child.onOptionsItemSelected(item) override fun onBackPressed() { if (DataStore.dirty) UnsavedChangesDialogFragment().apply { key() } .show(supportFragmentManager, null) else super.onBackPressed() } override fun onSupportNavigateUp(): Boolean { if (!super.onSupportNavigateUp()) finish() return true } override fun onDestroy() { DataStore.profileCacheStore.unregisterChangeListener(this) super.onDestroy() } override fun onPreferenceDataStoreChanged(store: PreferenceDataStore, key: String) { if (key != Key.PROFILE_DIRTY) { DataStore.dirty = true } } abstract fun PreferenceFragmentCompat.createPreferences( savedInstanceState: Bundle?, rootKey: String?, ) open fun PreferenceFragmentCompat.viewCreated(view: View, savedInstanceState: Bundle?) { } open fun PreferenceFragmentCompat.displayPreferenceDialog(preference: Preference): Boolean { return false } class MyPreferenceFragmentCompat : PreferenceFragmentCompat() { var activity: ProfileSettingsActivity<*>? = null override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { preferenceManager.preferenceDataStore = DataStore.profileCacheStore try { activity = (requireActivity() as ProfileSettingsActivity<*>).apply { createPreferences(savedInstanceState, rootKey) } } catch (e: Exception) { Toast.makeText( SagerNet.application, "Error on createPreferences, please try again.", Toast.LENGTH_SHORT ).show() Logs.e(e) } } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) ViewCompat.setOnApplyWindowInsetsListener(listView, ListListener) activity?.apply { viewCreated(view, savedInstanceState) DataStore.dirty = false DataStore.profileCacheStore.registerChangeListener(this) } } var callbackCustom: ((String) -> Unit)? = null var callbackCustomOutbound: ((String) -> Unit)? = null val resultCallbackCustom = registerForActivityResult( ActivityResultContracts.StartActivityForResult() ) { (_, _) -> callbackCustom?.let { it(DataStore.serverCustom) } } val resultCallbackCustomOutbound = registerForActivityResult( ActivityResultContracts.StartActivityForResult() ) { (_, _) -> callbackCustomOutbound?.let { it(DataStore.serverCustomOutbound) } } @SuppressLint("CheckResult") override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) { R.id.action_delete -> { if (DataStore.editingId == 0L) { requireActivity().finish() } else { DeleteConfirmationDialogFragment().apply { arg( ProfileIdArg( DataStore.editingId, DataStore.editingGroup ) ) key() }.show(parentFragmentManager, null) } true } R.id.action_apply -> { runOnDefaultDispatcher { activity?.saveAndExit() } true } R.id.action_custom_outbound_json -> { activity?.proxyEntity?.apply { val bean = requireBean() DataStore.serverCustomOutbound = bean.customOutboundJson callbackCustomOutbound = { bean.customOutboundJson = it } resultCallbackCustomOutbound.launch( Intent( requireContext(), ConfigEditActivity::class.java ).apply { putExtra("key", Key.SERVER_CUSTOM_OUTBOUND) }) } true } R.id.action_custom_config_json -> { activity?.proxyEntity?.apply { val bean = requireBean() DataStore.serverCustom = bean.customConfigJson callbackCustom = { bean.customConfigJson = it } resultCallbackCustom.launch( Intent( requireContext(), ConfigEditActivity::class.java ).apply { putExtra("key", Key.SERVER_CUSTOM) }) } true } R.id.action_create_shortcut -> { val activity = requireActivity() as ProfileSettingsActivity<*> val ent = activity.proxyEntity!! val shortcut = ShortcutInfoCompat.Builder(activity, "shortcut-profile-${ent.id}") .setShortLabel(ent.displayName()) .setLongLabel(ent.displayName()) .setIcon( IconCompat.createWithResource( activity, R.drawable.ic_qu_shadowsocks_launcher ) ).setIntent(Intent( context, QuickToggleShortcut::class.java ).apply { action = Intent.ACTION_MAIN putExtra("profile", ent.id) }).build() ShortcutManagerCompat.requestPinShortcut(activity, shortcut, null) } R.id.action_move -> { val activity = requireActivity() as ProfileSettingsActivity<*> val view = LinearLayout(context).apply { val ent = activity.proxyEntity!! orientation = LinearLayout.VERTICAL SagerDatabase.groupDao.allGroups() .filter { it.type == GroupType.BASIC && it.id != ent.groupId } .forEach { group -> LayoutGroupItemBinding.inflate(layoutInflater, this, true).apply { edit.isVisible = false options.isVisible = false groupName.text = group.displayName() groupUpdate.text = getString(R.string.move) groupUpdate.setOnClickListener { runOnDefaultDispatcher { val oldGroupId = ent.groupId val newGroupId = group.id ent.groupId = newGroupId ProfileManager.updateProfile(ent) GroupManager.postUpdate(oldGroupId) // reload GroupManager.postUpdate(newGroupId) DataStore.editingGroup = newGroupId // post switch animation runOnMainDispatcher { activity.finish() } } } } } } val scrollView = ScrollView(context).apply { addView(view) } MaterialAlertDialogBuilder(activity).setView(scrollView).show() true } else -> false } override fun onDisplayPreferenceDialog(preference: Preference) { activity?.apply { if (displayPreferenceDialog(preference)) return } super.onDisplayPreferenceDialog(preference) } } object PasswordSummaryProvider : Preference.SummaryProvider { override fun provideSummary(preference: EditTextPreference): CharSequence { val text = preference.text return if (text.isNullOrBlank()) { preference.context.getString(androidx.preference.R.string.not_set) } else { "\u2022".repeat(text.length) } } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ui/profile/SSHSettingsActivity.kt ================================================ package io.nekohasekai.sagernet.ui.profile import android.os.Bundle import androidx.preference.EditTextPreference import androidx.preference.PreferenceFragmentCompat import io.nekohasekai.sagernet.Key import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.database.preference.EditTextPreferenceModifiers import io.nekohasekai.sagernet.fmt.ssh.SSHBean import moe.matsuri.nb4a.ui.SimpleMenuPreference class SSHSettingsActivity : ProfileSettingsActivity() { override fun createEntity() = SSHBean() override fun SSHBean.init() { DataStore.profileName = name DataStore.serverAddress = serverAddress DataStore.serverPort = serverPort DataStore.serverUsername = username DataStore.serverAuthType = authType DataStore.serverPassword = password DataStore.serverPrivateKey = privateKey DataStore.serverPassword1 = privateKeyPassphrase DataStore.serverCertificates = publicKey } override fun SSHBean.serialize() { name = DataStore.profileName serverAddress = DataStore.serverAddress serverPort = DataStore.serverPort username = DataStore.serverUsername authType = DataStore.serverAuthType when (authType) { SSHBean.AUTH_TYPE_NONE -> { } SSHBean.AUTH_TYPE_PASSWORD -> { password = DataStore.serverPassword } SSHBean.AUTH_TYPE_PRIVATE_KEY -> { privateKey = DataStore.serverPrivateKey privateKeyPassphrase = DataStore.serverPassword1 } } publicKey = DataStore.serverCertificates } override fun PreferenceFragmentCompat.createPreferences( savedInstanceState: Bundle?, rootKey: String?, ) { addPreferencesFromResource(R.xml.ssh_preferences) findPreference(Key.SERVER_PORT)!!.apply { setOnBindEditTextListener(EditTextPreferenceModifiers.Port) } val password = findPreference(Key.SERVER_PASSWORD)!!.apply { summaryProvider = PasswordSummaryProvider } val privateKey = findPreference(Key.SERVER_PRIVATE_KEY)!! val privateKeyPassphrase = findPreference(Key.SERVER_PASSWORD1)!!.apply { summaryProvider = PasswordSummaryProvider } val authType = findPreference(Key.SERVER_AUTH_TYPE)!! fun updateAuthType(type: Int = DataStore.serverAuthType) { password.isVisible = type == SSHBean.AUTH_TYPE_PASSWORD privateKey.isVisible = type == SSHBean.AUTH_TYPE_PRIVATE_KEY privateKeyPassphrase.isVisible = type == SSHBean.AUTH_TYPE_PRIVATE_KEY } updateAuthType() authType.setOnPreferenceChangeListener { _, newValue -> updateAuthType((newValue as String).toInt()) true } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ui/profile/ShadowsocksSettingsActivity.kt ================================================ package io.nekohasekai.sagernet.ui.profile import android.os.Bundle import androidx.preference.EditTextPreference import androidx.preference.PreferenceFragmentCompat import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.database.preference.EditTextPreferenceModifiers import io.nekohasekai.sagernet.fmt.shadowsocks.ShadowsocksBean import moe.matsuri.nb4a.proxy.PreferenceBinding import moe.matsuri.nb4a.proxy.PreferenceBindingManager import moe.matsuri.nb4a.proxy.Type class ShadowsocksSettingsActivity : ProfileSettingsActivity() { override fun createEntity() = ShadowsocksBean() private val pbm = PreferenceBindingManager() private val name = pbm.add(PreferenceBinding(Type.Text, "name")) private val serverAddress = pbm.add(PreferenceBinding(Type.Text, "serverAddress")) private val serverPort = pbm.add(PreferenceBinding(Type.TextToInt, "serverPort")) private val password = pbm.add(PreferenceBinding(Type.Text, "password")) private val method = pbm.add(PreferenceBinding(Type.Text, "method")) private val pluginName = pbm.add(PreferenceBinding(Type.Text, "pluginName").apply { disable = true }) private val pluginConfig = pbm.add(PreferenceBinding(Type.Text, "pluginConfig").apply { disable = true }) private val sUoT = pbm.add(PreferenceBinding(Type.Bool, "sUoT")) override fun ShadowsocksBean.init() { pbm.writeToCacheAll(this) DataStore.profileCacheStore.putString("pluginName", plugin.substringBefore(";")) DataStore.profileCacheStore.putString("pluginConfig", plugin.substringAfter(";")) } override fun ShadowsocksBean.serialize() { pbm.fromCacheAll(this) val pn = pluginName.readStringFromCache() val pc = pluginConfig.readStringFromCache() plugin = if (pn.isNotBlank()) "$pn;$pc" else "" } override fun PreferenceFragmentCompat.createPreferences( savedInstanceState: Bundle?, rootKey: String?, ) { addPreferencesFromResource(R.xml.shadowsocks_preferences) pbm.setPreferenceFragment(this) serverPort.preference.apply { this as EditTextPreference setOnBindEditTextListener(EditTextPreferenceModifiers.Port) } password.preference.apply { this as EditTextPreference summaryProvider = PasswordSummaryProvider } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ui/profile/SocksSettingsActivity.kt ================================================ package io.nekohasekai.sagernet.ui.profile import android.os.Bundle import androidx.preference.EditTextPreference import androidx.preference.PreferenceFragmentCompat import io.nekohasekai.sagernet.Key import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.database.preference.EditTextPreferenceModifiers import io.nekohasekai.sagernet.fmt.socks.SOCKSBean import moe.matsuri.nb4a.ui.SimpleMenuPreference class SocksSettingsActivity : ProfileSettingsActivity() { override fun createEntity() = SOCKSBean() override fun SOCKSBean.init() { DataStore.profileName = name DataStore.serverAddress = serverAddress DataStore.serverPort = serverPort DataStore.serverProtocolInt = protocol DataStore.serverUsername = username DataStore.serverPassword = password DataStore.profileCacheStore.putBoolean("sUoT", sUoT) } override fun SOCKSBean.serialize() { name = DataStore.profileName serverAddress = DataStore.serverAddress serverPort = DataStore.serverPort protocol = DataStore.serverProtocolInt username = DataStore.serverUsername password = DataStore.serverPassword sUoT = DataStore.profileCacheStore.getBoolean("sUoT") } override fun PreferenceFragmentCompat.createPreferences( savedInstanceState: Bundle?, rootKey: String?, ) { addPreferencesFromResource(R.xml.socks_preferences) findPreference(Key.SERVER_PORT)!!.apply { setOnBindEditTextListener(EditTextPreferenceModifiers.Port) } val password = findPreference(Key.SERVER_PASSWORD)!!.apply { summaryProvider = PasswordSummaryProvider } val protocol = findPreference(Key.SERVER_PROTOCOL)!! fun updateProtocol(version: Int) { password.isVisible = version == SOCKSBean.PROTOCOL_SOCKS5 } updateProtocol(DataStore.protocolVersion) protocol.setOnPreferenceChangeListener { _, newValue -> updateProtocol((newValue as String).toInt()) true } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ui/profile/StandardV2RaySettingsActivity.kt ================================================ package io.nekohasekai.sagernet.ui.profile import android.os.Bundle import androidx.preference.EditTextPreference import androidx.preference.PreferenceCategory import androidx.preference.PreferenceFragmentCompat import io.nekohasekai.sagernet.Key import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.database.preference.EditTextPreferenceModifiers import io.nekohasekai.sagernet.fmt.http.HttpBean import io.nekohasekai.sagernet.fmt.trojan.TrojanBean import io.nekohasekai.sagernet.fmt.v2ray.StandardV2RayBean import io.nekohasekai.sagernet.fmt.v2ray.VMessBean import moe.matsuri.nb4a.proxy.PreferenceBinding import moe.matsuri.nb4a.proxy.PreferenceBindingManager import moe.matsuri.nb4a.proxy.Type import moe.matsuri.nb4a.ui.SimpleMenuPreference abstract class StandardV2RaySettingsActivity : ProfileSettingsActivity() { var tmpBean: StandardV2RayBean? = null private val pbm = PreferenceBindingManager() private val name = pbm.add(PreferenceBinding(Type.Text, "name")) private val serverAddress = pbm.add(PreferenceBinding(Type.Text, "serverAddress")) private val serverPort = pbm.add(PreferenceBinding(Type.TextToInt, "serverPort")) private val uuid = pbm.add(PreferenceBinding(Type.Text, "uuid")) private val username = pbm.add(PreferenceBinding(Type.Text, "username")) private val password = pbm.add(PreferenceBinding(Type.Text, "password")) private val alterId = pbm.add(PreferenceBinding(Type.TextToInt, "alterId")) private val encryption = pbm.add(PreferenceBinding(Type.Text, "encryption")) private val type = pbm.add(PreferenceBinding(Type.Text, "type")) private val host = pbm.add(PreferenceBinding(Type.Text, "host")) private val path = pbm.add(PreferenceBinding(Type.Text, "path")) private val packetEncoding = pbm.add(PreferenceBinding(Type.TextToInt, "packetEncoding")) private val wsMaxEarlyData = pbm.add(PreferenceBinding(Type.TextToInt, "wsMaxEarlyData")) private val earlyDataHeaderName = pbm.add(PreferenceBinding(Type.Text, "earlyDataHeaderName")) private val security = pbm.add(PreferenceBinding(Type.Text, "security")) private val sni = pbm.add(PreferenceBinding(Type.Text, "sni")) private val alpn = pbm.add(PreferenceBinding(Type.Text, "alpn")) private val certificates = pbm.add(PreferenceBinding(Type.Text, "certificates")) private val allowInsecure = pbm.add(PreferenceBinding(Type.Bool, "allowInsecure")) private val utlsFingerprint = pbm.add(PreferenceBinding(Type.Text, "utlsFingerprint")) private val realityPubKey = pbm.add(PreferenceBinding(Type.Text, "realityPubKey")) private val realityShortId = pbm.add(PreferenceBinding(Type.Text, "realityShortId")) private val enableECH = pbm.add(PreferenceBinding(Type.Bool, "enableECH")) private val echConfig = pbm.add(PreferenceBinding(Type.Text, "echConfig")) private val enableMux = pbm.add(PreferenceBinding(Type.Bool, "enableMux")) private val muxPadding = pbm.add(PreferenceBinding(Type.Bool, "muxPadding")) private val muxType = pbm.add(PreferenceBinding(Type.TextToInt, "muxType")) private val muxConcurrency = pbm.add(PreferenceBinding(Type.TextToInt, "muxConcurrency")) override fun StandardV2RayBean.init() { if (this is TrojanBean) { this@StandardV2RaySettingsActivity.uuid.fieldName = "password" this@StandardV2RaySettingsActivity.password.disable = true } tmpBean = this // copy bean pbm.writeToCacheAll(this) } override fun StandardV2RayBean.serialize() { pbm.fromCacheAll(this) } private lateinit var securityCategory: PreferenceCategory private lateinit var tlsCamouflageCategory: PreferenceCategory private lateinit var wsCategory: PreferenceCategory private lateinit var echCategory: PreferenceCategory override fun PreferenceFragmentCompat.createPreferences( savedInstanceState: Bundle?, rootKey: String?, ) { addPreferencesFromResource(R.xml.standard_v2ray_preferences) pbm.setPreferenceFragment(this) securityCategory = findPreference(Key.SERVER_SECURITY_CATEGORY)!! tlsCamouflageCategory = findPreference(Key.SERVER_TLS_CAMOUFLAGE_CATEGORY)!! echCategory = findPreference(Key.SERVER_ECH_CATEORY)!! wsCategory = findPreference(Key.SERVER_WS_CATEGORY)!! // vmess/vless/http/trojan val isHttp = tmpBean is HttpBean val isVmess = tmpBean is VMessBean && tmpBean?.isVLESS == false val isVless = tmpBean?.isVLESS == true serverPort.preference.apply { this as EditTextPreference setOnBindEditTextListener(EditTextPreferenceModifiers.Port) } alterId.preference.apply { this as EditTextPreference setOnBindEditTextListener(EditTextPreferenceModifiers.Port) } uuid.preference.summaryProvider = PasswordSummaryProvider type.preference.isVisible = !isHttp uuid.preference.isVisible = !isHttp packetEncoding.preference.isVisible = isVmess || isVless alterId.preference.isVisible = isVmess encryption.preference.isVisible = isVmess || isVless username.preference.isVisible = isHttp password.preference.isVisible = isHttp if (tmpBean is TrojanBean) { uuid.preference.title = resources.getString(R.string.password) } encryption.preference.apply { this as SimpleMenuPreference if (isVless) { title = resources.getString(R.string.xtls_flow) setIcon(R.drawable.ic_baseline_stream_24) setEntries(R.array.xtls_flow_value) setEntryValues(R.array.xtls_flow_value) } else { setEntries(R.array.vmess_encryption_value) setEntryValues(R.array.vmess_encryption_value) } } // menu with listener type.preference.apply { updateView(type.readStringFromCache()) this as SimpleMenuPreference setOnPreferenceChangeListener { _, newValue -> updateView(newValue as String) true } } security.preference.apply { updateTls(security.readStringFromCache()) this as SimpleMenuPreference setOnPreferenceChangeListener { _, newValue -> updateTls(newValue as String) true } } } private fun updateView(network: String) { host.preference.isVisible = false path.preference.isVisible = false wsCategory.isVisible = false when (network) { "tcp" -> { host.preference.setTitle(R.string.http_host) path.preference.setTitle(R.string.http_path) } "http" -> { host.preference.setTitle(R.string.http_host) path.preference.setTitle(R.string.http_path) host.preference.isVisible = true path.preference.isVisible = true } "ws" -> { host.preference.setTitle(R.string.ws_host) path.preference.setTitle(R.string.ws_path) host.preference.isVisible = true path.preference.isVisible = true wsCategory.isVisible = true } "grpc" -> { path.preference.setTitle(R.string.grpc_service_name) path.preference.isVisible = true } "httpupgrade" -> { host.preference.setTitle(R.string.http_upgrade_host) path.preference.setTitle(R.string.http_upgrade_path) host.preference.isVisible = true path.preference.isVisible = true } } } private fun updateTls(tls: String) { val isTLS = "tls" in tls securityCategory.isVisible = isTLS tlsCamouflageCategory.isVisible = isTLS echCategory.isVisible = isTLS } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ui/profile/TrojanGoSettingsActivity.kt ================================================ package io.nekohasekai.sagernet.ui.profile import android.os.Bundle import androidx.preference.EditTextPreference import androidx.preference.PreferenceCategory import androidx.preference.PreferenceFragmentCompat import io.nekohasekai.sagernet.Key import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.database.preference.EditTextPreferenceModifiers import io.nekohasekai.sagernet.fmt.trojan_go.TrojanGoBean import io.nekohasekai.sagernet.ktx.app import moe.matsuri.nb4a.ui.SimpleMenuPreference class TrojanGoSettingsActivity : ProfileSettingsActivity() { override fun createEntity() = TrojanGoBean() override fun TrojanGoBean.init() { DataStore.profileName = name DataStore.serverAddress = serverAddress DataStore.serverPort = serverPort DataStore.serverPassword = password DataStore.serverSNI = sni DataStore.serverAllowInsecure = allowInsecure DataStore.serverNetwork = type DataStore.serverHost = host DataStore.serverPath = path if (encryption.startsWith("ss;")) { DataStore.serverEncryption = "ss" DataStore.serverMethod = encryption.substringAfter(";").substringBefore(":") DataStore.serverPassword1 = encryption.substringAfter(":") } else { DataStore.serverEncryption = encryption } } override fun TrojanGoBean.serialize() { name = DataStore.profileName serverAddress = DataStore.serverAddress serverPort = DataStore.serverPort password = DataStore.serverPassword sni = DataStore.serverSNI allowInsecure = DataStore.serverAllowInsecure type = DataStore.serverNetwork host = DataStore.serverHost path = DataStore.serverPath encryption = when (val security = DataStore.serverEncryption) { "ss" -> { "ss;" + DataStore.serverMethod + ":" + DataStore.serverPassword1 } else -> { security } } } lateinit var network: SimpleMenuPreference lateinit var encryprtion: SimpleMenuPreference lateinit var wsCategory: PreferenceCategory lateinit var ssCategory: PreferenceCategory lateinit var method: SimpleMenuPreference val trojanGoMethods = app.resources.getStringArray(R.array.trojan_go_methods) val trojanGoNetworks = app.resources.getStringArray(R.array.trojan_go_networks_value) override fun PreferenceFragmentCompat.createPreferences( savedInstanceState: Bundle?, rootKey: String?, ) { addPreferencesFromResource(R.xml.trojan_go_preferences) findPreference(Key.SERVER_PORT)!!.apply { setOnBindEditTextListener(EditTextPreferenceModifiers.Port) } findPreference(Key.SERVER_PASSWORD)!!.apply { summaryProvider = PasswordSummaryProvider } findPreference(Key.SERVER_PASSWORD1)!!.apply { summaryProvider = PasswordSummaryProvider } wsCategory = findPreference(Key.SERVER_WS_CATEGORY)!! ssCategory = findPreference(Key.SERVER_SS_CATEGORY)!! method = findPreference(Key.SERVER_METHOD)!! network = findPreference(Key.SERVER_NETWORK)!! if (network.value !in trojanGoNetworks) { network.value = trojanGoNetworks[0] } updateNetwork(network.value) network.setOnPreferenceChangeListener { _, newValue -> updateNetwork(newValue as String) true } encryprtion = findPreference(Key.SERVER_ENCRYPTION)!! updateEncryption(encryprtion.value) encryprtion.setOnPreferenceChangeListener { _, newValue -> updateEncryption(newValue as String) true } } fun updateNetwork(newNet: String) { when (newNet) { "ws" -> { wsCategory.isVisible = true } else -> { wsCategory.isVisible = false } } } fun updateEncryption(encryption: String) { when (encryption) { "ss" -> { ssCategory.isVisible = true if (method.value !in trojanGoMethods) { method.value = trojanGoMethods[0] } } else -> { ssCategory.isVisible = false } } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ui/profile/TrojanSettingsActivity.kt ================================================ package io.nekohasekai.sagernet.ui.profile import io.nekohasekai.sagernet.fmt.trojan.TrojanBean class TrojanSettingsActivity : StandardV2RaySettingsActivity() { override fun createEntity() = TrojanBean() } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ui/profile/TuicSettingsActivity.kt ================================================ package io.nekohasekai.sagernet.ui.profile import android.os.Bundle import androidx.preference.EditTextPreference import androidx.preference.PreferenceFragmentCompat import androidx.preference.SwitchPreference import io.nekohasekai.sagernet.Key import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.fmt.tuic.TuicBean import io.nekohasekai.sagernet.ktx.applyDefaultValues class TuicSettingsActivity : ProfileSettingsActivity() { override fun createEntity() = TuicBean().applyDefaultValues() override fun TuicBean.init() { DataStore.profileName = name DataStore.serverAddress = serverAddress DataStore.serverPort = serverPort DataStore.serverUsername = uuid DataStore.serverPassword = token DataStore.serverALPN = alpn DataStore.serverCertificates = caText DataStore.serverUDPRelayMode = udpRelayMode DataStore.serverCongestionController = congestionController DataStore.serverDisableSNI = disableSNI DataStore.serverSNI = sni DataStore.serverReduceRTT = reduceRTT DataStore.serverAllowInsecure = allowInsecure } override fun TuicBean.serialize() { name = DataStore.profileName serverAddress = DataStore.serverAddress serverPort = DataStore.serverPort uuid = DataStore.serverUsername token = DataStore.serverPassword alpn = DataStore.serverALPN caText = DataStore.serverCertificates udpRelayMode = DataStore.serverUDPRelayMode congestionController = DataStore.serverCongestionController disableSNI = DataStore.serverDisableSNI sni = DataStore.serverSNI reduceRTT = DataStore.serverReduceRTT allowInsecure = DataStore.serverAllowInsecure } override fun PreferenceFragmentCompat.createPreferences( savedInstanceState: Bundle?, rootKey: String?, ) { addPreferencesFromResource(R.xml.tuic_preferences) val disableSNI = findPreference(Key.SERVER_DISABLE_SNI)!! val sni = findPreference(Key.SERVER_SNI)!! sni.isEnabled = !disableSNI.isChecked disableSNI.setOnPreferenceChangeListener { _, newValue -> sni.isEnabled = !(newValue as Boolean) true } findPreference(Key.SERVER_PASSWORD)!!.apply { summaryProvider = PasswordSummaryProvider } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ui/profile/VMessSettingsActivity.kt ================================================ package io.nekohasekai.sagernet.ui.profile import io.nekohasekai.sagernet.fmt.v2ray.VMessBean class VMessSettingsActivity : StandardV2RaySettingsActivity() { override fun createEntity() = VMessBean().apply { if (intent?.getBooleanExtra("vless", false) == true) { alterId = -1 } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ui/profile/WireGuardSettingsActivity.kt ================================================ package io.nekohasekai.sagernet.ui.profile import android.os.Bundle import androidx.preference.EditTextPreference import androidx.preference.PreferenceFragmentCompat import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.database.preference.EditTextPreferenceModifiers import io.nekohasekai.sagernet.fmt.wireguard.WireGuardBean import moe.matsuri.nb4a.proxy.PreferenceBinding import moe.matsuri.nb4a.proxy.PreferenceBindingManager import moe.matsuri.nb4a.proxy.Type class WireGuardSettingsActivity : ProfileSettingsActivity() { override fun createEntity() = WireGuardBean() private val pbm = PreferenceBindingManager() private val name = pbm.add(PreferenceBinding(Type.Text, "name")) private val serverAddress = pbm.add(PreferenceBinding(Type.Text, "serverAddress")) private val serverPort = pbm.add(PreferenceBinding(Type.TextToInt, "serverPort")) private val localAddress = pbm.add(PreferenceBinding(Type.Text, "localAddress")) private val privateKey = pbm.add(PreferenceBinding(Type.Text, "privateKey")) private val peerPublicKey = pbm.add(PreferenceBinding(Type.Text, "peerPublicKey")) private val peerPreSharedKey = pbm.add(PreferenceBinding(Type.Text, "peerPreSharedKey")) private val mtu = pbm.add(PreferenceBinding(Type.TextToInt, "mtu")) private val reserved = pbm.add(PreferenceBinding(Type.Text, "reserved")) override fun WireGuardBean.init() { pbm.writeToCacheAll(this) } override fun WireGuardBean.serialize() { pbm.fromCacheAll(this) } override fun PreferenceFragmentCompat.createPreferences( savedInstanceState: Bundle?, rootKey: String?, ) { addPreferencesFromResource(R.xml.wireguard_preferences) pbm.setPreferenceFragment(this) (serverPort.preference as EditTextPreference) .setOnBindEditTextListener(EditTextPreferenceModifiers.Port) (privateKey.preference as EditTextPreference).summaryProvider = PasswordSummaryProvider (mtu.preference as EditTextPreference).setOnBindEditTextListener(EditTextPreferenceModifiers.Number) } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/utils/Commandline.kt ================================================ package io.nekohasekai.sagernet.utils import java.util.* /** * Commandline objects help handling command lines specifying processes to * execute. * * The class can be used to define a command line as nested elements or as a * helper to define a command line by an application. * * * ` *

*   

*     

*     

*     

*   


*


` * * * Based on: https://github.com/apache/ant/blob/588ce1f/src/main/org/apache/tools/ant/types/Commandline.java * * Adds support for escape character '\'. */ object Commandline { /** * Quote the parts of the given array in way that makes them * usable as command line arguments. * @param args the list of arguments to quote. * @return empty string for null or no command, else every argument split * by spaces and quoted by quoting rules. */ fun toString(args: Iterable?): String { // empty path return empty string args ?: return "" // path containing one or more elements val result = StringBuilder() for (arg in args) { if (result.isNotEmpty()) result.append(' ') arg.indices.map { arg[it] }.forEach { when (it) { ' ', '\\', '"', '\'' -> { result.append('\\') // intentionally no break result.append(it) } else -> result.append(it) } } } return result.toString() } /** * Quote the parts of the given array in way that makes them * usable as command line arguments. * @param args the list of arguments to quote. * @return empty string for null or no command, else every argument split * by spaces and quoted by quoting rules. */ fun toString(args: Array) = toString(args.asIterable()) // thanks to Java, arrays aren't iterable /** * Crack a command line. * @param toProcess the command line to process. * @return the command line broken into strings. * An empty or null toProcess parameter results in a zero sized array. */ fun translateCommandline(toProcess: String?): Array { if (toProcess == null || toProcess.isEmpty()) { //no command? no string return arrayOf() } // parse with a simple finite state machine val normal = 0 val inQuote = 1 val inDoubleQuote = 2 var state = normal val tok = StringTokenizer(toProcess, "\\\"\' ", true) val result = ArrayList() val current = StringBuilder() var lastTokenHasBeenQuoted = false var lastTokenIsSlash = false while (tok.hasMoreTokens()) { val nextTok = tok.nextToken() when (state) { inQuote -> if ("\'" == nextTok) { lastTokenHasBeenQuoted = true state = normal } else current.append(nextTok) inDoubleQuote -> when (nextTok) { "\"" -> if (lastTokenIsSlash) { current.append(nextTok) lastTokenIsSlash = false } else { lastTokenHasBeenQuoted = true state = normal } "\\" -> lastTokenIsSlash = if (lastTokenIsSlash) { current.append(nextTok) false } else true else -> { if (lastTokenIsSlash) { current.append("\\") // unescaped lastTokenIsSlash = false } current.append(nextTok) } } else -> { when { lastTokenIsSlash -> { current.append(nextTok) lastTokenIsSlash = false } "\\" == nextTok -> lastTokenIsSlash = true "\'" == nextTok -> state = inQuote "\"" == nextTok -> state = inDoubleQuote " " == nextTok -> if (lastTokenHasBeenQuoted || current.isNotEmpty()) { result.add(current.toString()) current.setLength(0) } else -> current.append(nextTok) } lastTokenHasBeenQuoted = false } } } if (lastTokenHasBeenQuoted || current.isNotEmpty()) result.add(current.toString()) require(state != inQuote && state != inDoubleQuote) { "unbalanced quotes in $toProcess" } require(!lastTokenIsSlash) { "escape character following nothing in $toProcess" } return result.toTypedArray() } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/utils/CrashHandler.kt ================================================ package io.nekohasekai.sagernet.utils import android.annotation.SuppressLint import android.content.Intent import android.os.Build import android.util.Log import com.jakewharton.processphoenix.ProcessPhoenix import io.nekohasekai.sagernet.BuildConfig import io.nekohasekai.sagernet.SagerNet import io.nekohasekai.sagernet.database.preference.PublicDatabase import io.nekohasekai.sagernet.ktx.Logs import io.nekohasekai.sagernet.ktx.app import io.nekohasekai.sagernet.ui.BlankActivity import java.io.BufferedReader import java.io.IOException import java.io.InputStreamReader import java.text.SimpleDateFormat import java.util.* import java.util.regex.Pattern object CrashHandler : Thread.UncaughtExceptionHandler { @Suppress("UNNECESSARY_SAFE_CALL") override fun uncaughtException(thread: Thread, throwable: Throwable) { // note: libc / go panic is in android log try { Log.e(thread.toString(), throwable.stackTraceToString()) } catch (e: Exception) { } try { Logs.e(thread.toString()) Logs.e(throwable.stackTraceToString()) } catch (e: Exception) { } ProcessPhoenix.triggerRebirth(app, Intent(app, BlankActivity::class.java).apply { putExtra("sendLog", "NB4A Crash") }) } fun formatThrowable(throwable: Throwable): String { var format = throwable.javaClass.name val message = throwable.message if (!message.isNullOrBlank()) { format += ": $message" } format += "\n" format += throwable.stackTrace.joinToString("\n") { " at ${it.className}.${it.methodName}(${it.fileName}:${if (it.isNativeMethod) "native" else it.lineNumber})" } val cause = throwable.cause if (cause != null) { format += "\n\nCaused by: " + formatThrowable(cause) } return format } fun buildReportHeader(): String { var report = "" report += "NekoBox for Android ${SagerNet.appVersionNameForDisplay} (${BuildConfig.VERSION_CODE})\n" report += "Date: ${getCurrentMilliSecondUTCTimeStamp()}\n\n" report += "OS_VERSION: ${getSystemPropertyWithAndroidAPI("os.version")}\n" report += "SDK_INT: ${Build.VERSION.SDK_INT}\n" report += if ("REL" == Build.VERSION.CODENAME) { "RELEASE: ${Build.VERSION.RELEASE}" } else { "CODENAME: ${Build.VERSION.CODENAME}" } + "\n" report += "ID: ${Build.ID}\n" report += "DISPLAY: ${Build.DISPLAY}\n" report += "INCREMENTAL: ${Build.VERSION.INCREMENTAL}\n" val systemProperties = getSystemProperties() report += "SECURITY_PATCH: ${systemProperties.getProperty("ro.build.version.security_patch")}\n" report += "IS_DEBUGGABLE: ${systemProperties.getProperty("ro.debuggable")}\n" report += "IS_EMULATOR: ${systemProperties.getProperty("ro.boot.qemu")}\n" report += "IS_TREBLE_ENABLED: ${systemProperties.getProperty("ro.treble.enabled")}\n" report += "TYPE: ${Build.TYPE}\n" report += "TAGS: ${Build.TAGS}\n\n" report += "MANUFACTURER: ${Build.MANUFACTURER}\n" report += "BRAND: ${Build.BRAND}\n" report += "MODEL: ${Build.MODEL}\n" report += "PRODUCT: ${Build.PRODUCT}\n" report += "BOARD: ${Build.BOARD}\n" report += "HARDWARE: ${Build.HARDWARE}\n" report += "DEVICE: ${Build.DEVICE}\n" report += "SUPPORTED_ABIS: ${ Build.SUPPORTED_ABIS.filter { it.isNotBlank() }.joinToString(", ") }\n\n" try { report += "Settings: \n" for (pair in PublicDatabase.kvPairDao.all()) { report += "\n" report += pair.key + ": " + pair.toString() } } catch (e: Exception) { report += "Export settings failed: " + formatThrowable(e) } report += "\n\n" return report } private fun getSystemProperties(): Properties { val systemProperties = Properties() // getprop commands returns values in the format `[key]: [value]` // Regex matches string starting with a literal `[`, // followed by one or more characters that do not match a closing square bracket as the key, // followed by a literal `]: [`, // followed by one or more characters as the value, // followed by string ending with literal `]` // multiline values will be ignored val propertiesPattern = Pattern.compile("^\\[([^]]+)]: \\[(.+)]$") try { val process = ProcessBuilder().command("/system/bin/getprop") .redirectErrorStream(true) .start() val inputStream = process.inputStream val bufferedReader = BufferedReader(InputStreamReader(inputStream)) var line: String? var key: String var value: String while (bufferedReader.readLine().also { line = it } != null) { val matcher = propertiesPattern.matcher(line) if (matcher.matches()) { key = matcher.group(1) value = matcher.group(2) if (key != null && value != null && !key.isEmpty() && !value.isEmpty()) systemProperties[key] = value } } bufferedReader.close() process.destroy() } catch (e: IOException) { Logs.e( "Failed to get run \"/system/bin/getprop\" to get system properties.", e ) } //for (String key : systemProperties.stringPropertyNames()) { // Logger.logVerbose(key + ": " + systemProperties.get(key)); //} return systemProperties } private fun getSystemPropertyWithAndroidAPI(property: String): String? { return try { System.getProperty(property) } catch (e: Exception) { Logs.e("Failed to get system property \"" + property + "\":" + e.message) null } } @SuppressLint("SimpleDateFormat") private fun getCurrentMilliSecondUTCTimeStamp(): String { val df = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS z") df.timeZone = TimeZone.getTimeZone("UTC") return df.format(Date()) } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/utils/DefaultNetworkListener.kt ================================================ package io.nekohasekai.sagernet.utils import android.annotation.TargetApi import android.net.ConnectivityManager import android.net.Network import android.net.NetworkCapabilities import android.net.NetworkRequest import android.os.Build import android.os.Handler import android.os.Looper import io.nekohasekai.sagernet.SagerNet import io.nekohasekai.sagernet.ktx.Logs import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.channels.actor import kotlinx.coroutines.runBlocking import java.net.UnknownHostException object DefaultNetworkListener { private sealed class NetworkMessage { class Start(val key: Any, val listener: (Network?) -> Unit) : NetworkMessage() class Get : NetworkMessage() { val response = CompletableDeferred() } class Stop(val key: Any) : NetworkMessage() class Put(val network: Network) : NetworkMessage() class Update(val network: Network) : NetworkMessage() class Lost(val network: Network) : NetworkMessage() } 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.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.Update -> if (network == message.network) listeners.values.forEach { it( network ) } 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)) suspend fun get() = if (fallback) @TargetApi(23) { SagerNet.connectivity.activeNetwork ?: throw UnknownHostException() // failed to listen, return current if available } else NetworkMessage.Get().run { networkActor.send(this) response.await() } suspend fun stop(key: Any) = networkActor.send(NetworkMessage.Stop(key)) // NB: this runs in ConnectivityThread, and this behavior cannot be changed until API 26 private object Callback : ConnectivityManager.NetworkCallback() { override fun onAvailable(network: Network) = runBlocking { networkActor.send(NetworkMessage.Put(network)) } override fun onCapabilitiesChanged( network: Network, networkCapabilities: NetworkCapabilities ) { // it's a good idea to refresh capabilities runBlocking { networkActor.send(NetworkMessage.Update(network)) } } override fun onLost(network: Network) = runBlocking { networkActor.send(NetworkMessage.Lost(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 mainHandler = Handler(Looper.getMainLooper()) /** * Unfortunately registerDefaultNetworkCallback is going to return VPN interface since Android P DP1: * https://android.googlesource.com/platform/frameworks/base/+/dda156ab0c5d66ad82bdcf76cda07cbc0a9c8a2e * * This makes doing a requestNetwork with REQUEST necessary so that we don't get ALL possible networks that * satisfies default network capabilities but only THE default network. Unfortunately, we need to have * android.permission.CHANGE_NETWORK_STATE to be able to call requestNetwork. * * Source: https://android.googlesource.com/platform/frameworks/base/+/2df4c7d/services/core/java/com/android/server/ConnectivityService.java#887 */ private fun register() { try { fallback = false when (Build.VERSION.SDK_INT) { in 31..Int.MAX_VALUE -> @TargetApi(31) { SagerNet.connectivity.registerBestMatchingNetworkCallback( request, Callback, mainHandler ) } in 28 until 31 -> @TargetApi(28) { // we want REQUEST here instead of LISTEN SagerNet.connectivity.requestNetwork(request, Callback, mainHandler) } in 26 until 28 -> @TargetApi(26) { SagerNet.connectivity.registerDefaultNetworkCallback(Callback, mainHandler) } in 24 until 26 -> @TargetApi(24) { SagerNet.connectivity.registerDefaultNetworkCallback(Callback) } else -> { SagerNet.connectivity.requestNetwork(request, Callback) // known bug on API 23: https://stackoverflow.com/a/33509180/2245107 } } } catch (e: Exception) { Logs.w(e) fallback = true } } private fun unregister() = SagerNet.connectivity.unregisterNetworkCallback(Callback) } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/utils/PackageCache.kt ================================================ package io.nekohasekai.sagernet.utils import android.Manifest import android.annotation.SuppressLint import android.content.pm.ApplicationInfo import android.content.pm.PackageInfo import android.content.pm.PackageManager import io.nekohasekai.sagernet.ktx.app import io.nekohasekai.sagernet.ktx.listenForPackageChanges import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import moe.matsuri.nb4a.plugin.Plugins import java.util.concurrent.atomic.AtomicBoolean object PackageCache { lateinit var installedPackages: Map lateinit var installedPluginPackages: Map lateinit var installedApps: Map lateinit var packageMap: Map val uidMap = HashMap>() val loaded = Mutex(true) var registerd = AtomicBoolean(false) // called from init (suspend) fun register() { if (registerd.getAndSet(true)) return reload() app.listenForPackageChanges(false) { reload() labelMap.clear() } loaded.unlock() } @SuppressLint("InlinedApi") fun reload() { val rawPackageInfo = app.packageManager.getInstalledPackages( PackageManager.MATCH_UNINSTALLED_PACKAGES or PackageManager.GET_PERMISSIONS or PackageManager.GET_PROVIDERS or PackageManager.GET_META_DATA ) installedPackages = rawPackageInfo.filter { when (it.packageName) { "android" -> true else -> it.requestedPermissions?.contains(Manifest.permission.INTERNET) == true } }.associateBy { it.packageName } installedPluginPackages = rawPackageInfo.filter { Plugins.isExe(it) }.associateBy { it.packageName } val installed = app.packageManager.getInstalledApplications(PackageManager.GET_META_DATA) installedApps = installed.associateBy { it.packageName } packageMap = installed.associate { it.packageName to it.uid } uidMap.clear() for (info in installed) { val uid = info.uid uidMap.getOrPut(uid) { HashSet() }.add(info.packageName) } } operator fun get(uid: Int) = uidMap[uid] operator fun get(packageName: String) = packageMap[packageName] fun awaitLoadSync() { if (::packageMap.isInitialized) { return } if (!registerd.get()) { register() return } runBlocking { loaded.withLock { // just await } } } private val labelMap = mutableMapOf() fun loadLabel(packageName: String): String { var label = labelMap[packageName] if (label != null) return label val info = installedApps[packageName] ?: return packageName label = info.loadLabel(app.packageManager).toString() labelMap[packageName] = label return label } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/utils/Subnet.kt ================================================ package io.nekohasekai.sagernet.utils import io.nekohasekai.sagernet.ktx.parseNumericAddress import java.net.InetAddress import java.util.* class Subnet(val address: InetAddress, val prefixSize: Int) : Comparable { companion object { fun fromString(value: String, lengthCheck: Int = -1): Subnet? { val parts = value.split('/', limit = 2) val addr = parts[0].parseNumericAddress() ?: return null check(lengthCheck < 0 || addr.address.size == lengthCheck) return if (parts.size == 2) try { val prefixSize = parts[1].toInt() if (prefixSize < 0 || prefixSize > addr.address.size shl 3) null else Subnet(addr, prefixSize) } catch (_: NumberFormatException) { null } else Subnet(addr, addr.address.size shl 3) } } private val addressLength get() = address.address.size shl 3 init { require(prefixSize in 0..addressLength) { "prefixSize $prefixSize not in 0..$addressLength" } } class Immutable(private val a: ByteArray, private val prefixSize: Int = 0) { companion object : Comparator { override fun compare(a: Immutable, b: Immutable): Int { check(a.a.size == b.a.size) for (i in a.a.indices) { val result = a.a[i].compareTo(b.a[i]) if (result != 0) return result } return 0 } } fun matches(b: Immutable) = matches(b.a) fun matches(b: ByteArray): Boolean { if (a.size != b.size) return false var i = 0 while (i * 8 < prefixSize && i * 8 + 8 <= prefixSize) { if (a[i] != b[i]) return false ++i } return i * 8 == prefixSize || a[i] == (b[i].toInt() and -(1 shl i * 8 + 8 - prefixSize)).toByte() } } fun toImmutable() = Immutable(address.address.also { var i = prefixSize / 8 if (prefixSize % 8 > 0) { it[i] = (it[i].toInt() and -(1 shl i * 8 + 8 - prefixSize)).toByte() ++i } while (i < it.size) it[i++] = 0 }, prefixSize) override fun toString(): String = if (prefixSize == addressLength) address.hostAddress else address.hostAddress + '/' + prefixSize private fun Byte.unsigned() = toInt() and 0xFF override fun compareTo(other: Subnet): Int { val addrThis = address.address val addrThat = other.address.address var result = addrThis.size.compareTo(addrThat.size) // IPv4 address goes first if (result != 0) return result for (i in addrThis.indices) { result = addrThis[i].unsigned() .compareTo(addrThat[i].unsigned()) // undo sign extension of signed byte if (result != 0) return result } return prefixSize.compareTo(other.prefixSize) } override fun equals(other: Any?): Boolean { val that = other as? Subnet return address == that?.address && prefixSize == that.prefixSize } override fun hashCode(): Int = Objects.hash(address, prefixSize) } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/utils/Theme.kt ================================================ package io.nekohasekai.sagernet.utils import android.content.Context import android.content.res.Configuration import androidx.appcompat.app.AppCompatDelegate import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.ktx.app object Theme { const val RED = 1 const val PINK_SSR = 2 const val PINK = 3 const val PURPLE = 4 const val DEEP_PURPLE = 5 const val INDIGO = 6 const val BLUE = 7 const val LIGHT_BLUE = 8 const val CYAN = 9 const val TEAL = 10 const val GREEN = 11 const val LIGHT_GREEN = 12 const val LIME = 13 const val YELLOW = 14 const val AMBER = 15 const val ORANGE = 16 const val DEEP_ORANGE = 17 const val BROWN = 18 const val GREY = 19 const val BLUE_GREY = 20 const val BLACK = 21 private fun defaultTheme() = PINK_SSR fun apply(context: Context) { context.setTheme(getTheme()) } fun applyDialog(context: Context) { context.setTheme(getDialogTheme()) } fun getTheme(): Int { return getTheme(DataStore.appTheme) } fun getDialogTheme(): Int { return getDialogTheme(DataStore.appTheme) } fun getTheme(theme: Int): Int { return when (theme) { RED -> R.style.Theme_SagerNet_Red PINK -> R.style.Theme_SagerNet PINK_SSR -> R.style.Theme_SagerNet_Pink_SSR PURPLE -> R.style.Theme_SagerNet_Purple DEEP_PURPLE -> R.style.Theme_SagerNet_DeepPurple INDIGO -> R.style.Theme_SagerNet_Indigo BLUE -> R.style.Theme_SagerNet_Blue LIGHT_BLUE -> R.style.Theme_SagerNet_LightBlue CYAN -> R.style.Theme_SagerNet_Cyan TEAL -> R.style.Theme_SagerNet_Teal GREEN -> R.style.Theme_SagerNet_Green LIGHT_GREEN -> R.style.Theme_SagerNet_LightGreen LIME -> R.style.Theme_SagerNet_Lime YELLOW -> R.style.Theme_SagerNet_Yellow AMBER -> R.style.Theme_SagerNet_Amber ORANGE -> R.style.Theme_SagerNet_Orange DEEP_ORANGE -> R.style.Theme_SagerNet_DeepOrange BROWN -> R.style.Theme_SagerNet_Brown GREY -> R.style.Theme_SagerNet_Grey BLUE_GREY -> R.style.Theme_SagerNet_BlueGrey BLACK -> R.style.Theme_SagerNet_Black else -> getTheme(defaultTheme()) } } fun getDialogTheme(theme: Int): Int { return when (theme) { RED -> R.style.Theme_SagerNet_Dialog_Red PINK -> R.style.Theme_SagerNet_Dialog PINK_SSR -> R.style.Theme_SagerNet_Dialog_Pink_SSR PURPLE -> R.style.Theme_SagerNet_Dialog_Purple DEEP_PURPLE -> R.style.Theme_SagerNet_Dialog_DeepPurple INDIGO -> R.style.Theme_SagerNet_Dialog_Indigo BLUE -> R.style.Theme_SagerNet_Dialog_Blue LIGHT_BLUE -> R.style.Theme_SagerNet_Dialog_LightBlue CYAN -> R.style.Theme_SagerNet_Dialog_Cyan TEAL -> R.style.Theme_SagerNet_Dialog_Teal GREEN -> R.style.Theme_SagerNet_Dialog_Green LIGHT_GREEN -> R.style.Theme_SagerNet_Dialog_LightGreen LIME -> R.style.Theme_SagerNet_Dialog_Lime YELLOW -> R.style.Theme_SagerNet_Dialog_Yellow AMBER -> R.style.Theme_SagerNet_Dialog_Amber ORANGE -> R.style.Theme_SagerNet_Dialog_Orange DEEP_ORANGE -> R.style.Theme_SagerNet_Dialog_DeepOrange BROWN -> R.style.Theme_SagerNet_Dialog_Brown GREY -> R.style.Theme_SagerNet_Dialog_Grey BLUE_GREY -> R.style.Theme_SagerNet_Dialog_BlueGrey BLACK -> R.style.Theme_SagerNet_Dialog_Black else -> getDialogTheme(defaultTheme()) } } var currentNightMode = -1 fun getNightMode(): Int { if (currentNightMode == -1) { currentNightMode = DataStore.nightTheme } return getNightMode(currentNightMode) } fun getNightMode(mode: Int): Int { return when (mode) { 0 -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM 1 -> AppCompatDelegate.MODE_NIGHT_YES 2 -> AppCompatDelegate.MODE_NIGHT_NO else -> AppCompatDelegate.MODE_NIGHT_AUTO_BATTERY } } fun usingNightMode(): Boolean { return when (DataStore.nightTheme) { 1 -> true 2 -> false else -> (app.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES } } fun applyNightTheme() { AppCompatDelegate.setDefaultNightMode(getNightMode()) } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/widget/AppListPreference.kt ================================================ package io.nekohasekai.sagernet.widget import android.content.Context import android.util.AttributeSet import androidx.preference.Preference import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.ktx.app import io.nekohasekai.sagernet.utils.PackageCache class AppListPreference : Preference { constructor(context: Context) : super(context) constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) constructor(context: Context, attrs: AttributeSet?, defStyle: Int) : super( context, attrs, defStyle ) constructor( context: Context, attrs: AttributeSet?, defStyleAttr: Int, defStyleRes: Int ) : super(context, attrs, defStyleAttr, defStyleRes) override fun getSummary(): CharSequence { val packages = DataStore.routePackages.split("\n").filter { it.isNotBlank() }.map { PackageCache.installedPackages[it]?.applicationInfo?.loadLabel(app.packageManager) ?: PackageCache.installedPluginPackages[it]?.applicationInfo?.loadLabel(app.packageManager) ?: it } if (packages.isEmpty()) { return context.getString(androidx.preference.R.string.not_set) } val count = packages.size if (count <= 5) return packages.joinToString("\n") return context.getString(R.string.apps_message, count) } fun postUpdate() { notifyChanged() } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/widget/AutoCollapseTextView.kt ================================================ package io.nekohasekai.sagernet.widget import android.annotation.SuppressLint import android.content.Context import android.graphics.Rect import android.util.AttributeSet import android.view.MotionEvent import androidx.appcompat.widget.AppCompatTextView import androidx.core.view.isGone class AutoCollapseTextView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0, ) : AppCompatTextView(context, attrs, defStyleAttr) { override fun onTextChanged( text: CharSequence?, start: Int, lengthBefore: Int, lengthAfter: Int, ) { super.onTextChanged(text, start, lengthBefore, lengthAfter) isGone = text.isNullOrEmpty() } // #1874 override fun onFocusChanged(focused: Boolean, direction: Int, previouslyFocusedRect: Rect?) = try { super.onFocusChanged(focused, direction, previouslyFocusedRect) } catch (e: IndexOutOfBoundsException) { } @SuppressLint("ClickableViewAccessibility") override fun onTouchEvent(event: MotionEvent?) = try { super.onTouchEvent(event) } catch (e: IndexOutOfBoundsException) { false } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/widget/FabProgressBehavior.kt ================================================ package io.nekohasekai.sagernet.widget import android.content.Context import android.util.AttributeSet import android.view.View import androidx.coordinatorlayout.widget.CoordinatorLayout import com.google.android.material.progressindicator.CircularProgressIndicator class FabProgressBehavior(context: Context, attrs: AttributeSet?) : CoordinatorLayout.Behavior(context, attrs) { override fun layoutDependsOn( parent: CoordinatorLayout, child: CircularProgressIndicator, dependency: View, ): Boolean { return dependency.id == (child.layoutParams as CoordinatorLayout.LayoutParams).anchorId } override fun onLayoutChild( parent: CoordinatorLayout, child: CircularProgressIndicator, layoutDirection: Int, ): Boolean { val size = parent.getDependencies(child).single().measuredHeight + child.trackThickness return if (child.indicatorSize != size) { child.indicatorSize = size true } else false } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/widget/GroupPreference.kt ================================================ package io.nekohasekai.sagernet.widget import android.content.Context import android.util.AttributeSet import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.database.SagerDatabase import moe.matsuri.nb4a.ui.SimpleMenuPreference class GroupPreference @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyle: Int = R.attr.dropdownPreferenceStyle ) : SimpleMenuPreference(context, attrs, defStyle, 0) { init { val groups = SagerDatabase.groupDao.allGroups() entries = groups.map { it.displayName() }.toTypedArray() entryValues = groups.map { "${it.id}" }.toTypedArray() } override fun getSummary(): CharSequence? { if (!value.isNullOrBlank() && value != "0") { return SagerDatabase.groupDao.getById(value.toLong())?.displayName() ?: super.getSummary() } return super.getSummary() } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/widget/LinkOrContentPreference.kt ================================================ package io.nekohasekai.sagernet.widget import android.content.Context import android.net.Uri import android.util.AttributeSet import androidx.core.content.res.TypedArrayUtils import androidx.core.widget.addTextChangedListener import androidx.preference.EditTextPreference import com.google.android.material.textfield.TextInputLayout import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.ktx.app import io.nekohasekai.sagernet.ktx.readableMessage import okhttp3.HttpUrl.Companion.toHttpUrl class LinkOrContentPreference @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = TypedArrayUtils.getAttr( context, R.attr.editTextPreferenceStyle, android.R.attr.editTextPreferenceStyle ), defStyleRes: Int = 0 ) : EditTextPreference(context, attrs, defStyleAttr, defStyleRes) { init { dialogLayoutResource = R.layout.layout_urltest_preference_dialog setOnBindEditTextListener { val linkLayout = it.rootView.findViewById(R.id.input_layout) fun validate() { val link = it.text if (link.isBlank()) { linkLayout.isErrorEnabled = false return } try { if (Uri.parse(link.toString()).scheme == "content") { linkLayout.isErrorEnabled = false return } val url = link.toString().toHttpUrl() if ("http".equals(url.scheme, true)) { linkLayout.error = app.getString(R.string.cleartext_http_warning) linkLayout.isErrorEnabled = true } else { linkLayout.isErrorEnabled = false } if (link.contains("\n")) { linkLayout.error = "Unexpected new line" } } catch (e: Exception) { linkLayout.error = e.readableMessage linkLayout.isErrorEnabled = true } } validate() it.addTextChangedListener { validate() } } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/widget/OutboundPreference.kt ================================================ package io.nekohasekai.sagernet.widget import android.content.Context import android.util.AttributeSet import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.database.ProfileManager import moe.matsuri.nb4a.ui.SimpleMenuPreference class OutboundPreference @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyle: Int = R.attr.dropdownPreferenceStyle ) : SimpleMenuPreference(context, attrs, defStyle, 0) { init { setEntries(R.array.outbound_entry) setEntryValues(R.array.outbound_value) } override fun getSummary(): CharSequence? { if (value == "3") { val routeOutbound = DataStore.profileCacheStore.getLong(key + "Long") ?: 0 if (routeOutbound > 0) { ProfileManager.getProfile(routeOutbound)?.displayName()?.let { return it } } } return super.getSummary() } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/widget/QRCodeDialog.kt ================================================ package io.nekohasekai.sagernet.widget import android.graphics.Bitmap import android.graphics.Color import android.os.Bundle import android.util.DisplayMetrics import android.view.Gravity import android.view.LayoutInflater import android.view.ViewGroup import android.widget.ImageView import android.widget.LinearLayout import android.widget.TextView import androidx.core.os.bundleOf import androidx.fragment.app.DialogFragment import com.google.zxing.BarcodeFormat import com.google.zxing.EncodeHintType import com.google.zxing.MultiFormatWriter import com.google.zxing.WriterException import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.ktx.Logs import io.nekohasekai.sagernet.ktx.readableMessage import io.nekohasekai.sagernet.ui.MainActivity import java.nio.charset.StandardCharsets import kotlin.math.roundToInt class QRCodeDialog() : DialogFragment() { companion object { private const val KEY_URL = "io.nekohasekai.sagernet.QRCodeDialog.KEY_URL" private const val KEY_NAME = "io.nekohasekai.sagernet.QRCodeDialog.KEY_NAME" private val iso88591 = StandardCharsets.ISO_8859_1.newEncoder() } constructor(url: String, displayName: String) : this() { arguments = bundleOf( Pair(KEY_URL, url), Pair(KEY_NAME, displayName) ) } /** * Based on: * https://android.googlesource.com/platform/ packages/apps/Settings/+/0d706f0/src/com/android/settings/wifi/qrcode/QrCodeGenerator.java * https://android.googlesource.com/platform/ packages/apps/Settings/+/8a9ccfd/src/com/android/settings/wifi/dpp/WifiDppQrCodeGeneratorFragment.java#153 */ override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ) = try { // get display size var pixelMin = 0 try { val displayMetrics: DisplayMetrics = requireContext().resources.displayMetrics val height: Int = displayMetrics.heightPixels val width: Int = displayMetrics.widthPixels pixelMin = if (height > width) width else height pixelMin = (pixelMin * 0.8).roundToInt() } catch (e: Exception) { } val size = if (pixelMin > 0) pixelMin else resources.getDimensionPixelSize(R.dimen.qrcode_size) // draw QR Code val url = arguments?.getString(KEY_URL)!! val displayName = arguments?.getString(KEY_NAME)!! val hints = mutableMapOf() if (!iso88591.canEncode(url)) hints[EncodeHintType.CHARACTER_SET] = StandardCharsets.UTF_8.name() val qrBits = MultiFormatWriter().encode(url, BarcodeFormat.QR_CODE, size, size, hints) LinearLayout(context).apply { // Layout orientation = LinearLayout.VERTICAL gravity = Gravity.CENTER // QR Code Image View addView(ImageView(context).apply { layoutParams = ViewGroup.LayoutParams( ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT ) setImageBitmap(Bitmap.createBitmap(size, size, Bitmap.Config.RGB_565).apply { for (x in 0 until size) for (y in 0 until size) { setPixel(x, y, if (qrBits.get(x, y)) Color.BLACK else Color.WHITE) } }) }) // Text View addView(TextView(context).apply { gravity = Gravity.CENTER layoutParams = ViewGroup.LayoutParams( ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT ) text = displayName }) } } catch (e: WriterException) { Logs.w(e) (activity as MainActivity).snackbar(e.readableMessage).show() dismiss() null } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/widget/ServiceButton.kt ================================================ package io.nekohasekai.sagernet.widget import android.content.Context import android.graphics.drawable.Drawable import android.os.Build import android.util.AttributeSet import android.view.PointerIcon import android.view.View import androidx.annotation.DrawableRes import androidx.appcompat.widget.TooltipCompat import androidx.dynamicanimation.animation.DynamicAnimation import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.lifecycleScope import androidx.vectordrawable.graphics.drawable.Animatable2Compat import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat import com.google.android.material.floatingactionbutton.FloatingActionButton import com.google.android.material.progressindicator.BaseProgressIndicator import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.bg.BaseService import kotlinx.coroutines.Job import kotlinx.coroutines.delay import java.util.* class ServiceButton @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : FloatingActionButton(context, attrs, defStyleAttr), DynamicAnimation.OnAnimationEndListener { private val callback = object : Animatable2Compat.AnimationCallback() { override fun onAnimationEnd(drawable: Drawable) { super.onAnimationEnd(drawable) var next = animationQueue.peek() ?: return if (next.icon.current == drawable) { animationQueue.pop() next = animationQueue.peek() ?: return } next.start() } } private inner class AnimatedState( @DrawableRes resId: Int, private val onStart: BaseProgressIndicator<*>.() -> Unit = { hideProgress() } ) { val icon: AnimatedVectorDrawableCompat = AnimatedVectorDrawableCompat.create(context, resId)!!.apply { registerAnimationCallback(this@ServiceButton.callback) } fun start() { setImageDrawable(icon) icon.start() progress.onStart() } fun stop() = icon.stop() } private val iconStopped by lazy { AnimatedState(R.drawable.ic_service_stopped) } private val iconConnecting by lazy { AnimatedState(R.drawable.ic_service_connecting) { hideProgress() delayedAnimation = (context as LifecycleOwner).lifecycleScope.launchWhenStarted { delay(context.resources.getInteger(android.R.integer.config_mediumAnimTime) + 1000L) isIndeterminate = true show() } } } private val iconConnected by lazy { AnimatedState(R.drawable.ic_service_connected) { delayedAnimation?.cancel() setProgressCompat(1, true) } } private val iconStopping by lazy { AnimatedState(R.drawable.ic_service_stopping) } private val animationQueue = ArrayDeque() private var checked = false private var delayedAnimation: Job? = null private lateinit var progress: BaseProgressIndicator<*> fun initProgress(progress: BaseProgressIndicator<*>) { this.progress = progress progress.progressDrawable?.addSpringAnimationEndListener(this) } override fun onAnimationEnd( animation: DynamicAnimation>?, canceled: Boolean, value: Float, velocity: Float ) { if (!canceled) progress.hide() } private fun hideProgress() { delayedAnimation?.cancel() progress.hide() } override fun onCreateDrawableState(extraSpace: Int): IntArray { val drawableState = super.onCreateDrawableState(extraSpace + 1) if (checked) View.mergeDrawableStates( drawableState, intArrayOf(android.R.attr.state_checked) ) return drawableState } fun changeState(state: BaseService.State, previousState: BaseService.State, animate: Boolean) { when (state) { BaseService.State.Connecting -> changeState(iconConnecting, animate) BaseService.State.Connected -> changeState(iconConnected, animate) BaseService.State.Stopping -> { changeState(iconStopping, animate && previousState == BaseService.State.Connected) } else -> changeState(iconStopped, animate) } checked = state == BaseService.State.Connected refreshDrawableState() val description = context.getText(if (state.canStop) R.string.stop else R.string.connect) contentDescription = description TooltipCompat.setTooltipText(this, description) val enabled = state.canStop || state == BaseService.State.Stopped isEnabled = enabled if (Build.VERSION.SDK_INT >= 24) pointerIcon = PointerIcon.getSystemIcon( context, if (enabled) PointerIcon.TYPE_HAND else PointerIcon.TYPE_WAIT ) } private fun changeState(icon: AnimatedState, animate: Boolean) { fun counters(a: AnimatedState, b: AnimatedState): Boolean = a == iconStopped && b == iconConnecting || a == iconConnecting && b == iconStopped || a == iconConnected && b == iconStopping || a == iconStopping && b == iconConnected if (animate) { if (animationQueue.size < 2 || !counters(animationQueue.last, icon)) { animationQueue.add(icon) if (animationQueue.size == 1) icon.start() } else animationQueue.removeLast() } else { animationQueue.peekFirst()?.stop() animationQueue.clear() icon.start() // force ensureAnimatorSet to be called so that stop() will work icon.stop() } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/widget/StatsBar.kt ================================================ package io.nekohasekai.sagernet.widget import android.annotation.SuppressLint import android.content.Context import android.text.format.Formatter import android.util.AttributeSet import android.view.View import android.widget.TextView import androidx.appcompat.widget.TooltipCompat import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.lifecycle.lifecycleScope import androidx.lifecycle.whenStarted import com.google.android.material.bottomappbar.BottomAppBar import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.bg.BaseService import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.ktx.* import io.nekohasekai.sagernet.ui.MainActivity import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch class StatsBar @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = R.attr.bottomAppBarStyle, ) : BottomAppBar(context, attrs, defStyleAttr) { private lateinit var statusText: TextView private lateinit var txText: TextView private lateinit var rxText: TextView private lateinit var behavior: YourBehavior var allowShow = true override fun getBehavior(): YourBehavior { if (!this::behavior.isInitialized) behavior = YourBehavior { allowShow } return behavior } class YourBehavior(val getAllowShow: () -> Boolean) : Behavior() { override fun onNestedScroll( coordinatorLayout: CoordinatorLayout, child: BottomAppBar, target: View, dxConsumed: Int, dyConsumed: Int, dxUnconsumed: Int, dyUnconsumed: Int, type: Int, consumed: IntArray, ) { super.onNestedScroll( coordinatorLayout, child, target, dxConsumed, dyConsumed + dyUnconsumed, dxUnconsumed, 0, type, consumed ) } override fun slideUp(child: BottomAppBar) { if (!getAllowShow()) return super.slideUp(child) } override fun slideDown(child: BottomAppBar) { if (!getAllowShow()) return super.slideDown(child) } } override fun setOnClickListener(l: OnClickListener?) { statusText = findViewById(R.id.status) txText = findViewById(R.id.tx) rxText = findViewById(R.id.rx) super.setOnClickListener(l) } private fun setStatus(text: CharSequence) { statusText.text = text TooltipCompat.setTooltipText(this, text) } fun changeState(state: BaseService.State) { val activity = context as MainActivity fun postWhenStarted(what: () -> Unit) = activity.lifecycleScope.launch(Dispatchers.Main) { delay(100L) activity.whenStarted { what() } } if ((state == BaseService.State.Connected).also { hideOnScroll = it }) { postWhenStarted { if (allowShow) performShow() setStatus(app.getText(R.string.vpn_connected)) } } else { postWhenStarted { performHide() } updateSpeed(0, 0) setStatus( context.getText( when (state) { BaseService.State.Connecting -> R.string.connecting BaseService.State.Stopping -> R.string.stopping else -> R.string.not_connected } ) ) } } @SuppressLint("SetTextI18n") fun updateSpeed(txRate: Long, rxRate: Long) { txText.text = "▲ ${ context.getString( R.string.speed, Formatter.formatFileSize(context, txRate) ) }" rxText.text = "▼ ${ context.getString( R.string.speed, Formatter.formatFileSize(context, rxRate) ) }" } fun testConnection() { val activity = context as MainActivity isEnabled = false setStatus(app.getText(R.string.connection_test_testing)) runOnDefaultDispatcher { try { val elapsed = activity.urlTest() onMainDispatcher { isEnabled = true setStatus( app.getString( if (DataStore.connectionTestURL.startsWith("https://")) { R.string.connection_test_available } else { R.string.connection_test_available_http }, elapsed ) ) } } catch (e: Exception) { Logs.w(e.toString()) onMainDispatcher { isEnabled = true setStatus(app.getText(R.string.connection_test_testing)) activity.snackbar( app.getString( R.string.connection_test_error, e.readableMessage ) ).show() } } } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/widget/UndoSnackbarManager.kt ================================================ package io.nekohasekai.sagernet.widget import com.google.android.material.snackbar.Snackbar import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.ui.ThemedActivity /** * @param activity ThemedActivity. * //@param view The view to find a parent from. * @param undo Callback for undoing removals. * @param commit Callback for committing removals. * @tparam T Item type. */ class UndoSnackbarManager( private val activity: ThemedActivity, private val callback: Interface, ) { interface Interface { fun undo(actions: List>) fun commit(actions: List>) } private val recycleBin = ArrayList>() private val removedCallback = object : Snackbar.Callback() { override fun onDismissed(transientBottomBar: Snackbar?, event: Int) { if (last === transientBottomBar && event != DISMISS_EVENT_ACTION) { callback.commit(recycleBin) recycleBin.clear() last = null } } } private var last: Snackbar? = null fun remove(items: Collection>) { recycleBin.addAll(items) val count = recycleBin.size activity.snackbar(activity.resources.getQuantityString(R.plurals.removed, count, count)) .apply { addCallback(removedCallback) setAction(R.string.undo) { callback.undo(recycleBin.reversed()) recycleBin.clear() } last = this show() } } fun remove(vararg items: Pair) = remove(items.toList()) fun flush() = last?.dismiss() } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/widget/UserAgentPreference.kt ================================================ package io.nekohasekai.sagernet.widget import android.content.Context import android.util.AttributeSet import androidx.core.content.res.TypedArrayUtils import androidx.preference.EditTextPreference import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.ktx.USER_AGENT class UserAgentPreference @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyle: Int = TypedArrayUtils.getAttr( context, R.attr.editTextPreferenceStyle, android.R.attr.editTextPreferenceStyle ) ) : EditTextPreference(context, attrs, defStyle) { public override fun notifyChanged() { super.notifyChanged() } override fun getSummary(): CharSequence? { if (text.isNullOrBlank()) { return USER_AGENT } return super.getSummary() } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/widget/WindowInsetsListeners.kt ================================================ package io.nekohasekai.sagernet.widget import android.view.View import androidx.core.view.OnApplyWindowInsetsListener import androidx.core.view.WindowInsetsCompat import androidx.core.view.updatePadding object ListListener : OnApplyWindowInsetsListener { override fun onApplyWindowInsets(view: View, insets: WindowInsetsCompat) = insets.apply { view.updatePadding(bottom = insets.getInsets(WindowInsetsCompat.Type.navigationBars()).bottom) } } ================================================ FILE: app/src/main/java/moe/matsuri/nb4a/NativeInterface.kt ================================================ package moe.matsuri.nb4a import android.content.Context import android.net.ConnectivityManager import android.net.wifi.WifiManager import android.os.Build import android.os.Build.VERSION_CODES import androidx.annotation.RequiresApi import io.nekohasekai.sagernet.SagerNet import io.nekohasekai.sagernet.bg.ServiceNotification import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.database.SagerDatabase import io.nekohasekai.sagernet.ktx.Logs import io.nekohasekai.sagernet.ktx.app import io.nekohasekai.sagernet.ktx.runOnDefaultDispatcher import io.nekohasekai.sagernet.utils.PackageCache import libcore.BoxPlatformInterface import libcore.Libcore import libcore.NB4AInterface import java.net.InetSocketAddress class NativeInterface : BoxPlatformInterface, NB4AInterface { // libbox interface override fun autoDetectInterfaceControl(fd: Int) { DataStore.vpnService?.protect(fd) } override fun openTun(singTunOptionsJson: String, tunPlatformOptionsJson: String): Long { if (DataStore.vpnService == null) { throw Exception("no VpnService") } return DataStore.vpnService!!.startVpn(singTunOptionsJson, tunPlatformOptionsJson).toLong() } override fun useProcFS(): Boolean { return Build.VERSION.SDK_INT < Build.VERSION_CODES.Q } @RequiresApi(Build.VERSION_CODES.Q) override fun findConnectionOwner( ipProto: Int, srcIp: String, srcPort: Int, destIp: String, destPort: Int ): Int { return SagerNet.connectivity.getConnectionOwnerUid( ipProto, InetSocketAddress(srcIp, srcPort), InetSocketAddress(destIp, destPort) ) } override fun packageNameByUid(uid: Int): String { PackageCache.awaitLoadSync() if (uid <= 1000L) { return "android" } val packageNames = PackageCache.uidMap[uid] if (!packageNames.isNullOrEmpty()) for (packageName in packageNames) { return packageName } error("unknown uid $uid") } override fun uidByPackageName(packageName: String): Int { PackageCache.awaitLoadSync() return PackageCache[packageName] ?: 0 } // TODO: 'getter for connectionInfo: WifiInfo!' is deprecated override fun wifiState(): String { val wifiManager = app.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager val connectionInfo = wifiManager.connectionInfo return "${connectionInfo.ssid},${connectionInfo.bssid}" } // nb4a interface override fun useOfficialAssets(): Boolean { return DataStore.rulesProvider == 0 } override fun selector_OnProxySelected(selectorTag: String, tag: String) { if (selectorTag != "proxy") { Logs.d("other selector: $selectorTag") return } Libcore.resetAllConnections(true) DataStore.baseService?.apply { runOnDefaultDispatcher { val id = data.proxy!!.config.profileTagMap .filterValues { it == tag }.keys.firstOrNull() ?: -1 val ent = SagerDatabase.proxyDao.getById(id) ?: return@runOnDefaultDispatcher // traffic & title data.proxy?.apply { looper?.selectMain(id) displayProfileName = ServiceNotification.genTitle(ent) data.notification?.postNotificationTitle(displayProfileName) } // post binder data.binder.broadcast { b -> b.cbSelectorUpdate(id) } } } } } ================================================ FILE: app/src/main/java/moe/matsuri/nb4a/Protocols.kt ================================================ package moe.matsuri.nb4a import android.content.Context import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.database.ProxyEntity.Companion.TYPE_NEKO import io.nekohasekai.sagernet.fmt.AbstractBean import io.nekohasekai.sagernet.ktx.app import io.nekohasekai.sagernet.ktx.getColorAttr import moe.matsuri.nb4a.proxy.config.ConfigBean // Settings for all protocols, built-in or plugin object Protocols { // Deduplication class Deduplication( val bean: AbstractBean, val type: String ) { fun hash(): String { if (bean is ConfigBean) { return bean.config } return bean.serverAddress + bean.serverPort + type } override fun hashCode(): Int { return hash().toByteArray().contentHashCode() } override fun equals(other: Any?): Boolean { if (this === other) return true if (javaClass != other?.javaClass) return false other as Deduplication return hash() == other.hash() } } // Display fun Context.getProtocolColor(type: Int): Int { return when (type) { TYPE_NEKO -> getColorAttr(android.R.attr.textColorPrimary) else -> getColorAttr(R.attr.accentOrTextSecondary) } } // Test fun genFriendlyMsg(msg: String): String { val msgL = msg.lowercase() return when { msgL.contains("timeout") || msgL.contains("deadline") -> { app.getString(R.string.connection_test_timeout) } msgL.contains("refused") || msgL.contains("closed pipe") -> { app.getString(R.string.connection_test_refused) } else -> msg } } } ================================================ FILE: app/src/main/java/moe/matsuri/nb4a/SingBoxOptions.java ================================================ package moe.matsuri.nb4a; import com.google.gson.Gson; import com.google.gson.GsonBuilder; import com.google.gson.JsonElement; import com.google.gson.JsonSerializationContext; import com.google.gson.JsonSerializer; import com.google.gson.ToNumberPolicy; import com.google.gson.TypeAdapter; import com.google.gson.TypeAdapterFactory; import com.google.gson.annotations.SerializedName; import com.google.gson.reflect.TypeToken; import java.lang.reflect.Type; import java.util.HashMap; import java.util.List; import java.util.Map; import moe.matsuri.nb4a.utils.Util; public class SingBoxOptions { // base private static final Gson gsonSingbox = new GsonBuilder() .registerTypeHierarchyAdapter(SingBoxOption.class, new SingBoxOptionSerializer()) .setPrettyPrinting() .setNumberToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE) .setObjectToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE) .setLenient() .disableHtmlEscaping() .create(); public static class SingBoxOption { public transient Map _hack_config_map; // 仍然用普通json方式合并,所以Object内不要使用 _hack public transient String _hack_custom_config; public SingBoxOption() { _hack_config_map = new HashMap<>(); } public Map asMap() { return gsonSingbox.fromJson(gsonSingbox.toJson(this), Map.class); } } public static final class CustomSingBoxOption extends SingBoxOption { public transient String config; public CustomSingBoxOption(String config) { super(); this.config = config; } public Map getBasicMap() { Map map = gsonSingbox.fromJson(config, Map.class); if (map == null) { map = new HashMap<>(); } return map; } } // 自定义序列化器 public static class SingBoxOptionSerializer implements JsonSerializer { @Override public JsonElement serialize(SingBoxOption src, Type typeOfSrc, JsonSerializationContext context) { // 拿到原始的 delegate(默认序列化器) TypeAdapter delegate = gsonSingbox.getDelegateAdapter( new TypeAdapterFactory() { @Override public TypeAdapter create(Gson gson, TypeToken type) { return null; // 返回 null,表示只作为“跳过当前自定义”的 marker } }, TypeToken.get(src.getClass()) ); Map map; if (src instanceof CustomSingBoxOption) { map = ((CustomSingBoxOption) src).getBasicMap(); } else { map = gsonSingbox.fromJson(((TypeAdapter) delegate).toJson(src), Map.class); } if (src._hack_config_map != null && !src._hack_config_map.isEmpty()) { Util.INSTANCE.mergeMap(map, src._hack_config_map); } if (src._hack_custom_config != null && !src._hack_custom_config.isBlank()) { Util.INSTANCE.mergeJSON(map, src._hack_custom_config); } return gsonSingbox.toJsonTree(map); } } // custom classes public static class User { public String username; public String password; } public static class MyOptions extends SingBoxOption { public LogOptions log; public DNSOptions dns; public NTPOptions ntp; public List inbounds; public List outbounds; public RouteOptions route; public ExperimentalOptions experimental; } // paste generate output here public static class ClashAPIOptions extends SingBoxOption { public String external_controller; public String external_ui; public String external_ui_download_url; public String external_ui_download_detour; public String secret; public String default_mode; // Generate note: option type: public List ModeList; } public static class SelectorOutboundOptions extends SingBoxOption { public List outbounds; @SerializedName("default") public String default_; } public static class URLTestOutboundOptions extends SingBoxOption { public List outbounds; public String url; public Long interval; public Integer tolerance; } public static class Options extends SingBoxOption { public String $schema; public LogOptions log; public DNSOptions dns; public NTPOptions ntp; public List inbounds; public List outbounds; public RouteOptions route; public ExperimentalOptions experimental; } public static class LogOptions extends SingBoxOption { public Boolean disabled; public String level; public String output; public Boolean timestamp; // Generate note: option type: public Boolean DisableColor; } public static class DebugOptions extends SingBoxOption { public String listen; public Integer gc_percent; public Integer max_stack; public Integer max_threads; public Boolean panic_on_fault; public String trace_back; public Long memory_limit; public Boolean oom_killer; } public static class DirectInboundOptions extends SingBoxOption { // Generate note: nested type ListenOptions public String listen; public Integer listen_port; public Boolean tcp_fast_open; public Boolean tcp_multi_path; public Boolean udp_fragment; // Generate note: option type: public Boolean UDPFragmentDefault; public Long udp_timeout; public Boolean proxy_protocol; public Boolean proxy_protocol_accept_no_header; public String detour; // Generate note: nested type InboundOptions public Boolean sniff; public Boolean sniff_override_destination; public Long sniff_timeout; public String domain_strategy; // End of public InboundOptions ; // End of public ListenOptions ; public String network; public String override_address; public Integer override_port; } public static class DirectOutboundOptions extends SingBoxOption { // Generate note: nested type DialerOptions public String detour; public String bind_interface; public String inet4_bind_address; public String inet6_bind_address; public String protect_path; public Integer routing_mark; public Boolean reuse_addr; public Long connect_timeout; public Boolean tcp_fast_open; public Boolean tcp_multi_path; public Boolean udp_fragment; // Generate note: option type: public Boolean UDPFragmentDefault; public String domain_strategy; public Long fallback_delay; // End of public DialerOptions ; public String override_address; public Integer override_port; public Integer proxy_protocol; } public static class DNSOptions extends SingBoxOption { public List servers; public List rules; @SerializedName("final") public String final_; public Boolean reverse_mapping; public DNSFakeIPOptions fakeip; // Generate note: nested type DNSClientOptions public String strategy; public Boolean disable_cache; public Boolean disable_expire; public Boolean independent_cache; // End of public DNSClientOptions ; } public static class DNSServerOptions extends SingBoxOption { public String tag; public String address; public String address_resolver; public String address_strategy; public Long address_fallback_delay; public String strategy; public String detour; } public static class DNSClientOptions extends SingBoxOption { public String strategy; public Boolean disable_cache; public Boolean disable_expire; public Boolean independent_cache; } public static class DNSFakeIPOptions extends SingBoxOption { public Boolean enabled; public String inet4_range; public String inet6_range; } public static class ExperimentalOptions extends SingBoxOption { public ClashAPIOptions clash_api; public V2RayAPIOptions v2ray_api; public CacheFile cache_file; public DebugOptions debug; } public static class CacheFile extends SingBoxOption { public Boolean enabled; public Boolean store_fakeip; public String path; public String cache_id; } public static class HysteriaInboundOptions extends SingBoxOption { // Generate note: nested type ListenOptions public String listen; public Integer listen_port; public Boolean tcp_fast_open; public Boolean tcp_multi_path; public Boolean udp_fragment; // Generate note: option type: public Boolean UDPFragmentDefault; public Long udp_timeout; public Boolean proxy_protocol; public Boolean proxy_protocol_accept_no_header; public String detour; // Generate note: nested type InboundOptions public Boolean sniff; public Boolean sniff_override_destination; public Long sniff_timeout; public String domain_strategy; // End of public InboundOptions ; // End of public ListenOptions ; public String up; public Integer up_mbps; public String down; public Integer down_mbps; public String obfs; public List users; public Long recv_window_conn; public Long recv_window_client; public Integer max_conn_client; public Boolean disable_mtu_discovery; public InboundTLSOptions tls; } public static class HysteriaUser extends SingBoxOption { public String name; // Generate note: Base64 String public String auth; public String auth_str; } public static class HysteriaOutboundOptions extends SingBoxOption { // Generate note: nested type DialerOptions public String detour; public String bind_interface; public String inet4_bind_address; public String inet6_bind_address; public String protect_path; public Integer routing_mark; public Boolean reuse_addr; public Long connect_timeout; public Boolean tcp_fast_open; public Boolean tcp_multi_path; public Boolean udp_fragment; // Generate note: option type: public Boolean UDPFragmentDefault; public String domain_strategy; public Long fallback_delay; // End of public DialerOptions ; // Generate note: nested type ServerOptions public String server; public Integer server_port; // End of public ServerOptions ; public String up; public Integer up_mbps; public String down; public Integer down_mbps; public String obfs; // Generate note: Base64 String public String auth; public String auth_str; public Long recv_window_conn; public Long recv_window; public Boolean disable_mtu_discovery; public String network; public OutboundTLSOptions tls; public List server_ports; public String hop_interval; } public static class Hysteria2InboundOptions extends SingBoxOption { // Generate note: nested type ListenOptions public String listen; public Integer listen_port; public Boolean tcp_fast_open; public Boolean tcp_multi_path; public Boolean udp_fragment; // Generate note: option type: public Boolean UDPFragmentDefault; public Long udp_timeout; public Boolean proxy_protocol; public Boolean proxy_protocol_accept_no_header; public String detour; // Generate note: nested type InboundOptions public Boolean sniff; public Boolean sniff_override_destination; public Long sniff_timeout; public String domain_strategy; // End of public InboundOptions ; // End of public ListenOptions ; public Integer up_mbps; public Integer down_mbps; public Hysteria2Obfs obfs; public List users; public Boolean ignore_client_bandwidth; public InboundTLSOptions tls; public String masquerade; } public static class Hysteria2Obfs extends SingBoxOption { public String type; public String password; } public static class Hysteria2User extends SingBoxOption { public String name; public String password; } public static class Hysteria2OutboundOptions extends SingBoxOption { // Generate note: nested type DialerOptions public String detour; public String bind_interface; public String inet4_bind_address; public String inet6_bind_address; public String protect_path; public Integer routing_mark; public Boolean reuse_addr; public Long connect_timeout; public Boolean tcp_fast_open; public Boolean tcp_multi_path; public Boolean udp_fragment; // Generate note: option type: public Boolean UDPFragmentDefault; public String domain_strategy; public Long fallback_delay; // End of public DialerOptions ; // Generate note: nested type ServerOptions public String server; public Integer server_port; // End of public ServerOptions ; public Integer up_mbps; public Integer down_mbps; public Hysteria2Obfs obfs; public String password; public String network; public OutboundTLSOptions tls; public List server_ports; public String hop_interval; } public static class Inbound extends SingBoxOption { public String type; public String tag; // Generate note: option type: public TunInboundOptions TunOptions; // Generate note: option type: public RedirectInboundOptions RedirectOptions; // Generate note: option type: public TProxyInboundOptions TProxyOptions; // Generate note: option type: public DirectInboundOptions DirectOptions; // Generate note: option type: public SocksInboundOptions SocksOptions; // Generate note: option type: public HTTPMixedInboundOptions HTTPOptions; // Generate note: option type: public HTTPMixedInboundOptions MixedOptions; // Generate note: option type: public ShadowsocksInboundOptions ShadowsocksOptions; // Generate note: option type: public VMessInboundOptions VMessOptions; // Generate note: option type: public TrojanInboundOptions TrojanOptions; // Generate note: option type: public NaiveInboundOptions NaiveOptions; // Generate note: option type: public HysteriaInboundOptions HysteriaOptions; // Generate note: option type: public ShadowTLSInboundOptions ShadowTLSOptions; // Generate note: option type: public VLESSInboundOptions VLESSOptions; // Generate note: option type: public TUICInboundOptions TUICOptions; // Generate note: option type: public Hysteria2InboundOptions Hysteria2Options; } public static class InboundOptions extends SingBoxOption { public Boolean sniff; public Boolean sniff_override_destination; public Long sniff_timeout; public String domain_strategy; } public static class ListenOptions extends SingBoxOption { public String listen; public Integer listen_port; public Boolean tcp_fast_open; public Boolean tcp_multi_path; public Boolean udp_fragment; // Generate note: option type: public Boolean UDPFragmentDefault; public Long udp_timeout; public Boolean proxy_protocol; public Boolean proxy_protocol_accept_no_header; public String detour; // Generate note: nested type InboundOptions public Boolean sniff; public Boolean sniff_override_destination; public Long sniff_timeout; public String domain_strategy; // End of public InboundOptions ; } public static class NaiveInboundOptions extends SingBoxOption { // Generate note: nested type ListenOptions public String listen; public Integer listen_port; public Boolean tcp_fast_open; public Boolean tcp_multi_path; public Boolean udp_fragment; // Generate note: option type: public Boolean UDPFragmentDefault; public Long udp_timeout; public Boolean proxy_protocol; public Boolean proxy_protocol_accept_no_header; public String detour; // Generate note: nested type InboundOptions public Boolean sniff; public Boolean sniff_override_destination; public Long sniff_timeout; public String domain_strategy; // End of public InboundOptions ; // End of public ListenOptions ; public List users; public String network; public InboundTLSOptions tls; } public static class NTPOptions extends SingBoxOption { public Boolean enabled; public Long interval; public Boolean write_to_system; // Generate note: nested type ServerOptions public String server; public Integer server_port; // End of public ServerOptions ; // Generate note: nested type DialerOptions public String detour; public String bind_interface; public String inet4_bind_address; public String inet6_bind_address; public String protect_path; public Integer routing_mark; public Boolean reuse_addr; public Long connect_timeout; public Boolean tcp_fast_open; public Boolean tcp_multi_path; public Boolean udp_fragment; // Generate note: option type: public Boolean UDPFragmentDefault; public String domain_strategy; public Long fallback_delay; // End of public DialerOptions ; } public static class Outbound extends SingBoxOption { public String type; public String tag; // Generate note: option type: public DirectOutboundOptions DirectOptions; // Generate note: option type: public SocksOutboundOptions SocksOptions; // Generate note: option type: public HTTPOutboundOptions HTTPOptions; // Generate note: option type: public ShadowsocksOutboundOptions ShadowsocksOptions; // Generate note: option type: public VMessOutboundOptions VMessOptions; // Generate note: option type: public TrojanOutboundOptions TrojanOptions; // Generate note: option type: public WireGuardOutboundOptions WireGuardOptions; // Generate note: option type: public HysteriaOutboundOptions HysteriaOptions; // Generate note: option type: public TorOutboundOptions TorOptions; // Generate note: option type: public SSHOutboundOptions SSHOptions; // Generate note: option type: public ShadowTLSOutboundOptions ShadowTLSOptions; // Generate note: option type: public ShadowsocksROutboundOptions ShadowsocksROptions; // Generate note: option type: public VLESSOutboundOptions VLESSOptions; // Generate note: option type: public TUICOutboundOptions TUICOptions; // Generate note: option type: public Hysteria2OutboundOptions Hysteria2Options; // Generate note: option type: public SelectorOutboundOptions SelectorOptions; // Generate note: option type: public URLTestOutboundOptions URLTestOptions; } public static class DialerOptions extends SingBoxOption { public String detour; public String bind_interface; public String inet4_bind_address; public String inet6_bind_address; public String protect_path; public Integer routing_mark; public Boolean reuse_addr; public Long connect_timeout; public Boolean tcp_fast_open; public Boolean tcp_multi_path; public Boolean udp_fragment; // Generate note: option type: public Boolean UDPFragmentDefault; public String domain_strategy; public Long fallback_delay; } public static class ServerOptions extends SingBoxOption { public String server; public Integer server_port; } public static class MultiplexOptions extends SingBoxOption { public Boolean enabled; public String protocol; public Integer max_connections; public Integer min_streams; public Integer max_streams; public Boolean padding; } public static class OnDemandOptions extends SingBoxOption { public Boolean enabled; public List rules; } public static class OnDemandRule extends SingBoxOption { public String action; // Generate note: Listable public List dns_search_domain_match; // Generate note: Listable public List dns_server_address_match; public String interface_type_match; // Generate note: Listable public List ssid_match; public String probe_url; } public static class RedirectInboundOptions extends SingBoxOption { // Generate note: nested type ListenOptions public String listen; public Integer listen_port; public Boolean tcp_fast_open; public Boolean tcp_multi_path; public Boolean udp_fragment; // Generate note: option type: public Boolean UDPFragmentDefault; public Long udp_timeout; public Boolean proxy_protocol; public Boolean proxy_protocol_accept_no_header; public String detour; // Generate note: nested type InboundOptions public Boolean sniff; public Boolean sniff_override_destination; public Long sniff_timeout; public String domain_strategy; // End of public InboundOptions ; // End of public ListenOptions ; } public static class TProxyInboundOptions extends SingBoxOption { // Generate note: nested type ListenOptions public String listen; public Integer listen_port; public Boolean tcp_fast_open; public Boolean tcp_multi_path; public Boolean udp_fragment; // Generate note: option type: public Boolean UDPFragmentDefault; public Long udp_timeout; public Boolean proxy_protocol; public Boolean proxy_protocol_accept_no_header; public String detour; // Generate note: nested type InboundOptions public Boolean sniff; public Boolean sniff_override_destination; public Long sniff_timeout; public String domain_strategy; // End of public InboundOptions ; // End of public ListenOptions ; public String network; } public static class RouteOptions extends SingBoxOption { public List rules; public List rule_set; @SerializedName("final") public String final_; public Boolean find_process; public Boolean auto_detect_interface; public Boolean override_android_vpn; public String default_interface; public Integer default_mark; } public static class Rule extends SingBoxOption { public String type; // Generate note: option type: public DefaultRule DefaultOptions; // Generate note: option type: public LogicalRule LogicalOptions; } public static class RuleSet extends SingBoxOption { public String type; public String tag; public String format; public String path; public String url; } public static class DefaultRule extends SingBoxOption { // Generate note: Listable public List inbound; public Integer ip_version; // Generate note: Listable public List network; // Generate note: Listable public List auth_user; // Generate note: Listable public List protocol; // Generate note: Listable public List domain; // Generate note: Listable public List domain_suffix; // Generate note: Listable public List domain_keyword; // Generate note: Listable public List domain_regex; // Generate note: Listable public List source_ip_cidr; // Generate note: Listable public List ip_cidr; // Generate note: Listable public List source_port; // Generate note: Listable public List source_port_range; // Generate note: Listable public List port; // Generate note: Listable public List port_range; // Generate note: Listable public List process_name; // Generate note: Listable public List process_path; // Generate note: Listable public List package_name; // Generate note: Listable public List user; // Generate note: Listable public List user_id; public String clash_mode; public Boolean invert; public String outbound; } public static class DNSRule extends SingBoxOption { public String type; // Generate note: option type: public DefaultDNSRule DefaultOptions; // Generate note: option type: public LogicalDNSRule LogicalOptions; } public static class DefaultDNSRule extends SingBoxOption { // Generate note: Listable public List inbound; public Integer ip_version; // Generate note: Listable public List query_type; // Generate note: Listable public List network; // Generate note: Listable public List auth_user; // Generate note: Listable public List protocol; // Generate note: Listable public List domain; // Generate note: Listable public List domain_suffix; // Generate note: Listable public List domain_keyword; // Generate note: Listable public List domain_regex; // Generate note: Listable public List geosite; // Generate note: Listable public List source_ip_cidr; // Generate note: Listable public List source_port; // Generate note: Listable public List source_port_range; // Generate note: Listable public List port; // Generate note: Listable public List port_range; // Generate note: Listable public List process_name; // Generate note: Listable public List process_path; // Generate note: Listable public List package_name; // Generate note: Listable public List user; // Generate note: Listable public List user_id; // Generate note: Listable public List outbound; public String clash_mode; public Boolean invert; public String server; public Boolean disable_cache; public Integer rewrite_ttl; } public static class ShadowsocksInboundOptions extends SingBoxOption { // Generate note: nested type ListenOptions public String listen; public Integer listen_port; public Boolean tcp_fast_open; public Boolean tcp_multi_path; public Boolean udp_fragment; // Generate note: option type: public Boolean UDPFragmentDefault; public Long udp_timeout; public Boolean proxy_protocol; public Boolean proxy_protocol_accept_no_header; public String detour; // Generate note: nested type InboundOptions public Boolean sniff; public Boolean sniff_override_destination; public Long sniff_timeout; public String domain_strategy; // End of public InboundOptions ; // End of public ListenOptions ; public String network; public String method; public String password; public List users; public List destinations; } public static class ShadowsocksUser extends SingBoxOption { public String name; public String password; } public static class ShadowsocksDestination extends SingBoxOption { public String name; public String password; // Generate note: nested type ServerOptions public String server; public Integer server_port; // End of public ServerOptions ; } public static class ShadowsocksOutboundOptions extends SingBoxOption { // Generate note: nested type DialerOptions public String detour; public String bind_interface; public String inet4_bind_address; public String inet6_bind_address; public String protect_path; public Integer routing_mark; public Boolean reuse_addr; public Long connect_timeout; public Boolean tcp_fast_open; public Boolean tcp_multi_path; public Boolean udp_fragment; // Generate note: option type: public Boolean UDPFragmentDefault; public String domain_strategy; public Long fallback_delay; // End of public DialerOptions ; // Generate note: nested type ServerOptions public String server; public Integer server_port; // End of public ServerOptions ; public String method; public String password; public String plugin; public String plugin_opts; public String network; public UDPOverTCPOptions udp_over_tcp; public MultiplexOptions multiplex; } public static class ShadowsocksROutboundOptions extends SingBoxOption { // Generate note: nested type DialerOptions public String detour; public String bind_interface; public String inet4_bind_address; public String inet6_bind_address; public String protect_path; public Integer routing_mark; public Boolean reuse_addr; public Long connect_timeout; public Boolean tcp_fast_open; public Boolean tcp_multi_path; public Boolean udp_fragment; // Generate note: option type: public Boolean UDPFragmentDefault; public String domain_strategy; public Long fallback_delay; // End of public DialerOptions ; // Generate note: nested type ServerOptions public String server; public Integer server_port; // End of public ServerOptions ; public String method; public String password; public String obfs; public String obfs_param; public String protocol; public String protocol_param; public String network; } public static class ShadowTLSInboundOptions extends SingBoxOption { // Generate note: nested type ListenOptions public String listen; public Integer listen_port; public Boolean tcp_fast_open; public Boolean tcp_multi_path; public Boolean udp_fragment; // Generate note: option type: public Boolean UDPFragmentDefault; public Long udp_timeout; public Boolean proxy_protocol; public Boolean proxy_protocol_accept_no_header; public String detour; // Generate note: nested type InboundOptions public Boolean sniff; public Boolean sniff_override_destination; public Long sniff_timeout; public String domain_strategy; // End of public InboundOptions ; // End of public ListenOptions ; public Integer version; public String password; public List users; public ShadowTLSHandshakeOptions handshake; public Map handshake_for_server_name; public Boolean strict_mode; } public static class ShadowTLSUser extends SingBoxOption { public String name; public String password; } public static class ShadowTLSHandshakeOptions extends SingBoxOption { // Generate note: nested type ServerOptions public String server; public Integer server_port; // End of public ServerOptions ; // Generate note: nested type DialerOptions public String detour; public String bind_interface; public String inet4_bind_address; public String inet6_bind_address; public String protect_path; public Integer routing_mark; public Boolean reuse_addr; public Long connect_timeout; public Boolean tcp_fast_open; public Boolean tcp_multi_path; public Boolean udp_fragment; // Generate note: option type: public Boolean UDPFragmentDefault; public String domain_strategy; public Long fallback_delay; // End of public DialerOptions ; } public static class ShadowTLSOutboundOptions extends SingBoxOption { // Generate note: nested type DialerOptions public String detour; public String bind_interface; public String inet4_bind_address; public String inet6_bind_address; public String protect_path; public Integer routing_mark; public Boolean reuse_addr; public Long connect_timeout; public Boolean tcp_fast_open; public Boolean tcp_multi_path; public Boolean udp_fragment; // Generate note: option type: public Boolean UDPFragmentDefault; public String domain_strategy; public Long fallback_delay; // End of public DialerOptions ; // Generate note: nested type ServerOptions public String server; public Integer server_port; // End of public ServerOptions ; public Integer version; public String password; public OutboundTLSOptions tls; } public static class SocksInboundOptions extends SingBoxOption { // Generate note: nested type ListenOptions public String listen; public Integer listen_port; public Boolean tcp_fast_open; public Boolean tcp_multi_path; public Boolean udp_fragment; // Generate note: option type: public Boolean UDPFragmentDefault; public Long udp_timeout; public Boolean proxy_protocol; public Boolean proxy_protocol_accept_no_header; public String detour; // Generate note: nested type InboundOptions public Boolean sniff; public Boolean sniff_override_destination; public Long sniff_timeout; public String domain_strategy; // End of public InboundOptions ; // End of public ListenOptions ; public List users; } public static class HTTPMixedInboundOptions extends SingBoxOption { // Generate note: nested type ListenOptions public String listen; public Integer listen_port; public Boolean tcp_fast_open; public Boolean tcp_multi_path; public Boolean udp_fragment; // Generate note: option type: public Boolean UDPFragmentDefault; public Long udp_timeout; public Boolean proxy_protocol; public Boolean proxy_protocol_accept_no_header; public String detour; // Generate note: nested type InboundOptions public Boolean sniff; public Boolean sniff_override_destination; public Long sniff_timeout; public String domain_strategy; // End of public InboundOptions ; // End of public ListenOptions ; public List users; public Boolean set_system_proxy; public InboundTLSOptions tls; } public static class SocksOutboundOptions extends SingBoxOption { // Generate note: nested type DialerOptions public String detour; public String bind_interface; public String inet4_bind_address; public String inet6_bind_address; public String protect_path; public Integer routing_mark; public Boolean reuse_addr; public Long connect_timeout; public Boolean tcp_fast_open; public Boolean tcp_multi_path; public Boolean udp_fragment; // Generate note: option type: public Boolean UDPFragmentDefault; public String domain_strategy; public Long fallback_delay; // End of public DialerOptions ; // Generate note: nested type ServerOptions public String server; public Integer server_port; // End of public ServerOptions ; public String version; public String username; public String password; public String network; public UDPOverTCPOptions udp_over_tcp; } public static class HTTPOutboundOptions extends SingBoxOption { // Generate note: nested type DialerOptions public String detour; public String bind_interface; public String inet4_bind_address; public String inet6_bind_address; public String protect_path; public Integer routing_mark; public Boolean reuse_addr; public Long connect_timeout; public Boolean tcp_fast_open; public Boolean tcp_multi_path; public Boolean udp_fragment; // Generate note: option type: public Boolean UDPFragmentDefault; public String domain_strategy; public Long fallback_delay; // End of public DialerOptions ; // Generate note: nested type ServerOptions public String server; public Integer server_port; // End of public ServerOptions ; public String username; public String password; public OutboundTLSOptions tls; public String path; public Map headers; } public static class SSHOutboundOptions extends SingBoxOption { // Generate note: nested type DialerOptions public String detour; public String bind_interface; public String inet4_bind_address; public String inet6_bind_address; public String protect_path; public Integer routing_mark; public Boolean reuse_addr; public Long connect_timeout; public Boolean tcp_fast_open; public Boolean tcp_multi_path; public Boolean udp_fragment; // Generate note: option type: public Boolean UDPFragmentDefault; public String domain_strategy; public Long fallback_delay; // End of public DialerOptions ; // Generate note: nested type ServerOptions public String server; public Integer server_port; // End of public ServerOptions ; public String user; public String password; public String private_key; public String private_key_path; public String private_key_passphrase; // Generate note: Listable public List host_key; // Generate note: Listable public List host_key_algorithms; public String client_version; } public static class InboundTLSOptions extends SingBoxOption { public Boolean enabled; public String server_name; public Boolean insecure; // Generate note: Listable public List alpn; public String min_version; public String max_version; // Generate note: Listable public List cipher_suites; // Generate note: Listable public List certificate; public String certificate_path; // Generate note: Listable public List key; public String key_path; public InboundACMEOptions acme; public InboundECHOptions ech; public InboundRealityOptions reality; } public static class OutboundTLSOptions extends SingBoxOption { public Boolean enabled; public Boolean disable_sni; public String server_name; public Boolean insecure; // Generate note: Listable public List alpn; public String min_version; public String max_version; // Generate note: Listable public List cipher_suites; public String certificate; public String certificate_path; public OutboundECHOptions ech; public OutboundUTLSOptions utls; public OutboundRealityOptions reality; } public static class InboundRealityOptions extends SingBoxOption { public Boolean enabled; public InboundRealityHandshakeOptions handshake; public String private_key; // Generate note: Listable public List short_id; public Long max_time_difference; } public static class InboundRealityHandshakeOptions extends SingBoxOption { // Generate note: nested type ServerOptions public String server; public Integer server_port; // End of public ServerOptions ; // Generate note: nested type DialerOptions public String detour; public String bind_interface; public String inet4_bind_address; public String inet6_bind_address; public String protect_path; public Integer routing_mark; public Boolean reuse_addr; public Long connect_timeout; public Boolean tcp_fast_open; public Boolean tcp_multi_path; public Boolean udp_fragment; // Generate note: option type: public Boolean UDPFragmentDefault; public String domain_strategy; public Long fallback_delay; // End of public DialerOptions ; } public static class InboundECHOptions extends SingBoxOption { public Boolean enabled; // Generate note: Listable public List key; public String key_path; } public static class OutboundECHOptions extends SingBoxOption { public Boolean enabled; // Generate note: Listable public List config; public String config_path; } public static class OutboundUTLSOptions extends SingBoxOption { public Boolean enabled; public String fingerprint; } public static class OutboundRealityOptions extends SingBoxOption { public Boolean enabled; public String public_key; public String short_id; } public static class InboundACMEOptions extends SingBoxOption { // Generate note: Listable public List domain; public String data_directory; public String default_server_name; public String email; public String provider; public Boolean disable_http_challenge; public Boolean disable_tls_alpn_challenge; public Integer alternative_http_port; public Integer alternative_tls_port; public ACMEExternalAccountOptions external_account; } public static class ACMEExternalAccountOptions extends SingBoxOption { public String key_id; public String mac_key; } public static class TorOutboundOptions extends SingBoxOption { // Generate note: nested type DialerOptions public String detour; public String bind_interface; public String inet4_bind_address; public String inet6_bind_address; public String protect_path; public Integer routing_mark; public Boolean reuse_addr; public Long connect_timeout; public Boolean tcp_fast_open; public Boolean tcp_multi_path; public Boolean udp_fragment; // Generate note: option type: public Boolean UDPFragmentDefault; public String domain_strategy; public Long fallback_delay; // End of public DialerOptions ; public String executable_path; public List extra_args; public String data_directory; public Map torrc; } public static class TrojanInboundOptions extends SingBoxOption { // Generate note: nested type ListenOptions public String listen; public Integer listen_port; public Boolean tcp_fast_open; public Boolean tcp_multi_path; public Boolean udp_fragment; // Generate note: option type: public Boolean UDPFragmentDefault; public Long udp_timeout; public Boolean proxy_protocol; public Boolean proxy_protocol_accept_no_header; public String detour; // Generate note: nested type InboundOptions public Boolean sniff; public Boolean sniff_override_destination; public Long sniff_timeout; public String domain_strategy; // End of public InboundOptions ; // End of public ListenOptions ; public List users; public InboundTLSOptions tls; public ServerOptions fallback; public Map fallback_for_alpn; public V2RayTransportOptions transport; } public static class TrojanUser extends SingBoxOption { public String name; public String password; } public static class TrojanOutboundOptions extends SingBoxOption { // Generate note: nested type DialerOptions public String detour; public String bind_interface; public String inet4_bind_address; public String inet6_bind_address; public String protect_path; public Integer routing_mark; public Boolean reuse_addr; public Long connect_timeout; public Boolean tcp_fast_open; public Boolean tcp_multi_path; public Boolean udp_fragment; // Generate note: option type: public Boolean UDPFragmentDefault; public String domain_strategy; public Long fallback_delay; // End of public DialerOptions ; // Generate note: nested type ServerOptions public String server; public Integer server_port; // End of public ServerOptions ; public String password; public String network; public OutboundTLSOptions tls; public MultiplexOptions multiplex; public V2RayTransportOptions transport; } public static class TUICInboundOptions extends SingBoxOption { // Generate note: nested type ListenOptions public String listen; public Integer listen_port; public Boolean tcp_fast_open; public Boolean tcp_multi_path; public Boolean udp_fragment; // Generate note: option type: public Boolean UDPFragmentDefault; public Long udp_timeout; public Boolean proxy_protocol; public Boolean proxy_protocol_accept_no_header; public String detour; // Generate note: nested type InboundOptions public Boolean sniff; public Boolean sniff_override_destination; public Long sniff_timeout; public String domain_strategy; // End of public InboundOptions ; // End of public ListenOptions ; public List users; public String congestion_control; public Long auth_timeout; public Boolean zero_rtt_handshake; public Long heartbeat; public InboundTLSOptions tls; } public static class TUICUser extends SingBoxOption { public String name; public String uuid; public String password; } public static class TUICOutboundOptions extends SingBoxOption { // Generate note: nested type DialerOptions public String detour; public String bind_interface; public String inet4_bind_address; public String inet6_bind_address; public String protect_path; public Integer routing_mark; public Boolean reuse_addr; public Long connect_timeout; public Boolean tcp_fast_open; public Boolean tcp_multi_path; public Boolean udp_fragment; // Generate note: option type: public Boolean UDPFragmentDefault; public String domain_strategy; public Long fallback_delay; // End of public DialerOptions ; // Generate note: nested type ServerOptions public String server; public Integer server_port; // End of public ServerOptions ; public String uuid; public String password; public String congestion_control; public String udp_relay_mode; public Boolean udp_over_stream; public Boolean zero_rtt_handshake; public Long heartbeat; public String network; public OutboundTLSOptions tls; } public static class TunInboundOptions extends SingBoxOption { public String interface_name; public Integer mtu; // Generate note: Listable public List inet4_address; // Generate note: Listable public List inet6_address; public Boolean auto_route; public Boolean strict_route; // Generate note: Listable public List inet4_route_address; // Generate note: Listable public List inet6_route_address; // Generate note: Listable public List include_interface; // Generate note: Listable public List exclude_interface; // Generate note: Listable public List include_uid; // Generate note: Listable public List include_uid_range; // Generate note: Listable public List exclude_uid; // Generate note: Listable public List exclude_uid_range; // Generate note: Listable public List include_android_user; // Generate note: Listable public List include_package; // Generate note: Listable public List exclude_package; public Boolean endpoint_independent_nat; public Long udp_timeout; public String stack; public TunPlatformOptions platform; // Generate note: nested type InboundOptions public Boolean sniff; public Boolean sniff_override_destination; public Long sniff_timeout; public String domain_strategy; // End of public InboundOptions ; } public static class TunPlatformOptions extends SingBoxOption { public HTTPProxyOptions http_proxy; } public static class HTTPProxyOptions extends SingBoxOption { public Boolean enabled; // Generate note: nested type ServerOptions public String server; public Integer server_port; // End of public ServerOptions ; } public static class UDPOverTCPOptions extends SingBoxOption { public Boolean enabled; public Integer version; } public static class V2RayAPIOptions extends SingBoxOption { public String listen; public V2RayStatsServiceOptions stats; } public static class V2RayStatsServiceOptions extends SingBoxOption { public Boolean enabled; public List inbounds; public List outbounds; public List users; } public static class V2RayTransportOptions extends SingBoxOption { public String type; // Generate note: option type: public V2RayHTTPOptions HTTPOptions; // Generate note: option type: public V2RayWebsocketOptions WebsocketOptions; // Generate note: option type: public V2RayQUICOptions QUICOptions; // Generate note: option type: public V2RayGRPCOptions GRPCOptions; } public static class V2RayHTTPOptions extends SingBoxOption { // Generate note: Listable public List host; public String path; public String method; public Map headers; public Long idle_timeout; public Long ping_timeout; } public static class V2RayWebsocketOptions extends SingBoxOption { public String path; public Map headers; public Integer max_early_data; public String early_data_header_name; } public static class V2RayGRPCOptions extends SingBoxOption { public String service_name; public Long idle_timeout; public Long ping_timeout; public Boolean permit_without_stream; // Generate note: option type: public Boolean ForceLite; } public static class V2RayHTTPUpgradeOptions extends SingBoxOption { public String host; public String path; public Map headers; } public static class VLESSInboundOptions extends SingBoxOption { // Generate note: nested type ListenOptions public String listen; public Integer listen_port; public Boolean tcp_fast_open; public Boolean tcp_multi_path; public Boolean udp_fragment; // Generate note: option type: public Boolean UDPFragmentDefault; public Long udp_timeout; public Boolean proxy_protocol; public Boolean proxy_protocol_accept_no_header; public String detour; // Generate note: nested type InboundOptions public Boolean sniff; public Boolean sniff_override_destination; public Long sniff_timeout; public String domain_strategy; // End of public InboundOptions ; // End of public ListenOptions ; public List users; public InboundTLSOptions tls; public V2RayTransportOptions transport; } public static class VLESSUser extends SingBoxOption { public String name; public String uuid; public String flow; } public static class VLESSOutboundOptions extends SingBoxOption { // Generate note: nested type DialerOptions public String detour; public String bind_interface; public String inet4_bind_address; public String inet6_bind_address; public String protect_path; public Integer routing_mark; public Boolean reuse_addr; public Long connect_timeout; public Boolean tcp_fast_open; public Boolean tcp_multi_path; public Boolean udp_fragment; // Generate note: option type: public Boolean UDPFragmentDefault; public String domain_strategy; public Long fallback_delay; // End of public DialerOptions ; // Generate note: nested type ServerOptions public String server; public Integer server_port; // End of public ServerOptions ; public String uuid; public String flow; public String network; public OutboundTLSOptions tls; public MultiplexOptions multiplex; public V2RayTransportOptions transport; public String packet_encoding; } public static class VMessInboundOptions extends SingBoxOption { // Generate note: nested type ListenOptions public String listen; public Integer listen_port; public Boolean tcp_fast_open; public Boolean tcp_multi_path; public Boolean udp_fragment; // Generate note: option type: public Boolean UDPFragmentDefault; public Long udp_timeout; public Boolean proxy_protocol; public Boolean proxy_protocol_accept_no_header; public String detour; // Generate note: nested type InboundOptions public Boolean sniff; public Boolean sniff_override_destination; public Long sniff_timeout; public String domain_strategy; // End of public InboundOptions ; // End of public ListenOptions ; public List users; public InboundTLSOptions tls; public V2RayTransportOptions transport; } public static class VMessUser extends SingBoxOption { public String name; public String uuid; public Integer alterId; } public static class VMessOutboundOptions extends SingBoxOption { // Generate note: nested type DialerOptions public String detour; public String bind_interface; public String inet4_bind_address; public String inet6_bind_address; public String protect_path; public Integer routing_mark; public Boolean reuse_addr; public Long connect_timeout; public Boolean tcp_fast_open; public Boolean tcp_multi_path; public Boolean udp_fragment; // Generate note: option type: public Boolean UDPFragmentDefault; public String domain_strategy; public Long fallback_delay; // End of public DialerOptions ; // Generate note: nested type ServerOptions public String server; public Integer server_port; // End of public ServerOptions ; public String uuid; public String security; public Integer alter_id; public Boolean global_padding; public Boolean authenticated_length; public String network; public OutboundTLSOptions tls; public String packet_encoding; public MultiplexOptions multiplex; public V2RayTransportOptions transport; } public static class WireGuardOutboundOptions extends SingBoxOption { // Generate note: nested type DialerOptions public String detour; public String bind_interface; public String inet4_bind_address; public String inet6_bind_address; public String protect_path; public Integer routing_mark; public Boolean reuse_addr; public Long connect_timeout; public Boolean tcp_fast_open; public Boolean tcp_multi_path; public Boolean udp_fragment; // Generate note: option type: public Boolean UDPFragmentDefault; public String domain_strategy; public Long fallback_delay; // End of public DialerOptions ; public Boolean system_interface; public String interface_name; // Generate note: Listable public List local_address; public String private_key; public List peers; // Generate note: nested type ServerOptions public String server; public Integer server_port; // End of public ServerOptions ; public String peer_public_key; public String pre_shared_key; // Generate note: Base64 String public String reserved; public Integer workers; public Integer mtu; public String network; } public static class WireGuardPeer extends SingBoxOption { // Generate note: nested type ServerOptions public String server; public Integer server_port; // End of public ServerOptions ; public String public_key; public String pre_shared_key; // Generate note: Listable public List allowed_ips; // Generate note: Base64 String public String reserved; } public static class Inbound_TunOptions extends Inbound { public String interface_name; public Integer mtu; // Generate note: Listable public List inet4_address; // Generate note: Listable public List inet6_address; public Boolean auto_route; public Boolean strict_route; // Generate note: Listable public List inet4_route_address; // Generate note: Listable public List inet6_route_address; // Generate note: Listable public List include_interface; // Generate note: Listable public List exclude_interface; // Generate note: Listable public List include_uid; // Generate note: Listable public List include_uid_range; // Generate note: Listable public List exclude_uid; // Generate note: Listable public List exclude_uid_range; // Generate note: Listable public List include_android_user; // Generate note: Listable public List include_package; // Generate note: Listable public List exclude_package; public Boolean endpoint_independent_nat; public Long udp_timeout; public String stack; public TunPlatformOptions platform; // Generate note: nested type InboundOptions public Boolean sniff; public Boolean sniff_override_destination; public Long sniff_timeout; public String domain_strategy; // End of public InboundOptions ; } public static class Inbound_RedirectOptions extends Inbound { // Generate note: nested type ListenOptions public String listen; public Integer listen_port; public Boolean tcp_fast_open; public Boolean tcp_multi_path; public Boolean udp_fragment; public Long udp_timeout; public Boolean proxy_protocol; public Boolean proxy_protocol_accept_no_header; public String detour; // Generate note: nested type InboundOptions public Boolean sniff; public Boolean sniff_override_destination; public Long sniff_timeout; public String domain_strategy; // End of public InboundOptions ; // End of public ListenOptions ; } public static class Inbound_TProxyOptions extends Inbound { // Generate note: nested type ListenOptions public String listen; public Integer listen_port; public Boolean tcp_fast_open; public Boolean tcp_multi_path; public Boolean udp_fragment; public Long udp_timeout; public Boolean proxy_protocol; public Boolean proxy_protocol_accept_no_header; public String detour; // Generate note: nested type InboundOptions public Boolean sniff; public Boolean sniff_override_destination; public Long sniff_timeout; public String domain_strategy; // End of public InboundOptions ; // End of public ListenOptions ; public String network; } public static class Inbound_DirectOptions extends Inbound { // Generate note: nested type ListenOptions public String listen; public Integer listen_port; public Boolean tcp_fast_open; public Boolean tcp_multi_path; public Boolean udp_fragment; public Long udp_timeout; public Boolean proxy_protocol; public Boolean proxy_protocol_accept_no_header; public String detour; // Generate note: nested type InboundOptions public Boolean sniff; public Boolean sniff_override_destination; public Long sniff_timeout; public String domain_strategy; // End of public InboundOptions ; // End of public ListenOptions ; public String network; public String override_address; public Integer override_port; } public static class Inbound_SocksOptions extends Inbound { // Generate note: nested type ListenOptions public String listen; public Integer listen_port; public Boolean tcp_fast_open; public Boolean tcp_multi_path; public Boolean udp_fragment; public Long udp_timeout; public Boolean proxy_protocol; public Boolean proxy_protocol_accept_no_header; public String detour; // Generate note: nested type InboundOptions public Boolean sniff; public Boolean sniff_override_destination; public Long sniff_timeout; public String domain_strategy; // End of public InboundOptions ; // End of public ListenOptions ; public List users; } public static class Inbound_HTTPOptions extends Inbound { // Generate note: nested type ListenOptions public String listen; public Integer listen_port; public Boolean tcp_fast_open; public Boolean tcp_multi_path; public Boolean udp_fragment; public Long udp_timeout; public Boolean proxy_protocol; public Boolean proxy_protocol_accept_no_header; public String detour; // Generate note: nested type InboundOptions public Boolean sniff; public Boolean sniff_override_destination; public Long sniff_timeout; public String domain_strategy; // End of public InboundOptions ; // End of public ListenOptions ; public List users; public Boolean set_system_proxy; public InboundTLSOptions tls; } public static class Inbound_MixedOptions extends Inbound { // Generate note: nested type ListenOptions public String listen; public Integer listen_port; public Boolean tcp_fast_open; public Boolean tcp_multi_path; public Boolean udp_fragment; public Long udp_timeout; public Boolean proxy_protocol; public Boolean proxy_protocol_accept_no_header; public String detour; // Generate note: nested type InboundOptions public Boolean sniff; public Boolean sniff_override_destination; public Long sniff_timeout; public String domain_strategy; // End of public InboundOptions ; // End of public ListenOptions ; public List users; public Boolean set_system_proxy; public InboundTLSOptions tls; } public static class Inbound_ShadowsocksOptions extends Inbound { // Generate note: nested type ListenOptions public String listen; public Integer listen_port; public Boolean tcp_fast_open; public Boolean tcp_multi_path; public Boolean udp_fragment; public Long udp_timeout; public Boolean proxy_protocol; public Boolean proxy_protocol_accept_no_header; public String detour; // Generate note: nested type InboundOptions public Boolean sniff; public Boolean sniff_override_destination; public Long sniff_timeout; public String domain_strategy; // End of public InboundOptions ; // End of public ListenOptions ; public String network; public String method; public String password; public List users; public List destinations; } public static class Inbound_VMessOptions extends Inbound { // Generate note: nested type ListenOptions public String listen; public Integer listen_port; public Boolean tcp_fast_open; public Boolean tcp_multi_path; public Boolean udp_fragment; public Long udp_timeout; public Boolean proxy_protocol; public Boolean proxy_protocol_accept_no_header; public String detour; // Generate note: nested type InboundOptions public Boolean sniff; public Boolean sniff_override_destination; public Long sniff_timeout; public String domain_strategy; // End of public InboundOptions ; // End of public ListenOptions ; public List users; public InboundTLSOptions tls; public V2RayTransportOptions transport; } public static class Inbound_TrojanOptions extends Inbound { // Generate note: nested type ListenOptions public String listen; public Integer listen_port; public Boolean tcp_fast_open; public Boolean tcp_multi_path; public Boolean udp_fragment; public Long udp_timeout; public Boolean proxy_protocol; public Boolean proxy_protocol_accept_no_header; public String detour; // Generate note: nested type InboundOptions public Boolean sniff; public Boolean sniff_override_destination; public Long sniff_timeout; public String domain_strategy; // End of public InboundOptions ; // End of public ListenOptions ; public List users; public InboundTLSOptions tls; public ServerOptions fallback; public Map fallback_for_alpn; public V2RayTransportOptions transport; } public static class Inbound_NaiveOptions extends Inbound { // Generate note: nested type ListenOptions public String listen; public Integer listen_port; public Boolean tcp_fast_open; public Boolean tcp_multi_path; public Boolean udp_fragment; public Long udp_timeout; public Boolean proxy_protocol; public Boolean proxy_protocol_accept_no_header; public String detour; // Generate note: nested type InboundOptions public Boolean sniff; public Boolean sniff_override_destination; public Long sniff_timeout; public String domain_strategy; // End of public InboundOptions ; // End of public ListenOptions ; public List users; public String network; public InboundTLSOptions tls; } public static class Inbound_HysteriaOptions extends Inbound { // Generate note: nested type ListenOptions public String listen; public Integer listen_port; public Boolean tcp_fast_open; public Boolean tcp_multi_path; public Boolean udp_fragment; public Long udp_timeout; public Boolean proxy_protocol; public Boolean proxy_protocol_accept_no_header; public String detour; // Generate note: nested type InboundOptions public Boolean sniff; public Boolean sniff_override_destination; public Long sniff_timeout; public String domain_strategy; // End of public InboundOptions ; // End of public ListenOptions ; public String up; public Integer up_mbps; public String down; public Integer down_mbps; public String obfs; public List users; public Long recv_window_conn; public Long recv_window_client; public Integer max_conn_client; public Boolean disable_mtu_discovery; public InboundTLSOptions tls; } public static class Inbound_ShadowTLSOptions extends Inbound { // Generate note: nested type ListenOptions public String listen; public Integer listen_port; public Boolean tcp_fast_open; public Boolean tcp_multi_path; public Boolean udp_fragment; public Long udp_timeout; public Boolean proxy_protocol; public Boolean proxy_protocol_accept_no_header; public String detour; // Generate note: nested type InboundOptions public Boolean sniff; public Boolean sniff_override_destination; public Long sniff_timeout; public String domain_strategy; // End of public InboundOptions ; // End of public ListenOptions ; public Integer version; public String password; public List users; public ShadowTLSHandshakeOptions handshake; public Map handshake_for_server_name; public Boolean strict_mode; } public static class Inbound_VLESSOptions extends Inbound { // Generate note: nested type ListenOptions public String listen; public Integer listen_port; public Boolean tcp_fast_open; public Boolean tcp_multi_path; public Boolean udp_fragment; public Long udp_timeout; public Boolean proxy_protocol; public Boolean proxy_protocol_accept_no_header; public String detour; // Generate note: nested type InboundOptions public Boolean sniff; public Boolean sniff_override_destination; public Long sniff_timeout; public String domain_strategy; // End of public InboundOptions ; // End of public ListenOptions ; public List users; public InboundTLSOptions tls; public V2RayTransportOptions transport; } public static class Inbound_TUICOptions extends Inbound { // Generate note: nested type ListenOptions public String listen; public Integer listen_port; public Boolean tcp_fast_open; public Boolean tcp_multi_path; public Boolean udp_fragment; public Long udp_timeout; public Boolean proxy_protocol; public Boolean proxy_protocol_accept_no_header; public String detour; // Generate note: nested type InboundOptions public Boolean sniff; public Boolean sniff_override_destination; public Long sniff_timeout; public String domain_strategy; // End of public InboundOptions ; // End of public ListenOptions ; public List users; public String congestion_control; public Long auth_timeout; public Boolean zero_rtt_handshake; public Long heartbeat; public InboundTLSOptions tls; } public static class Inbound_Hysteria2Options extends Inbound { // Generate note: nested type ListenOptions public String listen; public Integer listen_port; public Boolean tcp_fast_open; public Boolean tcp_multi_path; public Boolean udp_fragment; public Long udp_timeout; public Boolean proxy_protocol; public Boolean proxy_protocol_accept_no_header; public String detour; // Generate note: nested type InboundOptions public Boolean sniff; public Boolean sniff_override_destination; public Long sniff_timeout; public String domain_strategy; // End of public InboundOptions ; // End of public ListenOptions ; public Integer up_mbps; public Integer down_mbps; public Hysteria2Obfs obfs; public List users; public Boolean ignore_client_bandwidth; public InboundTLSOptions tls; public String masquerade; } public static class Outbound_DirectOptions extends Outbound { // Generate note: nested type DialerOptions public String detour; public String bind_interface; public String inet4_bind_address; public String inet6_bind_address; public String protect_path; public Integer routing_mark; public Boolean reuse_addr; public Long connect_timeout; public Boolean tcp_fast_open; public Boolean tcp_multi_path; public Boolean udp_fragment; public String domain_strategy; public Long fallback_delay; // End of public DialerOptions ; public String override_address; public Integer override_port; public Integer proxy_protocol; } public static class Outbound_SocksOptions extends Outbound { // Generate note: nested type DialerOptions public String detour; public String bind_interface; public String inet4_bind_address; public String inet6_bind_address; public String protect_path; public Integer routing_mark; public Boolean reuse_addr; public Long connect_timeout; public Boolean tcp_fast_open; public Boolean tcp_multi_path; public Boolean udp_fragment; public String domain_strategy; public Long fallback_delay; // End of public DialerOptions ; // Generate note: nested type ServerOptions public String server; public Integer server_port; // End of public ServerOptions ; public String version; public String username; public String password; public String network; public UDPOverTCPOptions udp_over_tcp; } public static class Outbound_HTTPOptions extends Outbound { // Generate note: nested type DialerOptions public String detour; public String bind_interface; public String inet4_bind_address; public String inet6_bind_address; public String protect_path; public Integer routing_mark; public Boolean reuse_addr; public Long connect_timeout; public Boolean tcp_fast_open; public Boolean tcp_multi_path; public Boolean udp_fragment; public String domain_strategy; public Long fallback_delay; // End of public DialerOptions ; // Generate note: nested type ServerOptions public String server; public Integer server_port; // End of public ServerOptions ; public String username; public String password; public OutboundTLSOptions tls; public String path; public Map headers; } public static class Outbound_ShadowsocksOptions extends Outbound { // Generate note: nested type DialerOptions public String detour; public String bind_interface; public String inet4_bind_address; public String inet6_bind_address; public String protect_path; public Integer routing_mark; public Boolean reuse_addr; public Long connect_timeout; public Boolean tcp_fast_open; public Boolean tcp_multi_path; public Boolean udp_fragment; public String domain_strategy; public Long fallback_delay; // End of public DialerOptions ; // Generate note: nested type ServerOptions public String server; public Integer server_port; // End of public ServerOptions ; public String method; public String password; public String plugin; public String plugin_opts; public String network; public UDPOverTCPOptions udp_over_tcp; public MultiplexOptions multiplex; } public static class Outbound_VMessOptions extends Outbound { // Generate note: nested type DialerOptions public String detour; public String bind_interface; public String inet4_bind_address; public String inet6_bind_address; public String protect_path; public Integer routing_mark; public Boolean reuse_addr; public Long connect_timeout; public Boolean tcp_fast_open; public Boolean tcp_multi_path; public Boolean udp_fragment; public String domain_strategy; public Long fallback_delay; // End of public DialerOptions ; // Generate note: nested type ServerOptions public String server; public Integer server_port; // End of public ServerOptions ; public String uuid; public String security; public Integer alter_id; public Boolean global_padding; public Boolean authenticated_length; public String network; public OutboundTLSOptions tls; public String packet_encoding; public MultiplexOptions multiplex; public V2RayTransportOptions transport; } public static class Outbound_TrojanOptions extends Outbound { // Generate note: nested type DialerOptions public String detour; public String bind_interface; public String inet4_bind_address; public String inet6_bind_address; public String protect_path; public Integer routing_mark; public Boolean reuse_addr; public Long connect_timeout; public Boolean tcp_fast_open; public Boolean tcp_multi_path; public Boolean udp_fragment; public String domain_strategy; public Long fallback_delay; // End of public DialerOptions ; // Generate note: nested type ServerOptions public String server; public Integer server_port; // End of public ServerOptions ; public String password; public String network; public OutboundTLSOptions tls; public MultiplexOptions multiplex; public V2RayTransportOptions transport; } public static class Outbound_WireGuardOptions extends Outbound { // Generate note: nested type DialerOptions public String detour; public String bind_interface; public String inet4_bind_address; public String inet6_bind_address; public String protect_path; public Integer routing_mark; public Boolean reuse_addr; public Long connect_timeout; public Boolean tcp_fast_open; public Boolean tcp_multi_path; public Boolean udp_fragment; public String domain_strategy; public Long fallback_delay; // End of public DialerOptions ; public Boolean system_interface; public String interface_name; // Generate note: Listable public List local_address; public String private_key; public List peers; // Generate note: nested type ServerOptions public String server; public Integer server_port; // End of public ServerOptions ; public String peer_public_key; public String pre_shared_key; // Generate note: Base64 String public String reserved; public Integer workers; public Integer mtu; public String network; } public static class Outbound_HysteriaOptions extends Outbound { // Generate note: nested type DialerOptions public String detour; public String bind_interface; public String inet4_bind_address; public String inet6_bind_address; public String protect_path; public Integer routing_mark; public Boolean reuse_addr; public Long connect_timeout; public Boolean tcp_fast_open; public Boolean tcp_multi_path; public Boolean udp_fragment; public String domain_strategy; public Long fallback_delay; // End of public DialerOptions ; // Generate note: nested type ServerOptions public String server; public Integer server_port; // End of public ServerOptions ; public String up; public Integer up_mbps; public String down; public Integer down_mbps; public String obfs; // Generate note: Base64 String public String auth; public String auth_str; public Long recv_window_conn; public Long recv_window; public Boolean disable_mtu_discovery; public String network; public OutboundTLSOptions tls; public List server_ports; public String hop_interval; } public static class Outbound_TorOptions extends Outbound { // Generate note: nested type DialerOptions public String detour; public String bind_interface; public String inet4_bind_address; public String inet6_bind_address; public String protect_path; public Integer routing_mark; public Boolean reuse_addr; public Long connect_timeout; public Boolean tcp_fast_open; public Boolean tcp_multi_path; public Boolean udp_fragment; public String domain_strategy; public Long fallback_delay; // End of public DialerOptions ; public String executable_path; public List extra_args; public String data_directory; public Map torrc; } public static class Outbound_SSHOptions extends Outbound { // Generate note: nested type DialerOptions public String detour; public String bind_interface; public String inet4_bind_address; public String inet6_bind_address; public String protect_path; public Integer routing_mark; public Boolean reuse_addr; public Long connect_timeout; public Boolean tcp_fast_open; public Boolean tcp_multi_path; public Boolean udp_fragment; public String domain_strategy; public Long fallback_delay; // End of public DialerOptions ; // Generate note: nested type ServerOptions public String server; public Integer server_port; // End of public ServerOptions ; public String user; public String password; public String private_key; public String private_key_path; public String private_key_passphrase; // Generate note: Listable public List host_key; // Generate note: Listable public List host_key_algorithms; public String client_version; } public static class Outbound_ShadowTLSOptions extends Outbound { // Generate note: nested type DialerOptions public String detour; public String bind_interface; public String inet4_bind_address; public String inet6_bind_address; public String protect_path; public Integer routing_mark; public Boolean reuse_addr; public Long connect_timeout; public Boolean tcp_fast_open; public Boolean tcp_multi_path; public Boolean udp_fragment; public String domain_strategy; public Long fallback_delay; // End of public DialerOptions ; // Generate note: nested type ServerOptions public String server; public Integer server_port; // End of public ServerOptions ; public Integer version; public String password; public OutboundTLSOptions tls; } public static class Outbound_ShadowsocksROptions extends Outbound { // Generate note: nested type DialerOptions public String detour; public String bind_interface; public String inet4_bind_address; public String inet6_bind_address; public String protect_path; public Integer routing_mark; public Boolean reuse_addr; public Long connect_timeout; public Boolean tcp_fast_open; public Boolean tcp_multi_path; public Boolean udp_fragment; public String domain_strategy; public Long fallback_delay; // End of public DialerOptions ; // Generate note: nested type ServerOptions public String server; public Integer server_port; // End of public ServerOptions ; public String method; public String password; public String obfs; public String obfs_param; public String protocol; public String protocol_param; public String network; } public static class Outbound_VLESSOptions extends Outbound { // Generate note: nested type DialerOptions public String detour; public String bind_interface; public String inet4_bind_address; public String inet6_bind_address; public String protect_path; public Integer routing_mark; public Boolean reuse_addr; public Long connect_timeout; public Boolean tcp_fast_open; public Boolean tcp_multi_path; public Boolean udp_fragment; public String domain_strategy; public Long fallback_delay; // End of public DialerOptions ; // Generate note: nested type ServerOptions public String server; public Integer server_port; // End of public ServerOptions ; public String uuid; public String flow; public String network; public OutboundTLSOptions tls; public MultiplexOptions multiplex; public V2RayTransportOptions transport; public String packet_encoding; } public static class Outbound_TUICOptions extends Outbound { // Generate note: nested type DialerOptions public String detour; public String bind_interface; public String inet4_bind_address; public String inet6_bind_address; public String protect_path; public Integer routing_mark; public Boolean reuse_addr; public Long connect_timeout; public Boolean tcp_fast_open; public Boolean tcp_multi_path; public Boolean udp_fragment; public String domain_strategy; public Long fallback_delay; // End of public DialerOptions ; // Generate note: nested type ServerOptions public String server; public Integer server_port; // End of public ServerOptions ; public String uuid; public String password; public String congestion_control; public String udp_relay_mode; public Boolean udp_over_stream; public Boolean zero_rtt_handshake; public Long heartbeat; public String network; public OutboundTLSOptions tls; } public static class Outbound_Hysteria2Options extends Outbound { // Generate note: nested type DialerOptions public String detour; public String bind_interface; public String inet4_bind_address; public String inet6_bind_address; public String protect_path; public Integer routing_mark; public Boolean reuse_addr; public Long connect_timeout; public Boolean tcp_fast_open; public Boolean tcp_multi_path; public Boolean udp_fragment; public String domain_strategy; public Long fallback_delay; // End of public DialerOptions ; // Generate note: nested type ServerOptions public String server; public Integer server_port; // End of public ServerOptions ; public Integer up_mbps; public Integer down_mbps; public Hysteria2Obfs obfs; public String password; public String network; public OutboundTLSOptions tls; public List server_ports; public String hop_interval; } public static class Outbound_SelectorOptions extends Outbound { public List outbounds; @SerializedName("default") public String default_; } public static class Outbound_URLTestOptions extends Outbound { public List outbounds; public String url; public Long interval; public Integer tolerance; } public static class Rule_DefaultOptions extends Rule { // Generate note: Listable public List inbound; public Integer ip_version; // Generate note: Listable public List network; // Generate note: Listable public List auth_user; // Generate note: Listable public List protocol; // Generate note: Listable public List domain; // Generate note: Listable public List domain_suffix; // Generate note: Listable public List domain_keyword; // Generate note: Listable public List domain_regex; public List rule_set; public Boolean source_ip_is_private; public Boolean ip_is_private; // Generate note: Listable public List source_ip_cidr; // Generate note: Listable public List ip_cidr; // Generate note: Listable public List source_port; // Generate note: Listable public List source_port_range; // Generate note: Listable public List port; // Generate note: Listable public List port_range; // Generate note: Listable public List process_name; // Generate note: Listable public List process_path; // Generate note: Listable public List package_name; // Generate note: Listable public List user; // Generate note: Listable public List user_id; public String clash_mode; public Boolean invert; public String action; public String outbound; } public static class DNSRule_DefaultOptions extends DNSRule { // Generate note: Listable public List inbound; public Integer ip_version; // Generate note: Listable public List query_type; // Generate note: Listable public List network; // Generate note: Listable public List auth_user; // Generate note: Listable public List protocol; // Generate note: Listable public List domain; // Generate note: Listable public List domain_suffix; // Generate note: Listable public List domain_keyword; // Generate note: Listable public List domain_regex; public List rule_set; // Generate note: Listable public List source_ip_cidr; // Generate note: Listable public List source_port; // Generate note: Listable public List source_port_range; // Generate note: Listable public List port; // Generate note: Listable public List port_range; // Generate note: Listable public List process_name; // Generate note: Listable public List process_path; // Generate note: Listable public List package_name; // Generate note: Listable public List user; // Generate note: Listable public List user_id; // Generate note: Listable public List outbound; public String clash_mode; public Boolean invert; public String server; public Boolean disable_cache; public Integer rewrite_ttl; } public static class V2RayTransportOptions_HTTPOptions extends V2RayTransportOptions { // Generate note: Listable public List host; public String path; public String method; public Map headers; public Long idle_timeout; public Long ping_timeout; } public static class V2RayTransportOptions_WebsocketOptions extends V2RayTransportOptions { public String path; public Map headers; public Integer max_early_data; public String early_data_header_name; } public static class V2RayTransportOptions_GRPCOptions extends V2RayTransportOptions { public String service_name; public Long idle_timeout; public Long ping_timeout; public Boolean permit_without_stream; } public static class V2RayTransportOptions_HTTPUpgradeOptions extends V2RayTransportOptions { public String host; public String path; } // sing-box Options 生成器已经坏了,以下是从 husi 抄的 public static class Outbound_AnyTLSOptions extends Outbound { // Generate note: nested type DialerOptions public String detour; public String bind_interface; public String inet4_bind_address; public String inet6_bind_address; public String protect_path; public Integer routing_mark; public Boolean reuse_addr; public String connect_timeout; public Boolean tcp_fast_open; public Boolean tcp_multi_path; public Boolean udp_fragment; public String domain_strategy; public String network_strategy; public List network_type; public List fallback_network_type; public String fallback_delay; // Generate note: nested type ServerOptions public String server; public Integer server_port; // Generate note: nested type OutboundTLSOptionsContainer public OutboundTLSOptions tls; public String password; public String idle_session_check_interval; public String idle_session_timeout; } } ================================================ FILE: app/src/main/java/moe/matsuri/nb4a/SingBoxOptionsUtil.kt ================================================ package moe.matsuri.nb4a import io.nekohasekai.sagernet.database.DataStore import moe.matsuri.nb4a.SingBoxOptions.RuleSet object SingBoxOptionsUtil { fun domainStrategy(tag: String): String { fun auto2(key: String, newS: String): String { return (DataStore.configurationStore.getString(key) ?: "").replace("auto", newS) } return when (tag) { "dns-remote" -> { auto2("domain_strategy_for_remote", "") } "dns-direct" -> { auto2("domain_strategy_for_direct", "") } // server else -> { auto2("domain_strategy_for_server", "prefer_ipv4") } } } } fun SingBoxOptions.DNSRule_DefaultOptions.makeSingBoxRule(list: List) { rule_set = mutableListOf() domain = mutableListOf() domain_suffix = mutableListOf() domain_regex = mutableListOf() domain_keyword = mutableListOf() list.forEach { if (it.startsWith("geosite:")) { rule_set.plusAssign(it) } else if (it.startsWith("full:")) { domain.plusAssign(it.removePrefix("full:").lowercase()) } else if (it.startsWith("domain:")) { domain_suffix.plusAssign(it.removePrefix("domain:").lowercase()) } else if (it.startsWith("regexp:")) { domain_regex.plusAssign(it.removePrefix("regexp:").lowercase()) } else if (it.startsWith("keyword:")) { domain_keyword.plusAssign(it.removePrefix("keyword:").lowercase()) } else { domain_suffix.plusAssign(it.lowercase()) } } rule_set?.removeIf { it.isNullOrBlank() } domain?.removeIf { it.isNullOrBlank() } domain_suffix?.removeIf { it.isNullOrBlank() } domain_regex?.removeIf { it.isNullOrBlank() } domain_keyword?.removeIf { it.isNullOrBlank() } if (rule_set?.isEmpty() == true) rule_set = null if (domain?.isEmpty() == true) domain = null if (domain_suffix?.isEmpty() == true) domain_suffix = null if (domain_regex?.isEmpty() == true) domain_regex = null if (domain_keyword?.isEmpty() == true) domain_keyword = null } fun SingBoxOptions.DNSRule_DefaultOptions.checkEmpty(): Boolean { if (rule_set?.isNotEmpty() == true) return false if (domain?.isNotEmpty() == true) return false if (domain_suffix?.isNotEmpty() == true) return false if (domain_regex?.isNotEmpty() == true) return false if (domain_keyword?.isNotEmpty() == true) return false if (user_id?.isNotEmpty() == true) return false return true } fun generateRuleSet(ruleSetString: List, ruleSet: MutableList) { ruleSetString.forEach { when { it.startsWith("geoip:") -> { ruleSet.add(RuleSet().apply { type = "local" tag = it format = "binary" path = it }) } it.startsWith("geosite:") -> { ruleSet.add(RuleSet().apply { type = "local" tag = it format = "binary" path = it }) } } } } fun SingBoxOptions.Rule_DefaultOptions.makeSingBoxRule(list: List, isIP: Boolean) { if (isIP) { ip_cidr = mutableListOf() rule_set = mutableListOf() } else { rule_set = mutableListOf() domain = mutableListOf() domain_suffix = mutableListOf() domain_regex = mutableListOf() domain_keyword = mutableListOf() } list.forEach { if (isIP) { if (it.startsWith("geoip:")) { if (it == "geoip:private") { ip_is_private = true } else { rule_set.plusAssign(it) } } else { ip_cidr.plusAssign(it) } return@forEach } if (it.startsWith("geosite:")) { rule_set.plusAssign(it) } else if (it.startsWith("full:")) { domain.plusAssign(it.removePrefix("full:").lowercase()) } else if (it.startsWith("domain:")) { domain_suffix.plusAssign(it.removePrefix("domain:").lowercase()) } else if (it.startsWith("regexp:")) { domain_regex.plusAssign(it.removePrefix("regexp:").lowercase()) } else if (it.startsWith("keyword:")) { domain_keyword.plusAssign(it.removePrefix("keyword:").lowercase()) } else { domain_suffix.plusAssign(it.lowercase()) } } ip_cidr?.removeIf { it.isNullOrBlank() } rule_set?.removeIf { it.isNullOrBlank() } domain?.removeIf { it.isNullOrBlank() } domain_suffix?.removeIf { it.isNullOrBlank() } domain_regex?.removeIf { it.isNullOrBlank() } domain_keyword?.removeIf { it.isNullOrBlank() } if (ip_cidr?.isEmpty() == true) ip_cidr = null if (domain?.isEmpty() == true) domain = null if (domain_suffix?.isEmpty() == true) domain_suffix = null if (domain_regex?.isEmpty() == true) domain_regex = null if (domain_keyword?.isEmpty() == true) domain_keyword = null } fun SingBoxOptions.Rule_DefaultOptions.checkEmpty(): Boolean { if (ip_cidr?.isNotEmpty() == true) return false if (domain?.isNotEmpty() == true) return false if (rule_set?.isNotEmpty() == true) return false if (domain_suffix?.isNotEmpty() == true) return false if (domain_regex?.isNotEmpty() == true) return false if (domain_keyword?.isNotEmpty() == true) return false if (user_id?.isNotEmpty() == true) return false // if (port?.isNotEmpty() == true) return false if (port_range?.isNotEmpty() == true) return false if (source_ip_cidr?.isNotEmpty() == true) return false // if (!_hack_custom_config.isNullOrBlank()) return false return true } ================================================ FILE: app/src/main/java/moe/matsuri/nb4a/TempDatabase.kt ================================================ package moe.matsuri.nb4a import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase import io.nekohasekai.sagernet.SagerNet import io.nekohasekai.sagernet.database.preference.KeyValuePair import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch @Database(entities = [KeyValuePair::class], version = 1) abstract class TempDatabase : RoomDatabase() { companion object { @Suppress("EXPERIMENTAL_API_USAGE") private val instance by lazy { Room.inMemoryDatabaseBuilder(SagerNet.application, TempDatabase::class.java) .allowMainThreadQueries() .fallbackToDestructiveMigration() .setQueryExecutor { GlobalScope.launch { it.run() } } .build() } val profileCacheDao get() = instance.profileCacheDao() } abstract fun profileCacheDao(): KeyValuePair.Dao } ================================================ FILE: app/src/main/java/moe/matsuri/nb4a/net/LocalResolverImpl.kt ================================================ package moe.matsuri.nb4a.net import android.net.DnsResolver import android.os.Build import android.os.CancellationSignal import android.system.ErrnoException import androidx.annotation.RequiresApi import io.nekohasekai.sagernet.SagerNet import io.nekohasekai.sagernet.ktx.Logs import io.nekohasekai.sagernet.ktx.runOnIoDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.asExecutor import libcore.ExchangeContext import libcore.LocalDNSTransport import java.net.InetAddress import java.net.UnknownHostException object LocalResolverImpl : LocalDNSTransport { // new local private const val RCODE_NXDOMAIN = 3 override fun raw(): Boolean { return Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q } override fun networkHandle(): Long { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { return SagerNet.underlyingNetwork?.networkHandle ?: 0 } return 0 } @RequiresApi(Build.VERSION_CODES.Q) override fun exchange(ctx: ExchangeContext, message: ByteArray) { val signal = CancellationSignal() ctx.onCancel(signal::cancel) val callback = object : DnsResolver.Callback { override fun onAnswer(answer: ByteArray, rcode: Int) { ctx.rawSuccess(answer) } override fun onError(error: DnsResolver.DnsException) { val cause = error.cause if (cause is ErrnoException) { ctx.errnoCode(cause.errno) } else { Logs.w(error) ctx.errnoCode(114514) } } } DnsResolver.getInstance().rawQuery( SagerNet.underlyingNetwork, message, DnsResolver.FLAG_NO_RETRY, Dispatchers.IO.asExecutor(), signal, callback ) } override fun lookup(ctx: ExchangeContext, network: String, domain: String) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { val signal = CancellationSignal() ctx.onCancel(signal::cancel) val callback = object : DnsResolver.Callback> { override fun onAnswer(answer: Collection, rcode: Int) { try { if (rcode == 0) { ctx.success(answer.mapNotNull { it.hostAddress }.joinToString("\n")) } else { ctx.errorCode(rcode) } } catch (e: Exception) { Logs.w(e) ctx.errnoCode(114514) } } override fun onError(error: DnsResolver.DnsException) { try { val cause = error.cause if (cause is ErrnoException) { ctx.errnoCode(cause.errno) } else { Logs.w(error) ctx.errnoCode(114514) } } catch (e: Exception) { Logs.w(e) ctx.errnoCode(114514) } } } val type = when { network.endsWith("4") -> DnsResolver.TYPE_A network.endsWith("6") -> DnsResolver.TYPE_AAAA else -> null } if (type != null) { DnsResolver.getInstance().query( SagerNet.underlyingNetwork, domain, type, DnsResolver.FLAG_NO_RETRY, Dispatchers.IO.asExecutor(), signal, callback ) } else { DnsResolver.getInstance().query( SagerNet.underlyingNetwork, domain, DnsResolver.FLAG_NO_RETRY, Dispatchers.IO.asExecutor(), signal, callback ) } } else { runOnIoDispatcher { // 老版本系统,继续用阻塞的 InetAddress try { val u = SagerNet.underlyingNetwork val answer = try { u?.getAllByName(domain) } catch (e: UnknownHostException) { null } ?: InetAddress.getAllByName(domain) if (answer != null) { ctx.success(answer.mapNotNull { it.hostAddress }.joinToString("\n")) } else { ctx.errnoCode(114514) } } catch (e: UnknownHostException) { ctx.errorCode(RCODE_NXDOMAIN) } catch (e: Exception) { Logs.w(e) ctx.errnoCode(114514) } } } } } ================================================ FILE: app/src/main/java/moe/matsuri/nb4a/plugin/Plugins.kt ================================================ package moe.matsuri.nb4a.plugin import android.content.Intent import android.content.pm.PackageInfo import android.content.pm.PackageManager import android.content.pm.ProviderInfo import android.net.Uri import android.os.Build import android.widget.Toast import io.nekohasekai.sagernet.SagerNet import io.nekohasekai.sagernet.plugin.PluginManager.loadString import io.nekohasekai.sagernet.utils.PackageCache object Plugins { const val AUTHORITIES_PREFIX_SEKAI_EXE = "io.nekohasekai.sagernet.plugin." const val AUTHORITIES_PREFIX_NEKO_EXE = "moe.matsuri.exe." const val ACTION_NATIVE_PLUGIN = "io.nekohasekai.sagernet.plugin.ACTION_NATIVE_PLUGIN" const val METADATA_KEY_ID = "io.nekohasekai.sagernet.plugin.id" const val METADATA_KEY_EXECUTABLE_PATH = "io.nekohasekai.sagernet.plugin.executable_path" fun isExe(pkg: PackageInfo): Boolean { if (pkg.providers?.isEmpty() == true) return false val provider = pkg.providers?.get(0) ?: return false val auth = provider.authority ?: return false return auth.startsWith(AUTHORITIES_PREFIX_SEKAI_EXE) || auth.startsWith(AUTHORITIES_PREFIX_NEKO_EXE) } fun preferExePrefix(): String { return AUTHORITIES_PREFIX_NEKO_EXE } fun isUsingMatsuriExe(pluginId: String): Boolean { getPlugin(pluginId)?.apply { if (authority.startsWith(AUTHORITIES_PREFIX_NEKO_EXE)) { return true } } return false; } fun displayExeProvider(pkgName: String): String { return if (pkgName.startsWith(AUTHORITIES_PREFIX_SEKAI_EXE)) { "SagerNet" } else if (pkgName.startsWith(AUTHORITIES_PREFIX_NEKO_EXE)) { "Matsuri" } else { "Unknown" } } fun getPlugin(pluginId: String): ProviderInfo? { if (pluginId.isBlank()) return null getPluginExternal(pluginId)?.let { return it } // internal so return ProviderInfo().apply { authority = AUTHORITIES_PREFIX_NEKO_EXE } } fun getPluginExternal(pluginId: String): ProviderInfo? { if (pluginId.isBlank()) return null // try queryIntentContentProviders var providers = getExtPluginOld(pluginId) // try PackageCache if (providers.isEmpty()) providers = getExtPluginNew(pluginId) // not found if (providers.isEmpty()) return null if (providers.size > 1) { val prefer = providers.filter { it.authority.startsWith(preferExePrefix()) } if (prefer.size == 1) providers = prefer } if (providers.size > 1) { val message = "Conflicting plugins found from: ${providers.joinToString { it.packageName }}" Toast.makeText(SagerNet.application, message, Toast.LENGTH_LONG).show() } return providers[0] } private fun getExtPluginNew(pluginId: String): List { PackageCache.awaitLoadSync() val pkgs = PackageCache.installedPluginPackages .map { it.value } .filter { it.providers?.get(0)?.loadString(METADATA_KEY_ID) == pluginId } return pkgs.mapNotNull { it.providers?.get(0) } } private fun buildUri(id: String, auth: String) = Uri.Builder() .scheme("plugin") .authority(auth) .path("/$id") .build() private fun getExtPluginOld(pluginId: String): List { var flags = PackageManager.GET_META_DATA if (Build.VERSION.SDK_INT >= 24) { flags = flags or PackageManager.MATCH_DIRECT_BOOT_UNAWARE or PackageManager.MATCH_DIRECT_BOOT_AWARE } val list1 = SagerNet.application.packageManager.queryIntentContentProviders( Intent(ACTION_NATIVE_PLUGIN, buildUri(pluginId, "io.nekohasekai.sagernet")), flags ) val list2 = SagerNet.application.packageManager.queryIntentContentProviders( Intent(ACTION_NATIVE_PLUGIN, buildUri(pluginId, "moe.matsuri.lite")), flags ) return (list1 + list2).mapNotNull { it.providerInfo }.filter { it.exported } } } ================================================ FILE: app/src/main/java/moe/matsuri/nb4a/proxy/PreferenceBinding.kt ================================================ package moe.matsuri.nb4a.proxy import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.ktx.Logs import io.nekohasekai.sagernet.ktx.readableMessage object Type { const val Text = 0 const val TextToInt = 1 const val Int = 2 const val Bool = 3 } class PreferenceBinding( val type: Int = Type.Text, var fieldName: String, var bean: Any? = null, var pf: PreferenceFragmentCompat? = null ) { var cacheName = fieldName var disable = false fun readStringFromCache(): String { return DataStore.profileCacheStore.getString(cacheName) ?: "" } fun readBoolFromCache(): Boolean { return DataStore.profileCacheStore.getBoolean(cacheName, false) } fun readIntFromCache(): Int { return DataStore.profileCacheStore.getInt(cacheName, 0) } fun readStringToIntFromCache(): Int { val value = DataStore.profileCacheStore.getString(cacheName)?.toIntOrNull() ?: 0 // Logs.d("readStringToIntFromCache $value $cacheName -> $fieldName") return value } fun fromCache() { if (disable) return val f = try { bean!!.javaClass.getField(fieldName) } catch (e: Exception) { Logs.d("binding no field: ${e.readableMessage}") return } when (type) { Type.Text -> f.set(bean, readStringFromCache()) Type.TextToInt -> f.set(bean, readStringToIntFromCache()) Type.Int -> f.set(bean, readIntFromCache()) Type.Bool -> f.set(bean, readBoolFromCache()) } } fun writeToCache() { if (disable) return val f = try { bean!!.javaClass.getField(fieldName) ?: return } catch (e: Exception) { Logs.d("binding no field: ${e.readableMessage}") return } val value = f.get(bean) when (type) { Type.Text -> { if (value is String) { // Logs.d("writeToCache TEXT $value $cacheName -> $fieldName") DataStore.profileCacheStore.putString(cacheName, value) } } Type.TextToInt -> { if (value is Int) { // Logs.d("writeToCache TEXT2INT $value $cacheName -> $fieldName") DataStore.profileCacheStore.putString(cacheName, value.toString()) } } Type.Int -> { if (value is Int) { DataStore.profileCacheStore.putInt(cacheName, value) } } Type.Bool -> { if (value is Boolean) { DataStore.profileCacheStore.putBoolean(cacheName, value) } } } } val preference by lazy { pf!!.findPreference(cacheName)!! } } ================================================ FILE: app/src/main/java/moe/matsuri/nb4a/proxy/PreferenceBindingManager.kt ================================================ package moe.matsuri.nb4a.proxy import androidx.preference.PreferenceFragmentCompat class PreferenceBindingManager { val items = mutableListOf() fun add(b: PreferenceBinding): PreferenceBinding { items.add(b) return b } fun fromCacheAll(bean: Any) { items.forEach { it.bean = bean it.fromCache() } } fun writeToCacheAll(bean: Any) { items.forEach { it.bean = bean it.writeToCache() } } fun setPreferenceFragment(pf: PreferenceFragmentCompat) { items.forEach { it.pf = pf } } } ================================================ FILE: app/src/main/java/moe/matsuri/nb4a/proxy/anytls/AnyTLSBean.java ================================================ package moe.matsuri.nb4a.proxy.anytls; import androidx.annotation.NonNull; import com.esotericsoftware.kryo.io.ByteBufferInput; import com.esotericsoftware.kryo.io.ByteBufferOutput; import org.jetbrains.annotations.NotNull; import io.nekohasekai.sagernet.fmt.AbstractBean; import io.nekohasekai.sagernet.fmt.KryoConverters; public class AnyTLSBean extends AbstractBean { public static final Creator CREATOR = new CREATOR() { @NonNull @Override public AnyTLSBean newInstance() { return new AnyTLSBean(); } @Override public AnyTLSBean[] newArray(int size) { return new AnyTLSBean[size]; } }; public String password; public String sni; public String alpn; public String certificates; public String utlsFingerprint; public Boolean allowInsecure; // In sing-box, this seemed can be used with REALITY. // But even mihomo appended many options, it still not provide REALITY. // https://github.com/anytls/anytls-go/blob/4636d90462fa21a510420512d7706a9acf69c7b9/docs/faq.md?plain=1#L25-L37 public String echConfig; @Override public void initializeDefaultValues() { super.initializeDefaultValues(); if (password == null) password = ""; if (sni == null) sni = ""; if (alpn == null) alpn = ""; if (certificates == null) certificates = ""; if (utlsFingerprint == null) utlsFingerprint = ""; if (allowInsecure == null) allowInsecure = false; if (echConfig == null) echConfig = ""; } @Override public void serialize(ByteBufferOutput output) { output.writeInt(0); super.serialize(output); output.writeString(password); output.writeString(sni); output.writeString(alpn); output.writeString(certificates); output.writeString(utlsFingerprint); output.writeBoolean(allowInsecure); output.writeString(echConfig); } @Override public void deserialize(ByteBufferInput input) { int version = input.readInt(); super.deserialize(input); password = input.readString(); sni = input.readString(); alpn = input.readString(); certificates = input.readString(); utlsFingerprint = input.readString(); allowInsecure = input.readBoolean(); echConfig = input.readString(); } @NotNull @Override public AnyTLSBean clone() { return KryoConverters.deserialize(new AnyTLSBean(), KryoConverters.serialize(this)); } } ================================================ FILE: app/src/main/java/moe/matsuri/nb4a/proxy/anytls/AnyTLSFmt.kt ================================================ package moe.matsuri.nb4a.proxy.anytls import io.nekohasekai.sagernet.ktx.blankAsNull import io.nekohasekai.sagernet.ktx.linkBuilder import io.nekohasekai.sagernet.ktx.toLink import io.nekohasekai.sagernet.ktx.urlSafe import moe.matsuri.nb4a.SingBoxOptions import moe.matsuri.nb4a.utils.listByLineOrComma import okhttp3.HttpUrl.Companion.toHttpUrlOrNull fun buildSingBoxOutboundAnyTLSBean(bean: AnyTLSBean): SingBoxOptions.Outbound_AnyTLSOptions { return SingBoxOptions.Outbound_AnyTLSOptions().apply { type = "anytls" server = bean.serverAddress server_port = bean.serverPort password = bean.password tls = SingBoxOptions.OutboundTLSOptions().apply { enabled = true server_name = bean.sni.blankAsNull() if (bean.allowInsecure) insecure = true alpn = bean.alpn.blankAsNull()?.listByLineOrComma() bean.certificates.blankAsNull()?.let { certificate = it } bean.utlsFingerprint.blankAsNull()?.let { utls = SingBoxOptions.OutboundUTLSOptions().apply { enabled = true fingerprint = it } } bean.echConfig.blankAsNull()?.let { // In new version, some complex options will be deprecated, so we just do this. ech = SingBoxOptions.OutboundECHOptions().apply { enabled = true config = listOf(it) } } } } } fun AnyTLSBean.toUri(): String { val builder = linkBuilder() .host(serverAddress) .port(serverPort) .username(password) if (!name.isNullOrBlank()) { builder.encodedFragment(name.urlSafe()) } if (allowInsecure) { builder.addQueryParameter("insecure", "1") } if (!sni.isNullOrBlank()) { builder.addQueryParameter("sni", sni) } if (!utlsFingerprint.isNullOrBlank()) { builder.addQueryParameter("fp", utlsFingerprint) } return builder.toLink("anytls") } fun parseAnytls(url: String): AnyTLSBean { // https://github.com/anytls/anytls-go/blob/main/docs/uri_scheme.md val link = url.replace("anytls://", "https://").toHttpUrlOrNull() ?: error( "invalid anytls link $url" ) return AnyTLSBean().apply { serverAddress = link.host serverPort = link.port name = link.fragment password = link.username sni = link.queryParameter("sni") ?: "" link.queryParameter("insecure")?.also { allowInsecure = it == "1" || it == "true" } link.queryParameter("fp")?.let { utlsFingerprint = it } } } ================================================ FILE: app/src/main/java/moe/matsuri/nb4a/proxy/anytls/AnyTLSSettingsActivity.kt ================================================ package moe.matsuri.nb4a.proxy.anytls import android.os.Bundle import androidx.preference.EditTextPreference import androidx.preference.PreferenceFragmentCompat import io.nekohasekai.sagernet.Key import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.database.preference.EditTextPreferenceModifiers import io.nekohasekai.sagernet.ktx.applyDefaultValues import io.nekohasekai.sagernet.ui.profile.ProfileSettingsActivity import moe.matsuri.nb4a.proxy.PreferenceBinding import moe.matsuri.nb4a.proxy.PreferenceBindingManager import moe.matsuri.nb4a.proxy.Type class AnyTLSSettingsActivity : ProfileSettingsActivity() { override fun createEntity() = AnyTLSBean().applyDefaultValues() private val pbm = PreferenceBindingManager() private val name = pbm.add(PreferenceBinding(Type.Text, "name")) private val serverAddress = pbm.add(PreferenceBinding(Type.Text, "serverAddress")) private val serverPort = pbm.add(PreferenceBinding(Type.TextToInt, "serverPort")) private val password = pbm.add(PreferenceBinding(Type.Text, "password")) private val sni = pbm.add(PreferenceBinding(Type.Text, "sni")) private val alpn = pbm.add(PreferenceBinding(Type.Text, "alpn")) private val certificates = pbm.add(PreferenceBinding(Type.Text, "certificates")) private val allowInsecure = pbm.add(PreferenceBinding(Type.Bool, "allowInsecure")) private val utlsFingerprint = pbm.add(PreferenceBinding(Type.Text, "utlsFingerprint")) override fun AnyTLSBean.init() { pbm.writeToCacheAll(this) } override fun AnyTLSBean.serialize() { pbm.fromCacheAll(this) } override fun PreferenceFragmentCompat.createPreferences( savedInstanceState: Bundle?, rootKey: String? ) { addPreferencesFromResource(R.xml.anytls_preferences) findPreference(Key.SERVER_PORT)!!.apply { setOnBindEditTextListener(EditTextPreferenceModifiers.Port) } findPreference("password")!!.apply { summaryProvider = PasswordSummaryProvider } } } ================================================ FILE: app/src/main/java/moe/matsuri/nb4a/proxy/config/ConfigBean.java ================================================ package moe.matsuri.nb4a.proxy.config; import androidx.annotation.NonNull; import com.esotericsoftware.kryo.io.ByteBufferInput; import com.esotericsoftware.kryo.io.ByteBufferOutput; import com.google.gson.JsonObject; import org.jetbrains.annotations.NotNull; import io.nekohasekai.sagernet.fmt.KryoConverters; import io.nekohasekai.sagernet.fmt.internal.InternalBean; import moe.matsuri.nb4a.utils.JavaUtil; public class ConfigBean extends InternalBean { public Integer type; // 0=config 1=outbound public String config; @Override public void initializeDefaultValues() { super.initializeDefaultValues(); if (type == null) type = 0; if (config == null) config = ""; } @Override public void serialize(ByteBufferOutput output) { output.writeInt(0); super.serialize(output); output.writeInt(type); output.writeString(config); } @Override public void deserialize(ByteBufferInput input) { int version = input.readInt(); super.deserialize(input); type = input.readInt(); config = input.readString(); } @Override public String displayName() { if (JavaUtil.isNotBlank(name)) { return name; } else { return "Custom " + Math.abs(hashCode()); } } public String displayType() { if (type != null && type == 1 && JavaUtil.isNotBlank(config)) { try { JsonObject json = JavaUtil.gson.fromJson(config, JsonObject.class); if (json != null && json.has("type")) { return json.get("type").getAsString() + " (sing-box)"; } } catch (Exception ignored) { } } return type != null && type == 0 ? "sing-box config" : "sing-box outbound"; } @NotNull @Override public ConfigBean clone() { return KryoConverters.deserialize(new ConfigBean(), KryoConverters.serialize(this)); } public static final Creator CREATOR = new CREATOR() { @NonNull @Override public ConfigBean newInstance() { return new ConfigBean(); } @Override public ConfigBean[] newArray(int size) { return new ConfigBean[size]; } }; } ================================================ FILE: app/src/main/java/moe/matsuri/nb4a/proxy/config/ConfigSettingActivity.kt ================================================ package moe.matsuri.nb4a.proxy.config import android.os.Bundle import androidx.preference.PreferenceDataStore import androidx.preference.PreferenceFragmentCompat import io.nekohasekai.sagernet.Key import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.database.preference.OnPreferenceDataStoreChangeListener import io.nekohasekai.sagernet.ui.profile.ProfileSettingsActivity import moe.matsuri.nb4a.ui.EditConfigPreference class ConfigSettingActivity : ProfileSettingsActivity(), OnPreferenceDataStoreChangeListener { private val isOutboundOnlyKey = "isOutboundOnly" override fun createEntity() = ConfigBean() override fun ConfigBean.init() { // CustomBean to input DataStore.profileCacheStore.putBoolean(isOutboundOnlyKey, type == 1) DataStore.profileName = name DataStore.serverConfig = config } override fun ConfigBean.serialize() { // CustomBean from input type = if (DataStore.profileCacheStore.getBoolean(isOutboundOnlyKey, false)) 1 else 0 name = DataStore.profileName config = DataStore.serverConfig } override fun onPreferenceDataStoreChanged(store: PreferenceDataStore, key: String) { if (key != Key.PROFILE_DIRTY) { DataStore.dirty = true } } private lateinit var editConfigPreference: EditConfigPreference override fun PreferenceFragmentCompat.createPreferences( savedInstanceState: Bundle?, rootKey: String?, ) { addPreferencesFromResource(R.xml.config_preferences) editConfigPreference = findPreference(Key.SERVER_CONFIG)!! } override fun onResume() { super.onResume() if (::editConfigPreference.isInitialized) { editConfigPreference.notifyChanged() } } } ================================================ FILE: app/src/main/java/moe/matsuri/nb4a/proxy/neko/NekoBean.java ================================================ package moe.matsuri.nb4a.proxy.neko; import androidx.annotation.NonNull; import com.esotericsoftware.kryo.io.ByteBufferInput; import com.esotericsoftware.kryo.io.ByteBufferOutput; import org.jetbrains.annotations.NotNull; import org.json.JSONObject; import io.nekohasekai.sagernet.fmt.AbstractBean; import io.nekohasekai.sagernet.fmt.KryoConverters; import io.nekohasekai.sagernet.ktx.Logs; public class NekoBean extends AbstractBean { public String plgId; public String protocolId; public JSONObject sharedStorage = new JSONObject(); @Override public void initializeDefaultValues() { super.initializeDefaultValues(); if (protocolId == null) protocolId = ""; if (plgId == null) plgId = "moe.matsuri.plugin.donotexist"; } @Override public void serialize(ByteBufferOutput output) { output.writeInt(0); super.serialize(output); output.writeString(plgId); output.writeString(protocolId); output.writeString(sharedStorage.toString()); } @Override public void deserialize(ByteBufferInput input) { int version = input.readInt(); super.deserialize(input); plgId = input.readString(); protocolId = input.readString(); sharedStorage = tryParseJSON(input.readString()); } @NotNull public static JSONObject tryParseJSON(String input) { JSONObject ret; try { ret = new JSONObject(input); } catch (Exception e) { ret = new JSONObject(); Logs.INSTANCE.e(e); } return ret; } public String displayType() { return "invalid"; } @Override public boolean canMapping() { return false; } @Override public boolean canICMPing() { return false; } @Override public boolean canTCPing() { return false; } @NotNull @Override public NekoBean clone() { return KryoConverters.deserialize(new NekoBean(), KryoConverters.serialize(this)); } public static final Creator CREATOR = new CREATOR() { @NonNull @Override public NekoBean newInstance() { return new NekoBean(); } @Override public NekoBean[] newArray(int size) { return new NekoBean[size]; } }; } ================================================ FILE: app/src/main/java/moe/matsuri/nb4a/proxy/shadowtls/ShadowTLSBean.java ================================================ package moe.matsuri.nb4a.proxy.shadowtls; import androidx.annotation.NonNull; import com.esotericsoftware.kryo.io.ByteBufferInput; import com.esotericsoftware.kryo.io.ByteBufferOutput; import org.jetbrains.annotations.NotNull; import io.nekohasekai.sagernet.fmt.KryoConverters; import io.nekohasekai.sagernet.fmt.v2ray.StandardV2RayBean; public class ShadowTLSBean extends StandardV2RayBean { public Integer version; public String password; @Override public void initializeDefaultValues() { super.initializeDefaultValues(); security = "tls"; if (version == null) version = 3; if (password == null) password = ""; } @Override public void serialize(ByteBufferOutput output) { output.writeInt(0); super.serialize(output); output.writeInt(version); output.writeString(password); } @Override public void deserialize(ByteBufferInput input) { int version_ = input.readInt(); super.deserialize(input); version = input.readInt(); password = input.readString(); } @NotNull @Override public ShadowTLSBean clone() { return KryoConverters.deserialize(new ShadowTLSBean(), KryoConverters.serialize(this)); } public static final Creator CREATOR = new CREATOR() { @NonNull @Override public ShadowTLSBean newInstance() { return new ShadowTLSBean(); } @Override public ShadowTLSBean[] newArray(int size) { return new ShadowTLSBean[size]; } }; } ================================================ FILE: app/src/main/java/moe/matsuri/nb4a/proxy/shadowtls/ShadowTLSFmt.kt ================================================ package moe.matsuri.nb4a.proxy.shadowtls import io.nekohasekai.sagernet.fmt.v2ray.buildSingBoxOutboundTLS import moe.matsuri.nb4a.SingBoxOptions fun buildSingBoxOutboundShadowTLSBean(bean: ShadowTLSBean): SingBoxOptions.Outbound_ShadowTLSOptions { return SingBoxOptions.Outbound_ShadowTLSOptions().apply { type = "shadowtls" server = bean.serverAddress server_port = bean.serverPort version = bean.version password = bean.password tls = buildSingBoxOutboundTLS(bean) } } ================================================ FILE: app/src/main/java/moe/matsuri/nb4a/proxy/shadowtls/ShadowTLSSettingsActivity.kt ================================================ package moe.matsuri.nb4a.proxy.shadowtls import android.os.Bundle import androidx.preference.EditTextPreference import androidx.preference.PreferenceFragmentCompat import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.database.preference.EditTextPreferenceModifiers import io.nekohasekai.sagernet.ui.profile.ProfileSettingsActivity import moe.matsuri.nb4a.proxy.PreferenceBinding import moe.matsuri.nb4a.proxy.PreferenceBindingManager import moe.matsuri.nb4a.proxy.Type class ShadowTLSSettingsActivity : ProfileSettingsActivity() { override fun createEntity() = ShadowTLSBean() private val pbm = PreferenceBindingManager() private val name = pbm.add(PreferenceBinding(Type.Text, "name")) private val serverAddress = pbm.add(PreferenceBinding(Type.Text, "serverAddress")) private val serverPort = pbm.add(PreferenceBinding(Type.TextToInt, "serverPort")) private val password = pbm.add(PreferenceBinding(Type.Text, "password")) private val version = pbm.add(PreferenceBinding(Type.TextToInt, "version")) private val sni = pbm.add(PreferenceBinding(Type.Text, "sni")) private val alpn = pbm.add(PreferenceBinding(Type.Text, "alpn")) private val certificates = pbm.add(PreferenceBinding(Type.Text, "certificates")) private val allowInsecure = pbm.add(PreferenceBinding(Type.Bool, "allowInsecure")) private val utlsFingerprint = pbm.add(PreferenceBinding(Type.Text, "utlsFingerprint")) override fun ShadowTLSBean.init() { pbm.writeToCacheAll(this) } override fun ShadowTLSBean.serialize() { pbm.fromCacheAll(this) } override fun PreferenceFragmentCompat.createPreferences( savedInstanceState: Bundle?, rootKey: String?, ) { addPreferencesFromResource(R.xml.shadowtls_preferences) pbm.setPreferenceFragment(this) serverPort.preference.apply { this as EditTextPreference setOnBindEditTextListener(EditTextPreferenceModifiers.Port) } password.preference.apply { this as EditTextPreference summaryProvider = PasswordSummaryProvider } } } ================================================ FILE: app/src/main/java/moe/matsuri/nb4a/ui/ColorPickerPreference.kt ================================================ package moe.matsuri.nb4a.ui import android.content.Context import android.content.res.Resources import android.graphics.drawable.Drawable import android.util.AttributeSet import android.view.Gravity import android.view.View import android.view.ViewGroup import android.widget.GridLayout import android.widget.ImageView import android.widget.LinearLayout import androidx.appcompat.app.AlertDialog import androidx.core.content.res.ResourcesCompat import androidx.core.content.res.TypedArrayUtils import androidx.core.graphics.drawable.DrawableCompat import androidx.core.view.setPadding import androidx.preference.Preference import androidx.preference.PreferenceViewHolder import com.google.android.material.dialog.MaterialAlertDialogBuilder import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.ktx.getColorAttr import kotlin.math.roundToInt class ColorPickerPreference @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyle: Int = TypedArrayUtils.getAttr( context, androidx.preference.R.attr.editTextPreferenceStyle, android.R.attr.editTextPreferenceStyle ) ) : Preference( context, attrs, defStyle ) { var inited = false override fun onBindViewHolder(holder: PreferenceViewHolder) { super.onBindViewHolder(holder) val widgetFrame = holder.findViewById(android.R.id.widget_frame) as LinearLayout if (!inited) { inited = true widgetFrame.addView( getNekoImageViewAtColor( context.getColorAttr(R.attr.colorPrimary), 48, 0 ) ) widgetFrame.visibility = View.VISIBLE } } fun getNekoImageViewAtColor(color: Int, sizeDp: Int, paddingDp: Int): ImageView { // dp to pixel val factor = context.resources.displayMetrics.density val size = (sizeDp * factor).roundToInt() val paddingSize = (paddingDp * factor).roundToInt() return ImageView(context).apply { layoutParams = ViewGroup.LayoutParams(size, size) setPadding(paddingSize) setImageDrawable(getNekoAtColor(resources, color)) } } fun getNekoAtColor(res: Resources, color: Int): Drawable { val neko = ResourcesCompat.getDrawable( res, R.drawable.ic_baseline_fiber_manual_record_24, null )!! DrawableCompat.setTint(neko.mutate(), color) return neko } override fun onClick() { super.onClick() lateinit var dialog: AlertDialog val grid = GridLayout(context).apply { columnCount = 4 val colors = context.resources.getIntArray(R.array.material_colors) var i = 0 for (color in colors) { i++ //Theme.kt val themeId = i val view = getNekoImageViewAtColor(color, 64, 0).apply { setOnClickListener { persistInt(themeId) dialog.dismiss() callChangeListener(themeId) } } addView(view) } } dialog = MaterialAlertDialogBuilder(context).setTitle(title) .setView(LinearLayout(context).apply { gravity = Gravity.CENTER layoutParams = ViewGroup.LayoutParams( ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT ) addView(grid) }) .setNegativeButton(android.R.string.cancel, null) .show() } } ================================================ FILE: app/src/main/java/moe/matsuri/nb4a/ui/ConnectionTestNotification.kt ================================================ package moe.matsuri.nb4a.ui import android.content.Context import androidx.core.app.NotificationCompat import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.SagerNet import io.nekohasekai.sagernet.ktx.Logs class ConnectionTestNotification(val context: Context, val title: String) { private val channelId = "connection-test" private val notificationId = 1001 fun updateNotification(progress: Int, max: Int, finished: Boolean) { try { if (finished) { SagerNet.notification.cancel(notificationId) return } val builder = NotificationCompat.Builder(context, channelId) .setSmallIcon(R.drawable.ic_service_active) .setContentTitle(title) .setOnlyAlertOnce(true) .setContentText("$progress / $max").setProgress(max, progress, false) SagerNet.notification.notify(notificationId, builder.build()) } catch (e: Exception) { Logs.w(e) } } } ================================================ FILE: app/src/main/java/moe/matsuri/nb4a/ui/Dialogs.kt ================================================ package moe.matsuri.nb4a.ui import android.content.Context import android.widget.TextView import com.google.android.material.dialog.MaterialAlertDialogBuilder import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.ktx.Logs import io.nekohasekai.sagernet.ktx.readableMessage import io.nekohasekai.sagernet.ktx.runOnMainDispatcher object Dialogs { fun logExceptionAndShow(context: Context, e: Exception, callback: Runnable) { Logs.e(e) runOnMainDispatcher { MaterialAlertDialogBuilder(context) .setTitle(R.string.error_title) .setMessage(e.readableMessage) .setCancelable(false) .setPositiveButton(android.R.string.ok) { _, _ -> callback.run() } .show() } } fun message(context: Context, title: String, message: String) { runOnMainDispatcher { val dialog = MaterialAlertDialogBuilder(context) .setTitle(title) .setMessage(message) .setCancelable(true) .show() dialog.findViewById(android.R.id.message)?.apply { setTextIsSelectable(true) } } } } ================================================ FILE: app/src/main/java/moe/matsuri/nb4a/ui/EditConfigPreference.kt ================================================ package moe.matsuri.nb4a.ui import android.content.Context import android.content.Intent import android.util.AttributeSet import androidx.preference.Preference import io.nekohasekai.sagernet.Key import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.ktx.Logs import io.nekohasekai.sagernet.ktx.app import io.nekohasekai.sagernet.ui.profile.ConfigEditActivity class EditConfigPreference : Preference { constructor( context: Context, attrs: AttributeSet?, defStyleAttr: Int, defStyleRes: Int ) : super(context, attrs, defStyleAttr, defStyleRes) constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super( context, attrs, defStyleAttr ) constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) constructor(context: Context) : super(context) init { intent = Intent(context, ConfigEditActivity::class.java) } var configKey = Key.SERVER_CONFIG var useConfigStore = false fun useConfigStore(key: String) { try { this.configKey = key useConfigStore = true intent = intent!!.apply { putExtra("useConfigStore", "1") putExtra("key", key) } } catch (e: Exception) { Logs.w(e) } } override fun getSummary(): CharSequence { val config = (if (useConfigStore) DataStore.configurationStore.getString(configKey) else DataStore.serverConfig) ?: "" return if (config.isBlank()) { return app.resources.getString(androidx.preference.R.string.not_set) } else { app.resources.getString(R.string.lines, config.split('\n').size) } } public override fun notifyChanged() { super.notifyChanged() } } ================================================ FILE: app/src/main/java/moe/matsuri/nb4a/ui/ExtendedKeyboard.kt ================================================ /* * Copyright 2021 Squircle IDE contributors. * * 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 moe.matsuri.nb4a.ui import android.content.Context import android.graphics.Color import android.util.AttributeSet import android.view.LayoutInflater import android.view.ViewGroup import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView import io.nekohasekai.sagernet.databinding.ItemKeyboardKeyBinding class ExtendedKeyboard @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : RecyclerView(context, attrs, defStyleAttr) { private lateinit var keyAdapter: KeyAdapter fun setKeyListener(keyListener: OnKeyListener) { keyAdapter = KeyAdapter(keyListener) adapter = keyAdapter } fun submitList(keys: List) { keyAdapter.submitList(keys) } private class KeyAdapter( private val keyListener: OnKeyListener ) : ListAdapter(diffCallback) { companion object { private val diffCallback = object : DiffUtil.ItemCallback() { override fun areItemsTheSame(oldItem: String, newItem: String): Boolean { return oldItem == newItem } override fun areContentsTheSame(oldItem: String, newItem: String): Boolean { return oldItem == newItem } } } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): KeyViewHolder { return KeyViewHolder.create(parent, keyListener) } override fun onBindViewHolder(holder: KeyViewHolder, position: Int) { holder.bind(getItem(position)) } private class KeyViewHolder( private val binding: ItemKeyboardKeyBinding, private val keyListener: OnKeyListener ) : ViewHolder(binding.root) { companion object { fun create(parent: ViewGroup, keyListener: OnKeyListener): KeyViewHolder { val inflater = LayoutInflater.from(parent.context) val binding = ItemKeyboardKeyBinding.inflate(inflater, parent, false) return KeyViewHolder(binding, keyListener) } } private lateinit var char: String init { itemView.setOnClickListener { keyListener.onKey(char) } } fun bind(item: String) { char = item binding.itemTitle.text = char binding.itemTitle.setTextColor(Color.WHITE) } } } fun interface OnKeyListener { fun onKey(char: String) } } ================================================ FILE: app/src/main/java/moe/matsuri/nb4a/ui/LongClickListPreference.kt ================================================ package moe.matsuri.nb4a.ui import android.content.Context import android.util.AttributeSet import android.view.View import androidx.preference.ListPreference import androidx.preference.PreferenceViewHolder import io.nekohasekai.sagernet.R class LongClickListPreference @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyle: Int = R.attr.dropdownPreferenceStyle ) : ListPreference(context, attrs, defStyle, 0) { private var mLongClickListener: View.OnLongClickListener? = null override fun onBindViewHolder(holder: PreferenceViewHolder) { super.onBindViewHolder(holder) val itemView: View = holder.itemView itemView.setOnLongClickListener { mLongClickListener?.onLongClick(it) ?: true } } fun setOnLongClickListener(longClickListener: View.OnLongClickListener) { this.mLongClickListener = longClickListener } } ================================================ FILE: app/src/main/java/moe/matsuri/nb4a/ui/LongClickMenuPreference.kt ================================================ package moe.matsuri.nb4a.ui import android.content.Context import android.util.AttributeSet import android.view.View import androidx.preference.PreferenceViewHolder import io.nekohasekai.sagernet.R class LongClickMenuPreference @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyle: Int = R.attr.dropdownPreferenceStyle ) : SimpleMenuPreference(context, attrs, defStyle, 0) { private var mLongClickListener: View.OnLongClickListener? = null override fun onBindViewHolder(holder: PreferenceViewHolder) { super.onBindViewHolder(holder) val itemView: View = holder.itemView itemView.setOnLongClickListener { mLongClickListener?.onLongClick(it) ?: true } } fun setOnLongClickListener(longClickListener: View.OnLongClickListener) { this.mLongClickListener = longClickListener } } ================================================ FILE: app/src/main/java/moe/matsuri/nb4a/ui/LongClickSwitchPreference.kt ================================================ package moe.matsuri.nb4a.ui import android.content.Context import android.util.AttributeSet import android.view.View import androidx.core.content.res.TypedArrayUtils import androidx.preference.PreferenceViewHolder import androidx.preference.R import androidx.preference.SwitchPreference class LongClickSwitchPreference @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = TypedArrayUtils.getAttr( context, R.attr.switchPreferenceStyle, android.R.attr.switchPreferenceStyle ), defStyleRes: Int = 0 ) : SwitchPreference( context, attrs, defStyleAttr, defStyleRes ) { private var mLongClickListener: View.OnLongClickListener? = null override fun onBindViewHolder(holder: PreferenceViewHolder) { super.onBindViewHolder(holder) val itemView: View = holder.itemView itemView.setOnLongClickListener { mLongClickListener?.onLongClick(it) ?: true } } fun setOnLongClickListener(longClickListener: View.OnLongClickListener) { this.mLongClickListener = longClickListener } } ================================================ FILE: app/src/main/java/moe/matsuri/nb4a/ui/MTUPreference.kt ================================================ package moe.matsuri.nb4a.ui import android.content.Context import android.util.AttributeSet import android.view.View import android.view.inputmethod.EditorInfo import android.widget.EditText import androidx.preference.ListPreference import androidx.preference.PreferenceViewHolder import com.google.android.material.dialog.MaterialAlertDialogBuilder import io.nekohasekai.sagernet.R class MTUPreference @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyle: Int = R.attr.dropdownPreferenceStyle ) : ListPreference(context, attrs, defStyle, 0) { init { setSummaryProvider { value.toString() } dialogLayoutResource = R.layout.layout_mtu_help } override fun onBindViewHolder(holder: PreferenceViewHolder) { super.onBindViewHolder(holder) val itemView: View = holder.itemView itemView.setOnLongClickListener { val view = EditText(context).apply { inputType = EditorInfo.TYPE_CLASS_NUMBER setText(preferenceDataStore?.getString(key, "") ?: "") } MaterialAlertDialogBuilder(context).setTitle("MTU") .setView(view) .setPositiveButton(android.R.string.ok) { _, _ -> val mtu = view.text.toString().toInt() if (mtu < 1000 || mtu > 10000) return@setPositiveButton value = mtu.toString() } .setNegativeButton(android.R.string.cancel, null) .show() true } } } ================================================ FILE: app/src/main/java/moe/matsuri/nb4a/ui/SimpleMenuPreference.kt ================================================ /* * Copyright (C) 2020 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 moe.matsuri.nb4a.ui import android.content.Context import android.util.AttributeSet import android.view.View import android.view.ViewGroup import android.widget.ArrayAdapter import android.widget.Spinner import androidx.core.content.ContextCompat import androidx.preference.DropDownPreference import androidx.preference.PreferenceViewHolder import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.ktx.getColorAttr /** * Bend [DropDownPreference] to support * [Simple Menus](https://material.google.com/components/menus.html#menus-behavior). */ open class SimpleMenuPreference @JvmOverloads constructor( context: Context?, attrs: AttributeSet? = null, defStyleAttr: Int = androidx.preference.R.attr.dropdownPreferenceStyle, defStyleRes: Int = 0 ) : DropDownPreference(context!!, attrs, defStyleAttr, defStyleRes) { private lateinit var mAdapter: SimpleMenuAdapter override fun onBindViewHolder(holder: PreferenceViewHolder) { super.onBindViewHolder(holder) val mSpinner = holder.itemView.findViewById(R.id.spinner) mSpinner.layoutParams.width = ViewGroup.LayoutParams.WRAP_CONTENT } override fun createAdapter(): ArrayAdapter { mAdapter = SimpleMenuAdapter(getContext(), R.layout.simple_menu_dropdown_item) return mAdapter } override fun setValue(value: String?) { super.setValue(value) if (::mAdapter.isInitialized) { mAdapter.currentPosition = entryValues.indexOf(value) mAdapter.notifyDataSetChanged() } } private class SimpleMenuAdapter(context: Context, resource: Int) : ArrayAdapter(context, resource) { var currentPosition = -1 override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View { val view: View = super.getDropDownView(position, convertView, parent) if (position == currentPosition) { view.setBackgroundColor(context.getColorAttr(R.attr.colorMaterial100)) } else { view.setBackgroundColor( ContextCompat.getColor( context, R.color.preference_simple_menu_background ) ) } return view } } } ================================================ FILE: app/src/main/java/moe/matsuri/nb4a/ui/UrlTestPreference.kt ================================================ package moe.matsuri.nb4a.ui import android.content.Context import android.util.AttributeSet import android.widget.EditText import android.widget.LinearLayout import androidx.core.content.res.TypedArrayUtils import androidx.core.view.isVisible import androidx.preference.EditTextPreference import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.database.DataStore class UrlTestPreference @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = TypedArrayUtils.getAttr( context, R.attr.editTextPreferenceStyle, android.R.attr.editTextPreferenceStyle ), defStyleRes: Int = 0 ) : EditTextPreference(context, attrs, defStyleAttr, defStyleRes) { var concurrent: EditText? = null init { dialogLayoutResource = R.layout.layout_urltest_preference_dialog setOnBindEditTextListener { concurrent = it.rootView.findViewById(R.id.edit_concurrent) concurrent?.apply { setText(DataStore.connectionTestConcurrent.toString()) } it.rootView.findViewById(R.id.concurrent_layout)?.isVisible = true } setOnPreferenceChangeListener { _, _ -> concurrent?.apply { var newConcurrent = text?.toString()?.toIntOrNull() if (newConcurrent == null || newConcurrent <= 0) { newConcurrent = 5 } DataStore.connectionTestConcurrent = newConcurrent } true } } } ================================================ FILE: app/src/main/java/moe/matsuri/nb4a/utils/JavaUtil.java ================================================ package moe.matsuri.nb4a.utils; import android.annotation.SuppressLint; import android.annotation.TargetApi; import android.app.Application; import android.content.Context; import android.os.Build; import android.text.TextUtils; import android.webkit.WebView; import com.google.gson.Gson; import com.google.gson.GsonBuilder; import com.google.gson.ToNumberPolicy; import java.io.File; import java.io.RandomAccessFile; import java.lang.reflect.Method; import java.nio.channels.FileLock; import java.util.Arrays; import java.util.HashSet; import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; import io.nekohasekai.sagernet.BuildConfig; import io.nekohasekai.sagernet.ktx.Logs; import kotlin.text.StringsKt; public class JavaUtil { // The encoded character of each character escape. // This array functions as the keys of a sorted map, from encoded characters to decoded characters. static final char[] ENCODED_ESCAPES = {'\"', '\'', '\\', 'b', 'f', 'n', 'r', 't'}; // The decoded character of each character escape. // This array functions as the values of a sorted map, from encoded characters to decoded characters. static final char[] DECODED_ESCAPES = {'\"', '\'', '\\', '\b', '\f', '\n', '\r', '\t'}; // A pattern that matches an escape. // What follows the escape indicator is captured by group 1=character 2=octal 3=Unicode. static final Pattern PATTERN = Pattern.compile("\\\\(?:(b|t|n|f|r|\\\"|\\\'|\\\\)|((?:[0-3]?[0-7])?[0-7])|u+(\\p{XDigit}{4}))"); // Process the return of webView.evaluateJavascript public static String unescapeString(CharSequence encodedString) { Matcher matcher = PATTERN.matcher(encodedString); StringBuffer decodedString = new StringBuffer(); // Find each escape of the encoded string in succession. while (matcher.find()) { char ch; if (matcher.start(1) >= 0) { // Decode a character escape. ch = DECODED_ESCAPES[Arrays.binarySearch(ENCODED_ESCAPES, matcher.group(1).charAt(0))]; } else if (matcher.start(2) >= 0) { // Decode an octal escape. ch = (char) (Integer.parseInt(matcher.group(2), 8)); } else /* if (matcher.start(3) >= 0) */ { // Decode a Unicode escape. ch = (char) (Integer.parseInt(matcher.group(3), 16)); } // Replace the escape with the decoded character. matcher.appendReplacement(decodedString, Matcher.quoteReplacement(String.valueOf(ch))); } // Append the remainder of the encoded string to the decoded string. // The remainder is the longest suffix of the encoded string such that the suffix contains no escapes. matcher.appendTail(decodedString); return new String(decodedString); } // Webview Utils public static void handleWebviewDir(Context context) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { return; } try { Set pathSet = new HashSet<>(); String suffix; String dataPath = context.getDataDir().getAbsolutePath(); String webViewDir = "/app_webview"; String huaweiWebViewDir = "/app_hws_webview"; String lockFile = "/webview_data.lock"; String processName = Application.getProcessName(); if (!BuildConfig.APPLICATION_ID.equals(processName)) {//判断不等于默认进程名称 suffix = TextUtils.isEmpty(processName) ? context.getPackageName() : processName; WebView.setDataDirectorySuffix(suffix); suffix = "_" + suffix; pathSet.add(dataPath + webViewDir + suffix + lockFile); if (checkIsHuaweiRom()) { pathSet.add(dataPath + huaweiWebViewDir + suffix + lockFile); } } else { //主进程 suffix = "_" + processName; pathSet.add(dataPath + webViewDir + lockFile);//默认未添加进程名后缀 pathSet.add(dataPath + webViewDir + suffix + lockFile);//系统自动添加了进程名后缀 if (checkIsHuaweiRom()) {//部分华为手机更改了webview目录名 pathSet.add(dataPath + huaweiWebViewDir + lockFile); pathSet.add(dataPath + huaweiWebViewDir + suffix + lockFile); } } for (String path : pathSet) { File file = new File(path); if (file.exists()) { tryLockOrRecreateFile(file); break; } } } catch (Exception e) { Logs.INSTANCE.e(e); } } @TargetApi(Build.VERSION_CODES.P) private static void tryLockOrRecreateFile(File file) { try { FileLock tryLock = new RandomAccessFile(file, "rw").getChannel().tryLock(); if (tryLock != null) { tryLock.close(); } else { createFile(file, file.delete()); } } catch (Exception e) { e.printStackTrace(); boolean deleted = false; if (file.exists()) { deleted = file.delete(); } createFile(file, deleted); } } private static void createFile(File file, boolean deleted) { try { if (deleted && !file.exists()) { file.createNewFile(); } } catch (Exception e) { e.printStackTrace(); } } private static boolean checkIsHuaweiRom() { return Build.MANUFACTURER.contains("HUAWEI"); } @SuppressLint("PrivateApi") public static String getProcessName() { if (Build.VERSION.SDK_INT >= 28) return Application.getProcessName(); // Using the same technique as Application.getProcessName() for older devices // Using reflection since ActivityThread is an internal API try { Class activityThread = Class.forName("android.app.ActivityThread"); String methodName = "currentProcessName"; Method getProcessName = activityThread.getDeclaredMethod(methodName); return (String) getProcessName.invoke(null); } catch (Exception e) { return BuildConfig.APPLICATION_ID; } } // Old hutool Utils public static boolean isNullOrBlank(String str) { return str == null || StringsKt.isBlank(str); } public static boolean isNotBlank(String str) { return !isNullOrBlank(str); } private static final char[] HEX_ARRAY = "0123456789abcdef".toCharArray(); public static String bytesToHex(byte[] bytes) { char[] hexChars = new char[bytes.length * 2]; for (int j = 0; j < bytes.length; j++) { int v = bytes[j] & 0xFF; hexChars[j * 2] = HEX_ARRAY[v >>> 4]; hexChars[j * 2 + 1] = HEX_ARRAY[v & 0x0F]; } return new String(hexChars); } public static boolean isEmpty(byte[] array) { return array == null || array.length == 0; } // gson public static final Gson gson = new GsonBuilder() .setPrettyPrinting() .setNumberToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE) .setObjectToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE) .setLenient() .disableHtmlEscaping() .create(); } ================================================ FILE: app/src/main/java/moe/matsuri/nb4a/utils/KotlinUtil.kt ================================================ package moe.matsuri.nb4a.utils import android.annotation.SuppressLint import android.content.Context import android.graphics.drawable.Drawable import androidx.appcompat.content.res.AppCompatResources import io.nekohasekai.sagernet.SagerNet import io.nekohasekai.sagernet.ktx.Logs import java.io.File // SagerNet Class const val KB = 1024L const val MB = KB * 1024 const val GB = MB * 1024 fun SagerNet.cleanWebview() { var pathToClean = "app_webview" if (isBgProcess) pathToClean += "_$process" try { val dataDir = filesDir.parentFile!! File(dataDir, "$pathToClean/BrowserMetrics").recreate(true) File(dataDir, "$pathToClean/BrowserMetrics-spare.pma").recreate(false) } catch (e: Exception) { Logs.e(e) } } fun File.recreate(dir: Boolean) { if (parentFile?.isDirectory != true) return if (dir && !isFile) { if (exists()) deleteRecursively() createNewFile() } else if (!dir && !isDirectory) { if (exists()) delete() mkdir() } } // Context utils @SuppressLint("DiscouragedApi") fun Context.getDrawableByName(name: String?): Drawable? { val resourceId: Int = resources.getIdentifier(name, "drawable", packageName) return AppCompatResources.getDrawable(this, resourceId) } // Traffic display fun Long.toBytesString(): String { val size = this.toDouble() return when { this >= GB -> String.format("%.2f GiB", size / GB) this >= MB -> String.format("%.2f MiB", size / MB) this >= KB -> String.format("%.2f KiB", size / KB) else -> "$this Bytes" } } // List fun String.listByLineOrComma(): List { return this.split(",","\n").map { it.trim() }.filter { it.isNotEmpty() } } ================================================ FILE: app/src/main/java/moe/matsuri/nb4a/utils/NGUtil.kt ================================================ package moe.matsuri.nb4a.utils import android.content.ClipData import android.content.ClipboardManager import android.content.Context import android.content.Intent import android.net.Uri import android.text.Editable import android.util.Base64 import io.nekohasekai.sagernet.ktx.Logs import java.net.URLDecoder import java.net.URLEncoder import java.util.* // Copy form v2rayNG to parse their stupid format object NGUtil { /** * convert string to editalbe for kotlin * * @param text * @return */ fun getEditable(text: String): Editable { return Editable.Factory.getInstance().newEditable(text) } /** * find value in array position */ fun arrayFind(array: Array, value: String): Int { for (i in array.indices) { if (array[i] == value) { return i } } return -1 } /** * parseInt */ fun parseInt(str: String): Int { return try { Integer.parseInt(str) } catch (e: Exception) { e.printStackTrace() 0 } } /** * get text from clipboard */ fun getClipboard(context: Context): String { return try { val cmb = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager cmb.primaryClip?.getItemAt(0)?.text.toString() } catch (e: Exception) { e.printStackTrace() "" } } /** * set text to clipboard */ fun setClipboard(context: Context, content: String) { try { val cmb = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager val clipData = ClipData.newPlainText(null, content) cmb.setPrimaryClip(clipData) } catch (e: Exception) { e.printStackTrace() } } /** * base64 decode */ fun decode(text: String): String { tryDecodeBase64(text)?.let { return it } if (text.endsWith('=')) { // try again for some loosely formatted base64 tryDecodeBase64(text.trimEnd('='))?.let { return it } } return "" } fun tryDecodeBase64(text: String): String? { try { return Base64.decode(text, Base64.NO_WRAP).toString(charset("UTF-8")) } catch (e: Exception) { Logs.i( "Parse base64 standard failed $e") } try { return Base64.decode(text, Base64.NO_WRAP.or(Base64.URL_SAFE)).toString(charset("UTF-8")) } catch (e: Exception) { Logs.i( "Parse base64 url safe failed $e") } return null } /** * base64 encode */ fun encode(text: String): String { return try { Base64.encodeToString(text.toByteArray(charset("UTF-8")), Base64.NO_WRAP) } catch (e: Exception) { e.printStackTrace() "" } } /** * is ip address */ fun isIpAddress(value: String): Boolean { try { var addr = value if (addr.isEmpty() || addr.isBlank()) { return false } //CIDR if (addr.indexOf("/") > 0) { val arr = addr.split("/") if (arr.count() == 2 && Integer.parseInt(arr[1]) > 0) { addr = arr[0] } } // "::ffff:192.168.173.22" // "[::ffff:192.168.173.22]:80" if (addr.startsWith("::ffff:") && '.' in addr) { addr = addr.drop(7) } else if (addr.startsWith("[::ffff:") && '.' in addr) { addr = addr.drop(8).replace("]", "") } // addr = addr.toLowerCase() val octets = addr.split('.').toTypedArray() if (octets.size == 4) { if(octets[3].indexOf(":") > 0) { addr = addr.substring(0, addr.indexOf(":")) } return isIpv4Address(addr) } // Ipv6addr [2001:abc::123]:8080 return isIpv6Address(addr) } catch (e: Exception) { e.printStackTrace() return false } } fun isPureIpAddress(value: String): Boolean { return (isIpv4Address(value) || isIpv6Address(value)) } fun isIpv4Address(value: String): Boolean { val regV4 = Regex("^([01]?[0-9]?[0-9]|2[0-4][0-9]|25[0-5])\\.([01]?[0-9]?[0-9]|2[0-4][0-9]|25[0-5])\\.([01]?[0-9]?[0-9]|2[0-4][0-9]|25[0-5])\\.([01]?[0-9]?[0-9]|2[0-4][0-9]|25[0-5])$") return regV4.matches(value) } fun isIpv6Address(value: String): Boolean { var addr = value if (addr.indexOf("[") == 0 && addr.lastIndexOf("]") > 0) { addr = addr.drop(1) addr = addr.dropLast(addr.count() - addr.lastIndexOf("]")) } val regV6 = Regex("^((?:[0-9A-Fa-f]{1,4}))?((?::[0-9A-Fa-f]{1,4}))*::((?:[0-9A-Fa-f]{1,4}))?((?::[0-9A-Fa-f]{1,4}))*|((?:[0-9A-Fa-f]{1,4}))((?::[0-9A-Fa-f]{1,4})){7}$") return regV6.matches(addr) } private fun isCoreDNSAddress(s: String): Boolean { return s.startsWith("https") || s.startsWith("tcp") || s.startsWith("quic") } fun openUri(context: Context, uriString: String) { val uri = Uri.parse(uriString) context.startActivity(Intent(Intent.ACTION_VIEW, uri)) } /** * uuid */ fun getUuid(): String { return try { UUID.randomUUID().toString().replace("-", "") } catch (e: Exception) { e.printStackTrace() "" } } fun urlDecode(url: String): String { return try { URLDecoder.decode(url, "UTF-8") } catch (e: Exception) { url } } fun urlEncode(url: String): String { return try { URLEncoder.encode(url, "UTF-8") } catch (e: Exception) { e.printStackTrace() url } } /** * package path */ fun packagePath(context: Context): String { var path = context.filesDir.toString() path = path.replace("files", "") //path += "tun2socks" return path } /** * readTextFromAssets */ fun readTextFromAssets(context: Context, fileName: String): String { val content = context.assets.open(fileName).bufferedReader().use { it.readText() } return content } } ================================================ FILE: app/src/main/java/moe/matsuri/nb4a/utils/SendLog.kt ================================================ package moe.matsuri.nb4a.utils import android.content.Context import android.content.Intent import androidx.core.content.FileProvider import io.nekohasekai.sagernet.BuildConfig import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.SagerNet import io.nekohasekai.sagernet.ktx.Logs import io.nekohasekai.sagernet.ktx.app import io.nekohasekai.sagernet.ktx.use import io.nekohasekai.sagernet.utils.CrashHandler import java.io.File import java.io.FileInputStream import java.io.FileOutputStream import java.io.IOException object SendLog { // Create full log and send fun sendLog(context: Context, title: String) { val logFile = File.createTempFile( "$title ", ".log", File(app.cacheDir, "log").also { it.mkdirs() }) var report = CrashHandler.buildReportHeader() report += "Logcat: \n\n" logFile.writeText(report) try { Runtime.getRuntime().exec(arrayOf("logcat", "-d")).inputStream.use( FileOutputStream( logFile, true ) ) logFile.appendText("\n") } catch (e: IOException) { Logs.w(e) logFile.appendText("Export logcat error: " + CrashHandler.formatThrowable(e)) } logFile.appendText("\n") logFile.appendBytes(getNekoLog(0)) context.startActivity( Intent.createChooser( Intent(Intent.ACTION_SEND).setType("text/x-log") .setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) .putExtra( Intent.EXTRA_STREAM, FileProvider.getUriForFile( context, BuildConfig.APPLICATION_ID + ".cache", logFile ) ), context.getString(R.string.abc_shareactionprovider_share_with) ) ) } // Get log bytes from neko.log fun getNekoLog(max: Long): ByteArray { return try { val file = File( SagerNet.application.cacheDir, "neko.log" ) val len = file.length() val stream = FileInputStream(file) if (max in 1 until len) { stream.skip(len - max) // TODO string? } stream.use { it.readBytes() } } catch (e: Exception) { e.stackTraceToString().toByteArray() } } } ================================================ FILE: app/src/main/java/moe/matsuri/nb4a/utils/Util.kt ================================================ package moe.matsuri.nb4a.utils import android.annotation.SuppressLint import android.content.Context import android.util.Base64 import libcore.StringBox import java.io.ByteArrayOutputStream import java.net.URLDecoder import java.nio.charset.StandardCharsets import java.text.SimpleDateFormat import java.util.* import java.util.zip.Deflater import java.util.zip.Inflater object Util { /** * 取两个文本之间的文本值 * * @param text 源文本 比如:欲取全文本为 12345 * @param left 文本前面 * @param right 后面文本 * @return 返回 String */ fun getSubString(text: String, left: String?, right: String?): String { var zLen: Int if (left.isNullOrEmpty()) { zLen = 0 } else { zLen = text.indexOf(left) if (zLen > -1) { zLen += left.length } else { zLen = 0 } } var yLen = if (right == null) -1 else text.indexOf(right, zLen) if (yLen < 0 || right.isNullOrEmpty()) { yLen = text.length } return text.substring(zLen, yLen) } // Base64 for all fun b64EncodeUrlSafe(s: String): String { return b64EncodeUrlSafe(s.toByteArray()) } fun b64EncodeUrlSafe(b: ByteArray): String { return String(Base64.encode(b, Base64.NO_PADDING or Base64.NO_WRAP or Base64.URL_SAFE)) } // v2rayN Style fun b64EncodeOneLine(b: ByteArray): String { return String(Base64.encode(b, Base64.NO_WRAP)) } fun b64EncodeDefault(b: ByteArray): String { return String(Base64.encode(b, Base64.DEFAULT)) } fun b64Decode(b: String): ByteArray { var ret: ByteArray? = null // padding 自动处理,不用理 // URLSafe 需要替换这两个,不要用 URL_SAFE 否则处理非 Safe 的时候会乱码 val str = b.replace("-", "+").replace("_", "/") val flags = listOf( Base64.DEFAULT, // 多行 Base64.NO_WRAP, // 单行 ) for (flag in flags) { try { ret = Base64.decode(str, flag) } catch (_: Exception) { } if (ret != null) return ret } throw IllegalStateException("Cannot decode base64") } fun zlibCompress(input: ByteArray, level: Int): ByteArray { // Compress the bytes // 1 to 4 bytes/char for UTF-8 val output = ByteArray(input.size * 4) val compressor = Deflater(level).apply { setInput(input) finish() } val compressedDataLength: Int = compressor.deflate(output) compressor.end() return output.copyOfRange(0, compressedDataLength) } fun zlibDecompress(input: ByteArray): ByteArray { val inflater = Inflater() val outputStream = ByteArrayOutputStream() return outputStream.use { val buffer = ByteArray(1024) inflater.setInput(input) var count = -1 while (count != 0) { count = inflater.inflate(buffer) outputStream.write(buffer, 0, count) } inflater.end() outputStream.toByteArray() } } fun map2StringMap(m: Map<*, *>): MutableMap { val o = mutableMapOf() m.forEach { if (it.key is String) { o[it.key as String] = it.value as Any } } return o } fun mergeMap(dst: MutableMap, src: Map): MutableMap { src.forEach { (k, v) -> if (v is Map<*, *> && dst[k] is Map<*, *>) { val currentMap = (dst[k] as Map<*, *>).toMutableMap() dst[k] = mergeMap(map2StringMap(currentMap), map2StringMap(v)) } else if (v is List<*>) { if (k.startsWith("+")) { // prepend val dstKey = k.removePrefix("+") var currentList = (dst[dstKey] as? List<*>)?.toMutableList() ?: mutableListOf() currentList = (v + currentList).toMutableList() dst[dstKey] = currentList } else if (k.endsWith("+")) { // append val dstKey = k.removeSuffix("+") var currentList = (dst[dstKey] as? List<*>)?.toMutableList() ?: mutableListOf() currentList = (currentList + v).toMutableList() dst[dstKey] = currentList } else { dst[k] = v } } else { dst[k] = v } } return dst } fun mergeJSON(dst: MutableMap, j: String) { if (j.isBlank()) return val src = JavaUtil.gson.fromJson(j, dst.javaClass) mergeMap(dst, src) } // Format Time @SuppressLint("SimpleDateFormat") val sdf1 = SimpleDateFormat("yyyy-MM-dd HH:mm:ss") fun timeStamp2Text(t: Long): String { return sdf1.format(Date(t)) } fun tryToSetField(o: Any, name: String, value: Any) { try { o.javaClass.getField(name).set(o, value) } catch (_: Exception) { } } @SuppressLint("WrongConstant") fun collapseStatusBar(context: Context) { try { val statusBarManager = context.getSystemService("statusbar") val collapse = statusBarManager.javaClass.getMethod("collapsePanels") collapse.invoke(statusBarManager) } catch (_: Exception) { } } fun getStringBox(b: StringBox?): String { if (b != null && b.value != null) { return b.value } return "" } fun decodeFilename(headerValue: String): String { val regex = Regex("filename\\*=[^']*''(.+)") val match = regex.find(headerValue) val encoded = match?.groupValues?.get(1) ?: "" return URLDecoder.decode(encoded, StandardCharsets.UTF_8.name()) } } ================================================ FILE: app/src/main/java/moe/matsuri/nb4a/utils/WebViewUtil.kt ================================================ package moe.matsuri.nb4a.utils import android.os.Build import android.webkit.WebResourceError import android.webkit.WebResourceRequest import android.webkit.WebResourceResponse import android.webkit.WebView import io.nekohasekai.sagernet.ktx.Logs import java.io.ByteArrayInputStream import java.io.InputStream object WebViewUtil { fun onReceivedError( view: WebView?, request: WebResourceRequest?, error: WebResourceError? ) { if (Build.VERSION.SDK_INT >= 23 && error != null) { Logs.e("WebView error description: ${error.description}") } Logs.e("WebView error: ${error.toString()}") } fun interceptRequest( res: (String) -> InputStream?, view: WebView?, request: WebResourceRequest? ): WebResourceResponse { val path = request?.url?.path ?: "404" val input = res(path) var mime = "text/plain" if (path.endsWith(".js")) mime = "application/javascript" if (path.endsWith(".html")) mime = "text/html" return if (input != null) { WebResourceResponse(mime, "UTF-8", input) } else { WebResourceResponse( "text/plain", "UTF-8", ByteArrayInputStream("".toByteArray()) ) } } } ================================================ FILE: app/src/main/res/color/chip_background.xml ================================================ ================================================ FILE: app/src/main/res/color/chip_ripple_color.xml ================================================ ================================================ FILE: app/src/main/res/color/chip_text_color.xml ================================================ ================================================ FILE: app/src/main/res/color/navigation_icon.xml ================================================ ================================================ FILE: app/src/main/res/color/navigation_item.xml ================================================ ================================================ FILE: app/src/main/res/drawable/baseline_arrow_back_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/baseline_construction_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/baseline_delete_sweep_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/baseline_developer_board_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/baseline_flight_takeoff_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/baseline_keyboard_tab_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/baseline_public_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/baseline_redo_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/baseline_save_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/baseline_send_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/baseline_translate_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/baseline_undo_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/baseline_widgets_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/baseline_wrap_text_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_action_copyright.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_action_delete.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_action_description.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_action_dns.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_action_done.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_action_lock.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_action_lock_open.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_action_note_add.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_action_settings.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_app_shortcut_background.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_av_playlist_add.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_add_road_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_airplanemode_active_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_android_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_bug_report_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_camera_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_card_giftcard_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_cast_connected_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_center_focus_weak_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_color_lens_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_compare_arrows_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_domain_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_download_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_emoji_emotions_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_fast_forward_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_fiber_manual_record_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_fingerprint_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_flip_camera_android_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_format_align_left_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_grid_3x3_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_home_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_http_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_https_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_import_contacts_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_info_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_layers_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_legend_toggle_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_link_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_local_bar_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_location_on_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_lock_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_low_priority_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_manage_search_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_more_vert_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_multiline_chart_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_multiple_stop_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_nat_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_nfc_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_no_encryption_gmailerrorred_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_person_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_push_pin_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_refresh_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_rule_folder_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_running_with_errors_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_sanitizer_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_security_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_shuffle_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_shutter_speed_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_speed_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_stream_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_texture_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_timelapse_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_transform_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_transgender_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_update_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_view_list_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_vpn_key_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_warning_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_wb_sunny_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_communication_phonelink_ring.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_device_data_usage.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_device_developer_mode.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_file_cloud_queue.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_file_file_upload.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_hardware_router.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_image_camera_alt.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_image_edit.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_image_looks_6.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_image_photo.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_maps_360.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_maps_directions.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_maps_directions_boat.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_navigation_apps.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_navigation_close.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_navigation_menu.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_notification_enhanced_encryption.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_qu_camera_launcher.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_qu_shadowsocks_foreground.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_qu_shadowsocks_launcher.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_service_active.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_service_busy.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_service_connected.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_service_connecting.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_service_idle.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_service_stopped.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_service_stopping.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_settings_password.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_social_emoji_symbols.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_social_share.xml ================================================ ================================================ FILE: app/src/main/res/drawable/terminal_scroll_shape.xml ================================================ ================================================ FILE: app/src/main/res/drawable-v26/ic_qu_camera_launcher.xml ================================================ ================================================ FILE: app/src/main/res/drawable-v26/ic_qu_shadowsocks_launcher.xml ================================================ ================================================ FILE: app/src/main/res/layout/item_keyboard_key.xml ================================================ ================================================ FILE: app/src/main/res/layout/layout_about.xml ================================================ ================================================ FILE: app/src/main/res/layout/layout_add_entity.xml ================================================ ================================================ FILE: app/src/main/res/layout/layout_app_list.xml ================================================ ================================================ FILE: app/src/main/res/layout/layout_app_placeholder.xml ================================================ ================================================ FILE: app/src/main/res/layout/layout_appbar.xml ================================================ ================================================ FILE: app/src/main/res/layout/layout_apps.xml ================================================ ================================================ FILE: app/src/main/res/layout/layout_apps_item.xml ================================================ ================================================ FILE: app/src/main/res/layout/layout_asset_item.xml ================================================ ================================================ FILE: app/src/main/res/layout/layout_assets.xml ================================================ ================================================ FILE: app/src/main/res/layout/layout_backup.xml ================================================