Repository: zeromake/AnXray Branch: develop Commit: e147cddd17cc Files: 581 Total size: 2.3 MB Directory structure: gitextract_jf3i_ene/ ├── .github/ │ ├── FUNDING.yml │ └── workflows/ │ ├── build.yml │ ├── debug.yml │ └── release.yml ├── .gitignore ├── .gitmodules ├── .idea/ │ ├── .gitignore │ ├── codeStyles/ │ │ ├── Project.xml │ │ └── codeStyleConfig.xml │ ├── compiler.xml │ ├── copyright/ │ │ ├── profiles_settings.xml │ │ └── sagernet.xml │ ├── dictionaries/ │ │ └── sekai.xml │ ├── gradle.xml │ ├── inspectionProfiles/ │ │ └── Project_Default.xml │ ├── jarRepositories.xml │ ├── kotlinScripting.xml │ ├── misc.xml │ └── vcs.xml ├── AUTHORS ├── LICENSE ├── README.md ├── app/ │ ├── .gitignore │ ├── build.gradle.kts │ ├── proguard-rules.pro │ ├── schemas/ │ │ ├── io.nekohasekai.sagernet.database.SagerDatabase/ │ │ │ ├── 1.json │ │ │ ├── 10.json │ │ │ ├── 2.json │ │ │ ├── 3.json │ │ │ ├── 4.json │ │ │ ├── 5.json │ │ │ ├── 6.json │ │ │ ├── 7.json │ │ │ ├── 8.json │ │ │ └── 9.json │ │ └── io.nekohasekai.sagernet.database.preference.PublicDatabase/ │ │ └── 1.json │ └── src/ │ ├── androidTest/ │ │ └── java/ │ │ └── io/ │ │ └── nekohasekai/ │ │ └── sagernet/ │ │ └── ExampleInstrumentedTest.kt │ ├── main/ │ │ ├── AndroidManifest.xml │ │ ├── aidl/ │ │ │ └── io/ │ │ │ └── nekohasekai/ │ │ │ └── sagernet/ │ │ │ └── aidl/ │ │ │ ├── AppStatsList.aidl │ │ │ ├── ISagerNetService.aidl │ │ │ ├── ISagerNetServiceCallback.aidl │ │ │ └── TrafficStats.aidl │ │ ├── assets/ │ │ │ ├── LICENSE │ │ │ └── terminal.properties │ │ ├── java/ │ │ │ ├── cn/ │ │ │ │ └── hutool/ │ │ │ │ └── cache/ │ │ │ │ └── impl/ │ │ │ │ ├── AbstractCacheWithoutLock.java │ │ │ │ ├── LFUCacheCompact.java │ │ │ │ └── LFUCacheWithoutLock.java │ │ │ ├── com/ │ │ │ │ └── github/ │ │ │ │ └── shadowsocks/ │ │ │ │ ├── plugin/ │ │ │ │ │ ├── ConfigurationActivity.kt │ │ │ │ │ ├── HelpActivity.kt │ │ │ │ │ ├── HelpCallback.kt │ │ │ │ │ ├── InternalPlugin.kt │ │ │ │ │ ├── NativePlugin.kt │ │ │ │ │ ├── NativePluginProvider.kt │ │ │ │ │ ├── NoPlugin.kt │ │ │ │ │ ├── OptionsCapableActivity.kt │ │ │ │ │ ├── PathProvider.kt │ │ │ │ │ ├── Plugin.kt │ │ │ │ │ ├── PluginConfiguration.kt │ │ │ │ │ ├── PluginContract.kt │ │ │ │ │ ├── PluginList.kt │ │ │ │ │ ├── PluginManager.kt │ │ │ │ │ ├── PluginOptions.kt │ │ │ │ │ ├── ResolvedPlugin.kt │ │ │ │ │ ├── Utils.kt │ │ │ │ │ └── fragment/ │ │ │ │ │ └── AlertDialogFragment.kt │ │ │ │ └── preference/ │ │ │ │ ├── PluginConfigurationDialogFragment.kt │ │ │ │ ├── PluginPreference.kt │ │ │ │ └── PluginPreferenceDialogFragment.kt │ │ │ └── io/ │ │ │ └── nekohasekai/ │ │ │ └── sagernet/ │ │ │ ├── BootReceiver.kt │ │ │ ├── Constants.kt │ │ │ ├── QuickToggleShortcut.kt │ │ │ ├── SagerNet.kt │ │ │ ├── aidl/ │ │ │ │ ├── AppStats.kt │ │ │ │ ├── AppStatsList.kt │ │ │ │ └── TrafficStats.kt │ │ │ ├── bg/ │ │ │ │ ├── AbstractInstance.kt │ │ │ │ ├── BaseService.kt │ │ │ │ ├── ClashBasedInstance.kt │ │ │ │ ├── Executable.kt │ │ │ │ ├── ExternalInstance.kt │ │ │ │ ├── ForegroundDetectorService.kt │ │ │ │ ├── GuardedProcessPool.kt │ │ │ │ ├── ProxyService.kt │ │ │ │ ├── SagerConnection.kt │ │ │ │ ├── ServiceNotification.kt │ │ │ │ ├── SubscriptionUpdater.kt │ │ │ │ ├── TileService.kt │ │ │ │ ├── VpnService.kt │ │ │ │ ├── proto/ │ │ │ │ │ ├── ApiInstance.kt │ │ │ │ │ ├── ProxyInstance.kt │ │ │ │ │ ├── SSHInstance.kt │ │ │ │ │ ├── ShadowsocksInstance.kt │ │ │ │ │ ├── ShadowsocksRInstance.kt │ │ │ │ │ ├── SnellInstance.kt │ │ │ │ │ ├── UidDumper.kt │ │ │ │ │ └── V2RayInstance.kt │ │ │ │ └── test/ │ │ │ │ ├── DebugInstance.kt │ │ │ │ ├── LocalDnsInstance.kt │ │ │ │ ├── UrlTest.kt │ │ │ │ └── V2RayTestInstance.kt │ │ │ ├── database/ │ │ │ │ ├── DataStore.kt │ │ │ │ ├── GroupManager.kt │ │ │ │ ├── ProfileManager.kt │ │ │ │ ├── ProxyEntity.kt │ │ │ │ ├── ProxyGroup.kt │ │ │ │ ├── RuleEntity.kt │ │ │ │ ├── SagerDatabase.kt │ │ │ │ ├── StatsEntity.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 │ │ │ │ ├── brook/ │ │ │ │ │ ├── BrookBean.java │ │ │ │ │ └── BrookFmt.kt │ │ │ │ ├── gson/ │ │ │ │ │ ├── GsonConverters.java │ │ │ │ │ ├── Gsons.kt │ │ │ │ │ ├── JsonLazyAdapter.java │ │ │ │ │ ├── JsonLazyFactory.java │ │ │ │ │ ├── JsonLazyInterface.java │ │ │ │ │ ├── JsonOr.java │ │ │ │ │ ├── JsonOrAdapter.java │ │ │ │ │ └── JsonOrAdapterFactory.java │ │ │ │ ├── http/ │ │ │ │ │ ├── HttpBean.java │ │ │ │ │ └── HttpFmt.kt │ │ │ │ ├── hysteria/ │ │ │ │ │ ├── HysteriaBean.java │ │ │ │ │ └── HysteriaFmt.kt │ │ │ │ ├── internal/ │ │ │ │ │ ├── BalancerBean.java │ │ │ │ │ ├── ChainBean.java │ │ │ │ │ ├── ConfigBean.java │ │ │ │ │ └── InternalBean.java │ │ │ │ ├── naive/ │ │ │ │ │ ├── NaiveBean.java │ │ │ │ │ └── NaiveFmt.kt │ │ │ │ ├── pingtunnel/ │ │ │ │ │ ├── PingTunnelBean.java │ │ │ │ │ └── PingTunnelFmt.kt │ │ │ │ ├── relaybaton/ │ │ │ │ │ ├── RelayBatonBean.java │ │ │ │ │ └── RelayBatonFmt.kt │ │ │ │ ├── shadowsocks/ │ │ │ │ │ ├── ShadowsocksBean.java │ │ │ │ │ └── ShadowsocksFmt.kt │ │ │ │ ├── shadowsocksr/ │ │ │ │ │ ├── ShadowsocksRBean.java │ │ │ │ │ └── ShadowsocksRFmt.kt │ │ │ │ ├── snell/ │ │ │ │ │ └── SnellBean.java │ │ │ │ ├── socks/ │ │ │ │ │ ├── SOCKSBean.java │ │ │ │ │ └── SOCKSFmt.kt │ │ │ │ ├── ssh/ │ │ │ │ │ └── SSHBean.java │ │ │ │ ├── trojan/ │ │ │ │ │ ├── TrojanBean.java │ │ │ │ │ └── TrojanFmt.kt │ │ │ │ ├── trojan_go/ │ │ │ │ │ ├── TrojanGoBean.java │ │ │ │ │ └── TrojanGoFmt.kt │ │ │ │ ├── v2ray/ │ │ │ │ │ ├── StandardV2RayBean.java │ │ │ │ │ ├── V2RayConfig.java │ │ │ │ │ ├── V2RayFmt.kt │ │ │ │ │ ├── VLESSBean.java │ │ │ │ │ └── VMessBean.java │ │ │ │ └── wireguard/ │ │ │ │ ├── WireGuardBean.java │ │ │ │ └── WireGuardFmt.kt │ │ │ ├── group/ │ │ │ │ ├── GroupInterfaceAdapter.kt │ │ │ │ ├── GroupUpdater.kt │ │ │ │ ├── OpenOnlineConfigUpdater.kt │ │ │ │ ├── RawUpdater.kt │ │ │ │ └── SIP008Updater.kt │ │ │ ├── ktx/ │ │ │ │ ├── Asyncs.kt │ │ │ │ ├── Browsers.kt │ │ │ │ ├── Dialogs.kt │ │ │ │ ├── Dimens.kt │ │ │ │ ├── Formats.kt │ │ │ │ ├── Kryos.kt │ │ │ │ ├── Layouts.kt │ │ │ │ ├── Logs.kt │ │ │ │ ├── Nets.kt │ │ │ │ ├── Preferences.kt │ │ │ │ ├── Signatures.kt │ │ │ │ ├── UUIDs.kt │ │ │ │ ├── Utils.kt │ │ │ │ └── Validators.kt │ │ │ ├── plugin/ │ │ │ │ ├── NativePlugin.kt │ │ │ │ ├── Plugin.kt │ │ │ │ ├── PluginList.kt │ │ │ │ ├── PluginManager.kt │ │ │ │ └── ResolvedPlugin.kt │ │ │ ├── ui/ │ │ │ │ ├── AboutFragment.kt │ │ │ │ ├── ActiveFragment.kt │ │ │ │ ├── AppListActivity.kt │ │ │ │ ├── AppManagerActivity.kt │ │ │ │ ├── AssetsActivity.kt │ │ │ │ ├── CloudflareFragment.kt │ │ │ │ ├── ConfigurationFragment.kt │ │ │ │ ├── DebugFragment.kt │ │ │ │ ├── GroupFragment.kt │ │ │ │ ├── GroupSettingsActivity.kt │ │ │ │ ├── LicenseActivity.kt │ │ │ │ ├── LogcatFragment.kt │ │ │ │ ├── MainActivity.kt │ │ │ │ ├── NamedFragment.kt │ │ │ │ ├── ProfileSelectActivity.kt │ │ │ │ ├── RouteFragment.kt │ │ │ │ ├── RouteSettingsActivity.kt │ │ │ │ ├── ScannerActivity.kt │ │ │ │ ├── SettingsFragment.kt │ │ │ │ ├── SettingsPreferenceFragment.kt │ │ │ │ ├── StatsFragment.kt │ │ │ │ ├── ThemedActivity.kt │ │ │ │ ├── ToolbarFragment.kt │ │ │ │ ├── ToolsFragment.kt │ │ │ │ ├── TrafficFragment.kt │ │ │ │ ├── VpnRequestActivity.kt │ │ │ │ └── profile/ │ │ │ │ ├── BalancerSettingsActivity.kt │ │ │ │ ├── BrookSettingsActivity.kt │ │ │ │ ├── ChainSettingsActivity.kt │ │ │ │ ├── ConfigEditActivity.kt │ │ │ │ ├── ConfigSettingsActivity.kt │ │ │ │ ├── HttpSettingsActivity.kt │ │ │ │ ├── HysteriaSettingsActivity.kt │ │ │ │ ├── NaiveSettingsActivity.kt │ │ │ │ ├── PingTunnelSettingsActivity.kt │ │ │ │ ├── ProfileSettingsActivity.kt │ │ │ │ ├── RelayBatonSettingsActivity.kt │ │ │ │ ├── SSHSettingsActivity.kt │ │ │ │ ├── ShadowsocksRSettingsActivity.kt │ │ │ │ ├── ShadowsocksSettingsActivity.kt │ │ │ │ ├── SnellSettingsActivity.kt │ │ │ │ ├── SocksSettingsActivity.kt │ │ │ │ ├── StandardV2RaySettingsActivity.kt │ │ │ │ ├── TrojanGoSettingsActivity.kt │ │ │ │ ├── TrojanSettingsActivity.kt │ │ │ │ ├── VLESSSettingsActivity.kt │ │ │ │ ├── VMessSettingsActivity.kt │ │ │ │ └── WireGuardSettingsActivity.kt │ │ │ ├── utils/ │ │ │ │ ├── Cloudflare.kt │ │ │ │ ├── Commandline.kt │ │ │ │ ├── CrashHandler.kt │ │ │ │ ├── DefaultNetworkListener.kt │ │ │ │ ├── DeviceStorageApp.kt │ │ │ │ ├── DirectBoot.kt │ │ │ │ ├── HttpsTest.kt │ │ │ │ ├── PackageCache.kt │ │ │ │ ├── Subnet.kt │ │ │ │ ├── Theme.kt │ │ │ │ └── cf/ │ │ │ │ ├── DeviceResponse.kt │ │ │ │ ├── RegisterRequest.kt │ │ │ │ └── UpdateDeviceRequest.kt │ │ │ └── widget/ │ │ │ ├── AppListPreference.kt │ │ │ ├── AutoCollapseTextView.kt │ │ │ ├── ColorPickerPreference.kt │ │ │ ├── ColorPickerPreferenceDialogFragmentCompat.kt │ │ │ ├── EditConfigPreference.kt │ │ │ ├── FabProgressBehavior.kt │ │ │ ├── GroupPreference.kt │ │ │ ├── LinkOrContentPreference.kt │ │ │ ├── LinkPreference.kt │ │ │ ├── OOCv1TokenPreference.kt │ │ │ ├── OutboundPreference.kt │ │ │ ├── QRCodeDialog.kt │ │ │ ├── ServiceButton.kt │ │ │ ├── StatsBar.kt │ │ │ ├── UndoSnackbarManager.kt │ │ │ ├── UserAgentPreference.kt │ │ │ └── WindowInsetsListeners.kt │ │ ├── play/ │ │ │ └── release-notes/ │ │ │ ├── en-US/ │ │ │ │ └── default.txt │ │ │ └── zh-CN/ │ │ │ └── default.txt │ │ └── res/ │ │ ├── color/ │ │ │ ├── chip_background.xml │ │ │ ├── chip_ripple_color.xml │ │ │ ├── chip_text_color.xml │ │ │ ├── navigation_icon.xml │ │ │ └── navigation_item.xml │ │ ├── drawable/ │ │ │ ├── baseline_construction_24.xml │ │ │ ├── baseline_delete_sweep_24.xml │ │ │ ├── baseline_save_24.xml │ │ │ ├── baseline_send_24.xml │ │ │ ├── baseline_translate_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_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_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_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_rule_folder_24.xml │ │ │ ├── ic_baseline_running_with_errors_24.xml │ │ │ ├── ic_baseline_sanitizer_24.xml │ │ │ ├── ic_baseline_security_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_launcher_foreground.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_ax.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 │ │ ├── layout/ │ │ │ ├── layout_about.xml │ │ │ ├── layout_add_entity.xml │ │ │ ├── layout_app_list.xml │ │ │ ├── layout_appbar.xml │ │ │ ├── layout_apps.xml │ │ │ ├── layout_apps_item.xml │ │ │ ├── layout_asset_item.xml │ │ │ ├── layout_assets.xml │ │ │ ├── layout_chain_settings.xml │ │ │ ├── layout_cloudflare.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_license.xml │ │ │ ├── layout_link_dialog.xml │ │ │ ├── layout_loading.xml │ │ │ ├── layout_logcat.xml │ │ │ ├── layout_main.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_tools.xml │ │ │ ├── layout_traffic.xml │ │ │ ├── layout_traffic_item.xml │ │ │ └── layout_traffic_list.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 │ │ ├── mipmap-anydpi-v26/ │ │ │ ├── ic_launcher.xml │ │ │ └── ic_launcher_round.xml │ │ ├── raw/ │ │ │ ├── insecure.txt │ │ │ ├── mkcp_no_seed.txt │ │ │ ├── not_encrypted.txt │ │ │ ├── shadowsocks_stream_cipher.txt │ │ │ ├── shadowsocksr.txt │ │ │ └── vmess_md5_auth.txt │ │ ├── raw-zh-rCN/ │ │ │ ├── insecure.txt │ │ │ ├── mkcp_no_seed.txt │ │ │ ├── not_encrypted.txt │ │ │ ├── shadowsocks_stream_cipher.txt │ │ │ ├── shadowsocksr.txt │ │ │ └── vmess_md5_auth.txt │ │ ├── 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-rTW/ │ │ │ └── strings.xml │ │ └── xml/ │ │ ├── backup_descriptor.xml │ │ ├── balancer_preferences.xml │ │ ├── brook_preferences.xml │ │ ├── config_preferences.xml │ │ ├── foreground_detector_service.xml │ │ ├── global_preferences.xml │ │ ├── group_preferences.xml │ │ ├── http_preferences.xml │ │ ├── hysteria_preferences.xml │ │ ├── log_paths.xml │ │ ├── naive_preferences.xml │ │ ├── name_preferences.xml │ │ ├── network_security_config.xml │ │ ├── pingtunnel_preferences.xml │ │ ├── relaybaton_preferences.xml │ │ ├── route_preferences.xml │ │ ├── shadowsocks_preferences.xml │ │ ├── shadowsocksr_preferences.xml │ │ ├── shortcuts.xml │ │ ├── snell_preferences.xml │ │ ├── socks_preferences.xml │ │ ├── ssh_preferences.xml │ │ ├── standard_v2ray_preferences.xml │ │ ├── trojan_go_preferences.xml │ │ ├── trojan_preferences.xml │ │ └── wireguard_preferences.xml │ └── test/ │ └── java/ │ └── io/ │ └── nekohasekai/ │ └── sagernet/ │ ├── ExampleUnitTest.kt │ ├── fmt/ │ │ └── v2ray/ │ │ └── TestParseV2Ray.kt │ └── ktx/ │ └── UUIDsKtTest.kt ├── bin/ │ ├── debug.keystore │ ├── fdroid/ │ │ ├── build.sh │ │ ├── install_golang.sh │ │ ├── prebuild.sh │ │ ├── prebuild_plugin_golang.sh │ │ └── prebuild_plugin_naive.sh │ ├── init/ │ │ ├── action/ │ │ │ ├── library.sh │ │ │ ├── naive.sh │ │ │ └── shadowsocks.sh │ │ └── env.sh │ ├── lib/ │ │ ├── core/ │ │ │ ├── build.sh │ │ │ └── init.sh │ │ ├── core.sh │ │ ├── shadowsocks.sh │ │ └── shadowsocks_libev.sh │ ├── lint.sh │ ├── plugin/ │ │ ├── hysteria/ │ │ │ ├── arm64-v8a.sh │ │ │ ├── armeabi-v7a.sh │ │ │ ├── build.sh │ │ │ ├── end.sh │ │ │ ├── init.sh │ │ │ ├── x86.sh │ │ │ └── x86_64.sh │ │ ├── hysteria.sh │ │ ├── wireguard/ │ │ │ ├── arm64-v8a.sh │ │ │ ├── armeabi-v7a.sh │ │ │ ├── build.sh │ │ │ ├── end.sh │ │ │ ├── init.sh │ │ │ ├── x86.sh │ │ │ └── x86_64.sh │ │ └── wireguard.sh │ ├── re.sh │ └── update_core.sh ├── build.gradle.kts ├── buildSrc/ │ ├── build.gradle.kts │ └── src/ │ └── main/ │ └── kotlin/ │ ├── Helpers.kt │ └── V2RayAssets.kt ├── gradle/ │ └── wrapper/ │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradle.properties ├── gradlew ├── gradlew.bat ├── library/ │ ├── include/ │ │ ├── build.gradle.kts │ │ └── src/ │ │ └── main/ │ │ ├── AndroidManifest.xml │ │ └── java/ │ │ ├── com.wireguard/ │ │ │ └── crypto/ │ │ │ ├── Curve25519.java │ │ │ ├── Ed25519.java │ │ │ ├── Key.java │ │ │ ├── KeyFormatException.java │ │ │ └── KeyPair.java │ │ └── java/ │ │ └── nio/ │ │ ├── charset/ │ │ │ └── StandardCharsets.java │ │ └── file/ │ │ └── Path.java │ ├── proto/ │ │ └── build.gradle.kts │ ├── proto-stub/ │ │ ├── build.gradle.kts │ │ └── src/ │ │ └── main/ │ │ └── AndroidManifest.xml │ ├── shadowsocks/ │ │ ├── build.gradle.kts │ │ └── src/ │ │ └── main/ │ │ ├── AndroidManifest.xml │ │ └── rust/ │ │ └── linker-wrapper.py │ ├── shadowsocks-libev/ │ │ ├── build.gradle.kts │ │ └── src/ │ │ └── main/ │ │ ├── AndroidManifest.xml │ │ └── jni/ │ │ ├── Android.mk │ │ ├── Application.mk │ │ ├── build-shared-executable.mk │ │ ├── include/ │ │ │ ├── libev/ │ │ │ │ └── config.h │ │ │ ├── shadowsocks-libev/ │ │ │ │ └── config.h │ │ │ └── sodium/ │ │ │ └── version.h │ │ └── patch/ │ │ └── pcre/ │ │ └── pcre_chartables.c │ └── stub/ │ ├── build.gradle.kts │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ └── java/ │ ├── android/ │ │ └── net/ │ │ └── NetworkUtils.java │ └── sun/ │ └── misc/ │ └── Unsafe.java ├── lint.xml ├── plugin/ │ └── api/ │ ├── build.gradle.kts │ ├── proguard-rules.pro │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ └── java/ │ └── io/ │ └── nekohasekai/ │ └── sagernet/ │ └── plugin/ │ ├── NativePluginProvider.kt │ ├── PathProvider.kt │ └── PluginContract.kt ├── release.keystore ├── repositories.gradle.kts ├── run ├── sager.properties └── settings.gradle.kts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/FUNDING.yml ================================================ open_collective: sagernet liberapay: nekohasekai ================================================ FILE: .github/workflows/build.yml ================================================ name: build on: push: tags: - 'v*' jobs: libcore: name: Native Build (LibCore) runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v2 - name: Fetch Status run: git submodule status library/core > libcore_status - name: LibCore Cache id: cache uses: actions/cache@v2 with: path: | app/libs/libcore.aar key: ${{ hashFiles('bin/lib/core/*', 'libcore_status') }} - name: Install Golang uses: actions/setup-go@v2 if: steps.cache.outputs.cache-hit != 'true' with: go-version: 1.17.1 - name: Native Build if: steps.cache.outputs.cache-hit != 'true' run: ./run lib core shadowsocks: name: Native Build (shadowsocks-rust) runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v2 - name: Fetch Status run: git submodule status 'library/shadowsocks/*' > shadowsocks_status - name: Shadowsocks Cache id: cache uses: actions/cache@v2 with: path: | app/libs/shadowsocks.aar key: ${{ hashFiles('library/shadowsocks/build.gradle.kts', 'shadowsocks_status') }} - name: Install Rust if: steps.cache.outputs.cache-hit != 'true' run: ./run init action shadowsocks - name: Gradle cache uses: actions/cache@v2 if: steps.cache.outputs.cache-hit != 'true' with: path: ~/.gradle key: native-${{ hashFiles('**/*.gradle.kts') }} - name: Native Build if: steps.cache.outputs.cache-hit != 'true' env: BUILD_PLUGIN: none run: | echo "sdk.dir=${ANDROID_HOME}" > local.properties echo "ndk.dir=${ANDROID_HOME}/ndk/21.4.7075529" >> local.properties ./run init action library ./run lib shadowsocks shadowsocks_libev: name: Native Build (shadowsocks-libev) runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v2 - name: Fetch Status run: git submodule status 'library/shadowsocks-libev/*' > shadowsocks_libev_status - name: shadowsocks-libev Cache id: cache uses: actions/cache@v2 with: path: | app/libs/shadowsocks-libev.aar key: ${{ hashFiles('library/shadowsocks-libev/build.gradle.kts', 'shadowsocks_libev_status') }} - name: Gradle cache uses: actions/cache@v2 if: steps.cache.outputs.cache-hit != 'true' with: path: ~/.gradle key: native-${{ hashFiles('**/*.gradle.kts') }} - name: Native Build if: steps.cache.outputs.cache-hit != 'true' env: BUILD_PLUGIN: none run: | echo "sdk.dir=${ANDROID_HOME}" > local.properties ./run init action library ./run lib shadowsocks_libev build: name: Gradle Build runs-on: ubuntu-latest needs: - libcore - shadowsocks - shadowsocks_libev steps: - name: Checkout uses: actions/checkout@v2 - name: Fetch Status run: | git submodule status 'library/shadowsocks/*' > shadowsocks_status git submodule status 'library/shadowsocks-libev/*' > shadowsocks_libev_status git submodule status library/core > libcore_status - name: LibCore Cache uses: actions/cache@v2 with: path: | app/libs/libcore.aar key: ${{ hashFiles('bin/lib/core/*', 'libcore_status') }} - name: Shadowsocks Cache uses: actions/cache@v2 with: path: | app/libs/shadowsocks.aar key: ${{ hashFiles('library/shadowsocks/build.gradle.kts', 'shadowsocks_status') }} - name: Shadowsocks (libev) Cache uses: actions/cache@v2 with: path: | app/libs/shadowsocks-libev.aar key: ${{ hashFiles('library/shadowsocks-libev/build.gradle.kts', 'shadowsocks_libev_status') }} - name: Debug Build run: | echo "sdk.dir=${ANDROID_HOME}" > local.properties mkdir -p ~/.android && cp ./bin/debug.keystore ~/.android/debug.keystore tree app/libs/ ./run init action library ./gradlew app:assembleOssDebug bash ./bin/re.sh tree app/build/outputs/apk/ - name: update uses: softprops/action-gh-release@v1 if: startsWith(github.ref, 'refs/tags/') with: files: | app/libs/libcore.aar app/libs/shadowsocks.aar app/libs/shadowsocks-libev.aar app/build/outputs/apk/oss/debug/AX-arm64-v8a-debug.apk app/build/outputs/apk/oss/debug/AX-armeabi-v7a-debug.apk app/build/outputs/apk/oss/debug/AX-x86-debug.apk app/build/outputs/apk/oss/debug/AX-x86_64-debug.apk ================================================ FILE: .github/workflows/debug.yml ================================================ name: Debug build on: push: branches: - dev paths-ignore: - '**.md' - '.github/**' - '!.github/workflows/debug.yml' pull_request: branches: - dev jobs: libcore: name: Native Build (LibCore) runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v2 - name: Fetch Status run: git submodule status library/core > libcore_status - name: LibCore Cache id: cache uses: actions/cache@v2 with: path: | app/libs/libcore.aar key: ${{ hashFiles('.github/workflows/*', 'bin/lib/core/*', 'libcore_status') }} - name: Install Golang uses: actions/setup-go@v2 if: steps.cache.outputs.cache-hit != 'true' with: go-version: 1.17.1 - name: Native Build if: steps.cache.outputs.cache-hit != 'true' run: ./run lib core shadowsocks: name: Native Build (shadowsocks-rust) runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v2 - name: Fetch Status run: git submodule status 'library/shadowsocks/*' > shadowsocks_status - name: Shadowsocks Cache id: cache uses: actions/cache@v2 with: path: | app/libs/shadowsocks.aar key: ${{ hashFiles('.github/workflows/*', 'library/shadowsocks/build.gradle.kts', 'shadowsocks_status') }} - name: Install Rust if: steps.cache.outputs.cache-hit != 'true' run: ./run init action shadowsocks - name: Gradle cache uses: actions/cache@v2 if: steps.cache.outputs.cache-hit != 'true' with: path: ~/.gradle key: native-${{ hashFiles('**/*.gradle.kts') }} - name: Native Build if: steps.cache.outputs.cache-hit != 'true' env: BUILD_PLUGIN: none run: | echo "sdk.dir=${ANDROID_HOME}" > local.properties echo "ndk.dir=${ANDROID_HOME}/ndk/21.4.7075529" >> local.properties ./run init action library ./run lib shadowsocks shadowsocks_libev: name: Native Build (shadowsocks-libev) runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v2 - name: Fetch Status run: git submodule status 'library/shadowsocks-libev/*' > shadowsocks_libev_status - name: shadowsocks-libev Cache id: cache uses: actions/cache@v2 with: path: | app/libs/shadowsocks-libev.aar key: ${{ hashFiles('.github/workflows/*', 'library/shadowsocks-libev/build.gradle.kts', 'shadowsocks_libev_status') }} - name: Gradle cache uses: actions/cache@v2 if: steps.cache.outputs.cache-hit != 'true' with: path: ~/.gradle key: native-${{ hashFiles('**/*.gradle.kts') }} - name: Native Build if: steps.cache.outputs.cache-hit != 'true' env: BUILD_PLUGIN: none run: | echo "sdk.dir=${ANDROID_HOME}" > local.properties echo "ndk.dir=${ANDROID_HOME}/ndk/21.4.7075529" >> local.properties ./run init action library ./run lib shadowsocks_libev Lint: name: Android Lint runs-on: ubuntu-latest needs: - libcore - shadowsocks - shadowsocks_libev steps: - name: Checkout uses: actions/checkout@v2 - name: Fetch Status run: | git submodule status 'library/shadowsocks/*' > shadowsocks_status git submodule status 'library/shadowsocks-libev/*' > shadowsocks_libev_status git submodule status library/core > libcore_status - name: LibCore Cache uses: actions/cache@v2 with: path: | app/libs/libcore.aar key: ${{ hashFiles('.github/workflows/*', 'bin/lib/core/*', 'libcore_status') }} - name: Shadowsocks Cache uses: actions/cache@v2 with: path: | app/libs/shadowsocks.aar key: ${{ hashFiles('.github/workflows/*', 'library/shadowsocks/build.gradle.kts', 'shadowsocks_status') }} - name: Shadowsocks (libev) Cache uses: actions/cache@v2 with: path: | app/libs/shadowsocks-libev.aar key: ${{ hashFiles('.github/workflows/*', 'library/shadowsocks-libev/build.gradle.kts', 'shadowsocks_libev_status') }} - name: Gradle cache uses: actions/cache@v2 with: path: ~/.gradle key: gradle-${{ hashFiles('**/*.gradle.kts') }} - name: Android Lint env: BUILD_PLUGIN: none run: | echo "sdk.dir=${ANDROID_HOME}" > local.properties echo "ndk.dir=${ANDROID_HOME}/ndk/21.4.7075529" >> local.properties ./run init action library ./run lint ================================================ FILE: .github/workflows/release.yml ================================================ name: Release Build on: workflow_dispatch: inputs: tag: description: 'Release Tag' required: true upload: description: 'Upload: If want ignore' required: false publish: description: 'Publish: If want ignore' required: false play: description: 'Play: If want ignore' required: false jobs: check: name: Check Access runs-on: ubuntu-latest steps: - name: "Check access" uses: "lannonbr/repo-permission-check-action@2.0.0" with: permission: "write" env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} libcore: name: Native Build (LibCore) runs-on: ubuntu-latest needs: check steps: - name: Checkout uses: actions/checkout@v2 - name: Fetch Status run: git submodule status library/core > libcore_status - name: LibCore Cache id: cache uses: actions/cache@v2 with: path: | app/libs/libcore.aar key: ${{ hashFiles('.github/workflows/*', 'bin/lib/core/*', 'libcore_status') }} - name: Install Golang uses: actions/setup-go@v2 if: steps.cache.outputs.cache-hit != 'true' with: go-version: 1.17.1 - name: Gradle cache uses: actions/cache@v2 if: steps.cache.outputs.cache-hit != 'true' with: path: ~/.gradle key: native-${{ hashFiles('**/*.gradle.kts') }} - name: Native Build if: steps.cache.outputs.cache-hit != 'true' run: ./run lib core shadowsocks: name: Native Build (shadowsocks-rust) runs-on: ubuntu-latest needs: check steps: - name: Checkout uses: actions/checkout@v2 - name: Fetch Status run: git submodule status 'library/shadowsocks/*' > shadowsocks_status - name: Shadowsocks Cache id: cache uses: actions/cache@v2 with: path: | app/libs/shadowsocks.aar key: ${{ hashFiles('.github/workflows/*', 'library/shadowsocks/build.gradle.kts', 'shadowsocks_status') }} - name: Install Rust if: steps.cache.outputs.cache-hit != 'true' run: ./run init action shadowsocks - name: Gradle cache uses: actions/cache@v2 if: steps.cache.outputs.cache-hit != 'true' with: path: ~/.gradle key: native-${{ hashFiles('**/*.gradle.kts') }} - name: Native Build if: steps.cache.outputs.cache-hit != 'true' env: BUILD_PLUGIN: none run: | echo "sdk.dir=${ANDROID_HOME}" > local.properties echo "ndk.dir=${ANDROID_HOME}/ndk/21.4.7075529" >> local.properties ./run init action library ./run lib shadowsocks shadowsocks_libev: name: Native Build (shadowsocks-libev) runs-on: ubuntu-latest needs: check steps: - name: Checkout uses: actions/checkout@v2 - name: Fetch Status run: git submodule status 'library/shadowsocks-libev/*' > shadowsocks_libev_status - name: Shadowsocks Cache id: cache uses: actions/cache@v2 with: path: | app/libs/shadowsocks-libev.aar key: ${{ hashFiles('.github/workflows/*', 'library/shadowsocks-libev/build.gradle.kts', 'shadowsocks_libev_status') }} - name: Gradle cache uses: actions/cache@v2 if: steps.cache.outputs.cache-hit != 'true' with: path: ~/.gradle key: native-${{ hashFiles('**/*.gradle.kts') }} - name: Native Build if: steps.cache.outputs.cache-hit != 'true' env: BUILD_PLUGIN: none run: | echo "sdk.dir=${ANDROID_HOME}" > local.properties echo "ndk.dir=${ANDROID_HOME}/ndk/21.4.7075529" >> local.properties ./run init action library ./run lib shadowsocks_libev build: name: Gradle Build runs-on: ubuntu-latest needs: - libcore - shadowsocks - shadowsocks_libev steps: - name: Checkout uses: actions/checkout@v2 - name: Fetch Status run: | git submodule status 'library/shadowsocks/*' > shadowsocks_status git submodule status 'library/shadowsocks-libev/*' > shadowsocks_libev_status git submodule status library/core > libcore_status - name: LibCore Cache uses: actions/cache@v2 with: path: | app/libs/libcore.aar key: ${{ hashFiles('.github/workflows/*', 'bin/lib/core/*', 'libcore_status') }} - name: Shadowsocks Cache uses: actions/cache@v2 with: path: | app/libs/shadowsocks.aar key: ${{ hashFiles('.github/workflows/*', 'library/shadowsocks/build.gradle.kts', 'shadowsocks_status') }} - name: Shadowsocks (libev) Cache uses: actions/cache@v2 with: path: | app/libs/shadowsocks-libev.aar key: ${{ hashFiles('.github/workflows/*', 'library/shadowsocks-libev/build.gradle.kts', 'shadowsocks_libev_status') }} - name: Gradle cache uses: actions/cache@v2 with: path: ~/.gradle key: gradle-${{ hashFiles('**/*.gradle.kts') }} - name: Release Build env: BUILD_PLUGIN: none run: | echo "sdk.dir=${ANDROID_HOME}" > local.properties echo "ndk.dir=${ANDROID_HOME}/ndk/21.4.7075529" >> local.properties export LOCAL_PROPERTIES="${{ secrets.LOCAL_PROPERTIES }}" ./run init action library ./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@v2 with: name: APKs path: ${{ env.APK }} - uses: actions/upload-artifact@v2 with: name: "SHA256-ARM ${{ env.SHA256_ARM }}" path: ${{ env.SUM_ARM }} - uses: actions/upload-artifact@v2 with: name: "SHA256-ARM64 ${{ env.SHA256_ARM64 }}" path: ${{ env.SUM_ARM64 }} - uses: actions/upload-artifact@v2 with: name: "SHA256-X64 ${{ env.SHA256_X64 }}" path: ${{ env.SUM_X64 }} - uses: actions/upload-artifact@v2 with: name: "SHA256-X86 ${{ env.SHA256_X86 }}" path: ${{ env.SUM_X86 }} publish: name: Publish Release if: github.event.inputs.publish != 'y' runs-on: ubuntu-latest needs: build steps: - name: Checkout uses: actions/checkout@v2 - name: Donwload Artifacts uses: actions/download-artifact@v2 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 \; find artifacts -name "*.sha256sum.txt" -exec cp {} apks \; ./ghr -delete -t "${{ github.token }}" -n "${{ github.event.inputs.tag }}" "${{ github.event.inputs.tag }}" apks upload: name: Upload Release if: github.event.inputs.upload != 'y' runs-on: ubuntu-latest needs: build steps: - name: Donwload Artifacts uses: actions/download-artifact@v2 with: name: APKs path: artifacts - name: Release run: | mkdir apks find artifacts -name "*.apk" -exec cp {} apks \; function upload() { for apk in $@; do echo ">> Uploading $apk" curl https://api.telegram.org/bot${{ secrets.TELEGRAM_TOKEN }}/sendDocument \ -X POST \ -F chat_id="${{ secrets.TELEGRAM_CHANNEL }}" \ -F document="@$apk" \ --silent --show-error --fail >/dev/null & done for job in $(jobs -p); do wait $job || exit 1 done } upload apks/* play: name: Publish to Play Store if: github.event.inputs.play != 'y' runs-on: ubuntu-latest needs: - libcore - shadowsocks - shadowsocks_libev steps: - name: Checkout uses: actions/checkout@v2 - name: Fetch Status run: | git submodule status 'library/shadowsocks/*' > shadowsocks_status git submodule status 'library/shadowsocks-libev/*' > shadowsocks_libev_status git submodule status library/core > libcore_status - name: LibCore Cache uses: actions/cache@v2 with: path: | app/libs/libcore.aar key: ${{ hashFiles('.github/workflows/*', 'bin/lib/core/*', 'libcore_status') }} - name: Shadowsocks Cache uses: actions/cache@v2 with: path: | app/libs/shadowsocks.aar key: ${{ hashFiles('.github/workflows/*', 'library/shadowsocks/build.gradle.kts', 'shadowsocks_status') }} - name: Shadowsocks (libev) Cache uses: actions/cache@v2 with: path: | app/libs/shadowsocks-libev.aar key: ${{ hashFiles('.github/workflows/*', 'library/shadowsocks-libev/build.gradle.kts', 'shadowsocks_libev_status') }} - name: Gradle cache uses: actions/cache@v2 with: path: ~/.gradle key: gradle-${{ hashFiles('**/*.gradle.kts') }} - name: Checkout Library run: | git submodule update --init 'app/*' - name: Release Build env: BUILD_PLUGIN: none run: | echo "sdk.dir=${ANDROID_HOME}" > local.properties echo "ndk.dir=${ANDROID_HOME}/ndk/21.4.7075529" >> local.properties export LOCAL_PROPERTIES="${{ secrets.LOCAL_PROPERTIES }}" cat > service_account_credentials.json << EOF ${{ secrets.ANDROID_PUBLISHER_CREDENTIALS }}" EOF ./run init action library ./gradlew app:publishPlayReleaseBundle ================================================ FILE: .gitignore ================================================ *.iml .gradle /.idea/caches /.idea/libraries /.idea/modules.xml /.idea/workspace.xml /.idea/navEditor.xml /.idea/assetWizardSettings.xml .DS_Store build/ /captures .externalNativeBuild .cxx local.properties /app/libs/ /app/src/main/assets/v2ray /service_account_credentials.json jniLibs/ ================================================ FILE: .gitmodules ================================================ [submodule "library/shadowsocks/src/main/rust/shadowsocks-rust"] path = library/shadowsocks/src/main/rust/shadowsocks-rust url = https://github.com/shadowsocks/shadowsocks-rust.git [submodule "external/editorkit"] path = external/editorkit url = https://github.com/SagerNet/editorkit [submodule "external/preferencex"] path = external/preferencex url = https://github.com/SagerNet/preferencex-android [submodule "external/Xray-core"] path = external/Xray-core url = https://github.com/SagerNet/Xray-core [submodule "library/core"] path = library/core url = https://github.com/XTLS/LibAnXrayCore [submodule "external/termux-view"] path = external/termux-view url = https://github.com/SagerNet/termux-view [submodule "library/shadowsocks-libev/src/main/jni/libev"] path = library/shadowsocks-libev/src/main/jni/libev url = https://git.lighttpd.net/mirrors/libev.git [submodule "library/shadowsocks-libev/src/main/jni/libancillary"] path = library/shadowsocks-libev/src/main/jni/libancillary url = https://github.com/shadowsocks/libancillary.git [submodule "library/shadowsocks-libev/src/main/jni/libevent"] path = library/shadowsocks-libev/src/main/jni/libevent url = https://github.com/shadowsocks/libevent.git [submodule "library/shadowsocks-libev/src/main/jni/mbedtls"] path = library/shadowsocks-libev/src/main/jni/mbedtls url = https://github.com/SagerNet/mbedtls [submodule "library/shadowsocks-libev/src/main/jni/pcre"] path = library/shadowsocks-libev/src/main/jni/pcre url = https://android.googlesource.com/platform/external/pcre [submodule "library/shadowsocks-libev/src/main/jni/libsodium"] path = library/shadowsocks-libev/src/main/jni/libsodium url = https://github.com/jedisct1/libsodium.git branch = stable [submodule "library/shadowsocks-libev/src/main/jni/shadowsocks-libev"] path = library/shadowsocks-libev/src/main/jni/shadowsocks-libev url = https://github.com/SagerNet/shadowsocks-libev ================================================ FILE: .idea/.gitignore ================================================ # Default ignored files /shelf/ /workspace.xml ================================================ FILE: .idea/codeStyles/Project.xml ================================================ ================================================ FILE: .idea/codeStyles/codeStyleConfig.xml ================================================ ================================================ FILE: .idea/compiler.xml ================================================ ================================================ FILE: .idea/copyright/profiles_settings.xml ================================================ ================================================ FILE: .idea/copyright/sagernet.xml ================================================ ================================================ FILE: .idea/dictionaries/sekai.xml ================================================ acra aead alpn blackhole conns conscrypt dokodemo downlink fakedns fdroid geoip geosite grpc gson gvisor libev libnaive libtrojan loyalsoldier naiveproxy nativeproxy naïve nekohasekai obfs pingtunnel proxychains quic relaybaton rprx sagernet shadowsocks shadowsocksr snackbar thiz tproxy transproxy uplink utls vless vmess websocket xray xtls ================================================ FILE: .idea/gradle.xml ================================================ ================================================ FILE: .idea/inspectionProfiles/Project_Default.xml ================================================ ================================================ FILE: .idea/jarRepositories.xml ================================================ ================================================ FILE: .idea/kotlinScripting.xml ================================================ ================================================ FILE: .idea/misc.xml ================================================ ================================================ FILE: .idea/vcs.xml ================================================ ================================================ 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 ================================================
# ![AnXray](https://github.com/XTLS/AnXray/raw/img/screenshots/0.png) Another Xray 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/downloads/XTLS/AnXray/total.svg)](https://github.com/XTLS/AnXray/releases) [![Language: Kotlin](https://img.shields.io/github/languages/top/XTLS/AnXray.svg)](https://github.com/XTLS/AnXray/search?l=kotlin) [![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-orange.svg)](https://www.gnu.org/licenses/gpl-3.0)
## SCREENSHOTS The X-style logo, slogan, and exclusive bright & dark themes designed by [RPRX](https://github.com/rprx), the Chief Visual Designer at AnXray. ## Documents https://anxray.org ### Protocols The application is designed to be used whenever possible. #### Proxy * SOCKS (4/4a/5) * HTTP(S) * SSH * Shadowsocks * ShadowsocksR * VMess * VLESS with XTLS support * Trojan with XTLS support * Snell * Trojan-Go ( trojan-go-plugin ) * NaïveProxy ( naive-plugin ) * relaybaton ( relaybaton-plugin ) * Brook ( brook-plugin ) * Hysteria ( hysteria-plugin ) * WireGuard ( wireguard-plugin ) ##### ROOT required * Ping Tunnel ( pingtunnel-plugin ) #### Subscription * Raw: All widely used formats (base64, clash or origin configuration) * [Open Online Config](https://github.com/Shadowsocks-NET/OpenOnlineConfig) * [Shadowsocks SIP008](https://shadowsocks.org/en/wiki/SIP008-Online-Configuration-Delivery.html) #### Features * Full basic features * Xray WebSocket browser dialer * Option to change the notification update interval * A Chinese apps scanner (based on dex classpath scanning, so it may be slower) * Proxy chain * Balancer * Advanced routing with outbound profile selection * Reverse proxy * Custom config (Xray / Trojan-Go) * Traffic statistics support, including real-time display and cumulative statistics * Foreground status based routing support ## Credits ## 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/.gitignore ================================================ /build ================================================ FILE: app/build.gradle.kts ================================================ plugins { id("com.android.application") id("kotlin-android") id("kotlin-kapt") id("kotlin-parcelize") id("com.mikepenz.aboutlibraries.plugin") id("com.google.protobuf") } setupApp() android { compileOptions { isCoreLibraryDesugaringEnabled = true } kapt.arguments { arg("room.incremental", true) arg("room.schemaLocation", "$projectDir/schemas") } bundle { language { enableSplit = false } } buildFeatures { viewBinding = true } } dependencies { implementation(fileTree("libs")) compileOnly(project(":library:stub")) implementation(project(":library:include")) implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2") implementation("androidx.core:core-ktx:1.6.0") implementation("androidx.activity:activity-ktx:1.3.1") implementation("androidx.fragment:fragment-ktx:1.3.6") implementation("androidx.browser:browser:1.3.0") implementation("androidx.constraintlayout:constraintlayout:2.1.1") implementation("androidx.navigation:navigation-fragment-ktx:2.3.5") implementation("androidx.navigation:navigation-ui-ktx:2.3.5") implementation("androidx.preference:preference-ktx:1.1.1") implementation("androidx.appcompat:appcompat:1.3.1") implementation("androidx.work:work-runtime-ktx:2.7.0") implementation("androidx.work:work-multiprocess:2.7.0") implementation(project(":external:preferencex:preferencex")) implementation(project(":external:preferencex:preferencex-simplemenu")) implementation(project(":external:preferencex:preferencex-colorpicker")) implementation("com.google.android.material:material:1.4.0") implementation("cn.hutool:hutool-core:5.7.14") implementation("cn.hutool:hutool-cache:5.7.14") implementation("cn.hutool:hutool-json:5.7.14") implementation("cn.hutool:hutool-crypto:5.7.14") implementation("com.google.code.gson:gson:2.8.8") implementation("com.google.zxing:core:3.4.1") implementation(platform("com.squareup.okhttp3:okhttp-bom:5.0.0-alpha.2")) implementation("com.squareup.okhttp3:okhttp") implementation("com.squareup.okhttp3:okhttp-dnsoverhttps") implementation("org.yaml:snakeyaml:1.29") implementation("com.github.daniel-stoneuk:material-about-library:3.2.0-rc01") implementation("com.mikepenz:aboutlibraries:8.9.3") implementation("com.jakewharton:process-phoenix:2.1.2") implementation("com.esotericsoftware:kryo:5.2.0") implementation("org.conscrypt:conscrypt-android:2.5.2") implementation("com.google.guava:guava:31.0.1-android") implementation("com.journeyapps:zxing-android-embedded:4.2.0") implementation("org.ini4j:ini4j:0.5.4") implementation("com.simplecityapps:recyclerview-fastscroll:2.0.1") { exclude(group = "androidx.recyclerview") exclude(group = "androidx.appcompat") } implementation("org.smali:dexlib2:2.5.2") { exclude(group = "com.google.guava", module = "guava") } implementation("androidx.room:room-runtime:2.3.0") kapt("androidx.room:room-compiler:2.3.0") implementation("androidx.room:room-ktx:2.3.0") implementation("com.github.MatrixDev.Roomigrant:RoomigrantLib:0.3.4") kapt("com.github.MatrixDev.Roomigrant:RoomigrantCompiler:0.3.4") implementation("editorkit:editorkit:2.0.0") implementation("editorkit:feature-editor:2.0.0") implementation("editorkit:language-json:2.0.0") implementation("termux:terminal-view:1.0") // implementation(project(":library:proto-stub")) // implementation("io.grpc:grpc-okhttp:1.40.1") coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:1.1.5") } ================================================ FILE: app/proguard-rules.pro ================================================ -repackageclasses '' -allowaccessmodification -keep class io.nekohasekai.sagernet.** { *;} # ini4j -keep public class org.ini4j.spi.** { (); } # SnakeYaml -keep class org.yaml.snakeyaml.** { *; } # IDK Why -keep class cn.hutool.core.convert.** { *; } -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 com.android.org.conscrypt.SSLParametersImpl ================================================ FILE: app/schemas/io.nekohasekai.sagernet.database.SagerDatabase/1.json ================================================ { "formatVersion": 1, "database": { "version": 1, "identityHash": "1239f165d8e5bf435ab3644e8fe25ff2", "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)", "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 } ], "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, `ssrBean` BLOB, `vmessBean` BLOB, `vlessBean` BLOB, `trojanBean` BLOB, `trojanGoBean` BLOB, `naiveBean` BLOB, `ptBean` BLOB, `rbBean` BLOB, `brookBean` BLOB, `configBean` BLOB, `chainBean` BLOB, `balancerBean` 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": "ssrBean", "columnName": "ssrBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "vmessBean", "columnName": "vmessBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "vlessBean", "columnName": "vlessBean", "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": "ptBean", "columnName": "ptBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "rbBean", "columnName": "rbBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "brookBean", "columnName": "brookBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "configBean", "columnName": "configBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "chainBean", "columnName": "chainBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "balancerBean", "columnName": "balancerBean", "affinity": "BLOB", "notNull": false } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "groupId", "unique": false, "columnNames": [ "groupId" ], "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, `attrs` TEXT NOT NULL, `outbound` INTEGER NOT NULL, `reverse` INTEGER NOT NULL, `redirect` 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": "attrs", "columnName": "attrs", "affinity": "TEXT", "notNull": true }, { "fieldPath": "outbound", "columnName": "outbound", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "reverse", "columnName": "reverse", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "redirect", "columnName": "redirect", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [], "foreignKeys": [] }, { "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, '1239f165d8e5bf435ab3644e8fe25ff2')" ] } } ================================================ FILE: app/schemas/io.nekohasekai.sagernet.database.SagerDatabase/10.json ================================================ { "formatVersion": 1, "database": { "version": 10, "identityHash": "e429a05e6fa8d85cb18332d6a20b490e", "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, `ssrBean` BLOB, `vmessBean` BLOB, `vlessBean` BLOB, `trojanBean` BLOB, `trojanGoBean` BLOB, `naiveBean` BLOB, `ptBean` BLOB, `rbBean` BLOB, `brookBean` BLOB, `hysteriaBean` BLOB, `snellBean` BLOB, `sshBean` BLOB, `wgBean` BLOB, `configBean` BLOB, `chainBean` BLOB, `balancerBean` 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": "ssrBean", "columnName": "ssrBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "vmessBean", "columnName": "vmessBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "vlessBean", "columnName": "vlessBean", "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": "ptBean", "columnName": "ptBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "rbBean", "columnName": "rbBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "brookBean", "columnName": "brookBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "hysteriaBean", "columnName": "hysteriaBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "snellBean", "columnName": "snellBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "sshBean", "columnName": "sshBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "wgBean", "columnName": "wgBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "configBean", "columnName": "configBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "chainBean", "columnName": "chainBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "balancerBean", "columnName": "balancerBean", "affinity": "BLOB", "notNull": false } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "groupId", "unique": false, "columnNames": [ "groupId" ], "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, `attrs` TEXT NOT NULL, `outbound` INTEGER NOT NULL, `reverse` INTEGER NOT NULL, `redirect` TEXT NOT NULL, `packages` TEXT NOT NULL, `appStatus` 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": "attrs", "columnName": "attrs", "affinity": "TEXT", "notNull": true }, { "fieldPath": "outbound", "columnName": "outbound", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "reverse", "columnName": "reverse", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "redirect", "columnName": "redirect", "affinity": "TEXT", "notNull": true }, { "fieldPath": "packages", "columnName": "packages", "affinity": "TEXT", "notNull": true }, { "fieldPath": "appStatus", "columnName": "appStatus", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [], "foreignKeys": [] }, { "tableName": "stats", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `packageName` TEXT NOT NULL, `tcpConnections` INTEGER NOT NULL, `udpConnections` INTEGER NOT NULL, `uplink` INTEGER NOT NULL, `downlink` INTEGER NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "packageName", "columnName": "packageName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tcpConnections", "columnName": "tcpConnections", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "udpConnections", "columnName": "udpConnections", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "uplink", "columnName": "uplink", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "downlink", "columnName": "downlink", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "index_stats_packageName", "unique": true, "columnNames": [ "packageName" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_stats_packageName` ON `${TABLE_NAME}` (`packageName`)" } ], "foreignKeys": [] }, { "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, 'e429a05e6fa8d85cb18332d6a20b490e')" ] } } ================================================ FILE: app/schemas/io.nekohasekai.sagernet.database.SagerDatabase/2.json ================================================ { "formatVersion": 1, "database": { "version": 2, "identityHash": "7d7e2a82a10090ef33d4144c968a0261", "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)", "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 } ], "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, `ssrBean` BLOB, `vmessBean` BLOB, `vlessBean` BLOB, `trojanBean` BLOB, `trojanGoBean` BLOB, `naiveBean` BLOB, `ptBean` BLOB, `rbBean` BLOB, `brookBean` BLOB, `configBean` BLOB, `chainBean` BLOB, `balancerBean` 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": "ssrBean", "columnName": "ssrBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "vmessBean", "columnName": "vmessBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "vlessBean", "columnName": "vlessBean", "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": "ptBean", "columnName": "ptBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "rbBean", "columnName": "rbBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "brookBean", "columnName": "brookBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "configBean", "columnName": "configBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "chainBean", "columnName": "chainBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "balancerBean", "columnName": "balancerBean", "affinity": "BLOB", "notNull": false } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "groupId", "unique": false, "columnNames": [ "groupId" ], "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, `attrs` TEXT NOT NULL, `outbound` INTEGER NOT NULL, `reverse` INTEGER NOT NULL, `redirect` TEXT 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": "attrs", "columnName": "attrs", "affinity": "TEXT", "notNull": true }, { "fieldPath": "outbound", "columnName": "outbound", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "reverse", "columnName": "reverse", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "redirect", "columnName": "redirect", "affinity": "TEXT", "notNull": true }, { "fieldPath": "packages", "columnName": "packages", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [], "foreignKeys": [] }, { "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, '7d7e2a82a10090ef33d4144c968a0261')" ] } } ================================================ FILE: app/schemas/io.nekohasekai.sagernet.database.SagerDatabase/3.json ================================================ { "formatVersion": 1, "database": { "version": 3, "identityHash": "3e2a0dd9879b5afd0ca3e9d55c7e0347", "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)", "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 } ], "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, `ssrBean` BLOB, `vmessBean` BLOB, `vlessBean` BLOB, `trojanBean` BLOB, `trojanGoBean` BLOB, `naiveBean` BLOB, `ptBean` BLOB, `rbBean` BLOB, `brookBean` BLOB, `hysteriaBean` BLOB, `configBean` BLOB, `chainBean` BLOB, `balancerBean` 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": "ssrBean", "columnName": "ssrBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "vmessBean", "columnName": "vmessBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "vlessBean", "columnName": "vlessBean", "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": "ptBean", "columnName": "ptBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "rbBean", "columnName": "rbBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "brookBean", "columnName": "brookBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "hysteriaBean", "columnName": "hysteriaBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "configBean", "columnName": "configBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "chainBean", "columnName": "chainBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "balancerBean", "columnName": "balancerBean", "affinity": "BLOB", "notNull": false } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "groupId", "unique": false, "columnNames": [ "groupId" ], "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, `attrs` TEXT NOT NULL, `outbound` INTEGER NOT NULL, `reverse` INTEGER NOT NULL, `redirect` TEXT 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": "attrs", "columnName": "attrs", "affinity": "TEXT", "notNull": true }, { "fieldPath": "outbound", "columnName": "outbound", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "reverse", "columnName": "reverse", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "redirect", "columnName": "redirect", "affinity": "TEXT", "notNull": true }, { "fieldPath": "packages", "columnName": "packages", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [], "foreignKeys": [] }, { "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, '3e2a0dd9879b5afd0ca3e9d55c7e0347')" ] } } ================================================ FILE: app/schemas/io.nekohasekai.sagernet.database.SagerDatabase/4.json ================================================ { "formatVersion": 1, "database": { "version": 4, "identityHash": "695d898dfaf50d8e53a65332eeae0f8f", "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)", "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 } ], "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, `ssrBean` BLOB, `vmessBean` BLOB, `vlessBean` BLOB, `trojanBean` BLOB, `trojanGoBean` BLOB, `naiveBean` BLOB, `ptBean` BLOB, `rbBean` BLOB, `brookBean` BLOB, `hysteriaBean` BLOB, `snellBean` BLOB, `configBean` BLOB, `chainBean` BLOB, `balancerBean` 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": "ssrBean", "columnName": "ssrBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "vmessBean", "columnName": "vmessBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "vlessBean", "columnName": "vlessBean", "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": "ptBean", "columnName": "ptBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "rbBean", "columnName": "rbBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "brookBean", "columnName": "brookBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "hysteriaBean", "columnName": "hysteriaBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "snellBean", "columnName": "snellBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "configBean", "columnName": "configBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "chainBean", "columnName": "chainBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "balancerBean", "columnName": "balancerBean", "affinity": "BLOB", "notNull": false } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "groupId", "unique": false, "columnNames": [ "groupId" ], "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, `attrs` TEXT NOT NULL, `outbound` INTEGER NOT NULL, `reverse` INTEGER NOT NULL, `redirect` TEXT 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": "attrs", "columnName": "attrs", "affinity": "TEXT", "notNull": true }, { "fieldPath": "outbound", "columnName": "outbound", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "reverse", "columnName": "reverse", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "redirect", "columnName": "redirect", "affinity": "TEXT", "notNull": true }, { "fieldPath": "packages", "columnName": "packages", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [], "foreignKeys": [] }, { "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, '695d898dfaf50d8e53a65332eeae0f8f')" ] } } ================================================ FILE: app/schemas/io.nekohasekai.sagernet.database.SagerDatabase/5.json ================================================ { "formatVersion": 1, "database": { "version": 5, "identityHash": "255dcce3959e7a074a5c7835554e061d", "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, `ssrBean` BLOB, `vmessBean` BLOB, `vlessBean` BLOB, `trojanBean` BLOB, `trojanGoBean` BLOB, `naiveBean` BLOB, `ptBean` BLOB, `rbBean` BLOB, `brookBean` BLOB, `hysteriaBean` BLOB, `snellBean` BLOB, `configBean` BLOB, `chainBean` BLOB, `balancerBean` 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": "ssrBean", "columnName": "ssrBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "vmessBean", "columnName": "vmessBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "vlessBean", "columnName": "vlessBean", "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": "ptBean", "columnName": "ptBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "rbBean", "columnName": "rbBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "brookBean", "columnName": "brookBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "hysteriaBean", "columnName": "hysteriaBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "snellBean", "columnName": "snellBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "configBean", "columnName": "configBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "chainBean", "columnName": "chainBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "balancerBean", "columnName": "balancerBean", "affinity": "BLOB", "notNull": false } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "groupId", "unique": false, "columnNames": [ "groupId" ], "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, `attrs` TEXT NOT NULL, `outbound` INTEGER NOT NULL, `reverse` INTEGER NOT NULL, `redirect` TEXT 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": "attrs", "columnName": "attrs", "affinity": "TEXT", "notNull": true }, { "fieldPath": "outbound", "columnName": "outbound", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "reverse", "columnName": "reverse", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "redirect", "columnName": "redirect", "affinity": "TEXT", "notNull": true }, { "fieldPath": "packages", "columnName": "packages", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [], "foreignKeys": [] }, { "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, '255dcce3959e7a074a5c7835554e061d')" ] } } ================================================ FILE: app/schemas/io.nekohasekai.sagernet.database.SagerDatabase/6.json ================================================ { "formatVersion": 1, "database": { "version": 6, "identityHash": "defc60daae7ba00e68f5aade29470de7", "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, `ssrBean` BLOB, `vmessBean` BLOB, `vlessBean` BLOB, `trojanBean` BLOB, `trojanGoBean` BLOB, `naiveBean` BLOB, `ptBean` BLOB, `rbBean` BLOB, `brookBean` BLOB, `hysteriaBean` BLOB, `snellBean` BLOB, `configBean` BLOB, `chainBean` BLOB, `balancerBean` 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": "ssrBean", "columnName": "ssrBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "vmessBean", "columnName": "vmessBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "vlessBean", "columnName": "vlessBean", "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": "ptBean", "columnName": "ptBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "rbBean", "columnName": "rbBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "brookBean", "columnName": "brookBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "hysteriaBean", "columnName": "hysteriaBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "snellBean", "columnName": "snellBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "configBean", "columnName": "configBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "chainBean", "columnName": "chainBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "balancerBean", "columnName": "balancerBean", "affinity": "BLOB", "notNull": false } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "groupId", "unique": false, "columnNames": [ "groupId" ], "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, `attrs` TEXT NOT NULL, `outbound` INTEGER NOT NULL, `reverse` INTEGER NOT NULL, `redirect` TEXT 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": "attrs", "columnName": "attrs", "affinity": "TEXT", "notNull": true }, { "fieldPath": "outbound", "columnName": "outbound", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "reverse", "columnName": "reverse", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "redirect", "columnName": "redirect", "affinity": "TEXT", "notNull": true }, { "fieldPath": "packages", "columnName": "packages", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [], "foreignKeys": [] }, { "tableName": "stats", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`packageName` TEXT NOT NULL, `tcpConnections` INTEGER NOT NULL, `udpConnections` INTEGER NOT NULL, `uplink` INTEGER NOT NULL, `downlink` INTEGER NOT NULL, PRIMARY KEY(`packageName`))", "fields": [ { "fieldPath": "packageName", "columnName": "packageName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tcpConnections", "columnName": "tcpConnections", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "udpConnections", "columnName": "udpConnections", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "uplink", "columnName": "uplink", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "downlink", "columnName": "downlink", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "packageName" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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, 'defc60daae7ba00e68f5aade29470de7')" ] } } ================================================ FILE: app/schemas/io.nekohasekai.sagernet.database.SagerDatabase/7.json ================================================ { "formatVersion": 1, "database": { "version": 7, "identityHash": "e957a0581037cce1c1f33c637f856cea", "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, `ssrBean` BLOB, `vmessBean` BLOB, `vlessBean` BLOB, `trojanBean` BLOB, `trojanGoBean` BLOB, `naiveBean` BLOB, `ptBean` BLOB, `rbBean` BLOB, `brookBean` BLOB, `hysteriaBean` BLOB, `snellBean` BLOB, `configBean` BLOB, `chainBean` BLOB, `balancerBean` 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": "ssrBean", "columnName": "ssrBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "vmessBean", "columnName": "vmessBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "vlessBean", "columnName": "vlessBean", "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": "ptBean", "columnName": "ptBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "rbBean", "columnName": "rbBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "brookBean", "columnName": "brookBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "hysteriaBean", "columnName": "hysteriaBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "snellBean", "columnName": "snellBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "configBean", "columnName": "configBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "chainBean", "columnName": "chainBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "balancerBean", "columnName": "balancerBean", "affinity": "BLOB", "notNull": false } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "groupId", "unique": false, "columnNames": [ "groupId" ], "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, `attrs` TEXT NOT NULL, `outbound` INTEGER NOT NULL, `reverse` INTEGER NOT NULL, `redirect` TEXT 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": "attrs", "columnName": "attrs", "affinity": "TEXT", "notNull": true }, { "fieldPath": "outbound", "columnName": "outbound", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "reverse", "columnName": "reverse", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "redirect", "columnName": "redirect", "affinity": "TEXT", "notNull": true }, { "fieldPath": "packages", "columnName": "packages", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [], "foreignKeys": [] }, { "tableName": "stats", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `packageName` TEXT NOT NULL, `tcpConnections` INTEGER NOT NULL, `udpConnections` INTEGER NOT NULL, `uplink` INTEGER NOT NULL, `downlink` INTEGER NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "packageName", "columnName": "packageName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tcpConnections", "columnName": "tcpConnections", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "udpConnections", "columnName": "udpConnections", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "uplink", "columnName": "uplink", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "downlink", "columnName": "downlink", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "index_stats_packageName", "unique": true, "columnNames": [ "packageName" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_stats_packageName` ON `${TABLE_NAME}` (`packageName`)" } ], "foreignKeys": [] }, { "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, 'e957a0581037cce1c1f33c637f856cea')" ] } } ================================================ FILE: app/schemas/io.nekohasekai.sagernet.database.SagerDatabase/8.json ================================================ { "formatVersion": 1, "database": { "version": 8, "identityHash": "f494636dc7d13a464b5d27634324ae0a", "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, `ssrBean` BLOB, `vmessBean` BLOB, `vlessBean` BLOB, `trojanBean` BLOB, `trojanGoBean` BLOB, `naiveBean` BLOB, `ptBean` BLOB, `rbBean` BLOB, `brookBean` BLOB, `hysteriaBean` BLOB, `snellBean` BLOB, `configBean` BLOB, `chainBean` BLOB, `balancerBean` 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": "ssrBean", "columnName": "ssrBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "vmessBean", "columnName": "vmessBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "vlessBean", "columnName": "vlessBean", "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": "ptBean", "columnName": "ptBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "rbBean", "columnName": "rbBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "brookBean", "columnName": "brookBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "hysteriaBean", "columnName": "hysteriaBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "snellBean", "columnName": "snellBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "configBean", "columnName": "configBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "chainBean", "columnName": "chainBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "balancerBean", "columnName": "balancerBean", "affinity": "BLOB", "notNull": false } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "groupId", "unique": false, "columnNames": [ "groupId" ], "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, `attrs` TEXT NOT NULL, `outbound` INTEGER NOT NULL, `reverse` INTEGER NOT NULL, `redirect` TEXT NOT NULL, `packages` TEXT NOT NULL, `appStatus` 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": "attrs", "columnName": "attrs", "affinity": "TEXT", "notNull": true }, { "fieldPath": "outbound", "columnName": "outbound", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "reverse", "columnName": "reverse", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "redirect", "columnName": "redirect", "affinity": "TEXT", "notNull": true }, { "fieldPath": "packages", "columnName": "packages", "affinity": "TEXT", "notNull": true }, { "fieldPath": "appStatus", "columnName": "appStatus", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [], "foreignKeys": [] }, { "tableName": "stats", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `packageName` TEXT NOT NULL, `tcpConnections` INTEGER NOT NULL, `udpConnections` INTEGER NOT NULL, `uplink` INTEGER NOT NULL, `downlink` INTEGER NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "packageName", "columnName": "packageName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tcpConnections", "columnName": "tcpConnections", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "udpConnections", "columnName": "udpConnections", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "uplink", "columnName": "uplink", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "downlink", "columnName": "downlink", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "index_stats_packageName", "unique": true, "columnNames": [ "packageName" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_stats_packageName` ON `${TABLE_NAME}` (`packageName`)" } ], "foreignKeys": [] }, { "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, 'f494636dc7d13a464b5d27634324ae0a')" ] } } ================================================ FILE: app/schemas/io.nekohasekai.sagernet.database.SagerDatabase/9.json ================================================ { "formatVersion": 1, "database": { "version": 9, "identityHash": "aa700a039e9ba16631c1a023f4355410", "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, `ssrBean` BLOB, `vmessBean` BLOB, `vlessBean` BLOB, `trojanBean` BLOB, `trojanGoBean` BLOB, `naiveBean` BLOB, `ptBean` BLOB, `rbBean` BLOB, `brookBean` BLOB, `hysteriaBean` BLOB, `snellBean` BLOB, `sshBean` BLOB, `configBean` BLOB, `chainBean` BLOB, `balancerBean` 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": "ssrBean", "columnName": "ssrBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "vmessBean", "columnName": "vmessBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "vlessBean", "columnName": "vlessBean", "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": "ptBean", "columnName": "ptBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "rbBean", "columnName": "rbBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "brookBean", "columnName": "brookBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "hysteriaBean", "columnName": "hysteriaBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "snellBean", "columnName": "snellBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "sshBean", "columnName": "sshBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "configBean", "columnName": "configBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "chainBean", "columnName": "chainBean", "affinity": "BLOB", "notNull": false }, { "fieldPath": "balancerBean", "columnName": "balancerBean", "affinity": "BLOB", "notNull": false } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "groupId", "unique": false, "columnNames": [ "groupId" ], "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, `attrs` TEXT NOT NULL, `outbound` INTEGER NOT NULL, `reverse` INTEGER NOT NULL, `redirect` TEXT NOT NULL, `packages` TEXT NOT NULL, `appStatus` 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": "attrs", "columnName": "attrs", "affinity": "TEXT", "notNull": true }, { "fieldPath": "outbound", "columnName": "outbound", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "reverse", "columnName": "reverse", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "redirect", "columnName": "redirect", "affinity": "TEXT", "notNull": true }, { "fieldPath": "packages", "columnName": "packages", "affinity": "TEXT", "notNull": true }, { "fieldPath": "appStatus", "columnName": "appStatus", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [], "foreignKeys": [] }, { "tableName": "stats", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `packageName` TEXT NOT NULL, `tcpConnections` INTEGER NOT NULL, `udpConnections` INTEGER NOT NULL, `uplink` INTEGER NOT NULL, `downlink` INTEGER NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "packageName", "columnName": "packageName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tcpConnections", "columnName": "tcpConnections", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "udpConnections", "columnName": "udpConnections", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "uplink", "columnName": "uplink", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "downlink", "columnName": "downlink", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "index_stats_packageName", "unique": true, "columnNames": [ "packageName" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_stats_packageName` ON `${TABLE_NAME}` (`packageName`)" } ], "foreignKeys": [] }, { "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, 'aa700a039e9ba16631c1a023f4355410')" ] } } ================================================ 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/src/androidTest/java/io/nekohasekai/sagernet/ExampleInstrumentedTest.kt ================================================ package io.nekohasekai.sagernet import androidx.test.platform.app.InstrumentationRegistry import androidx.test.ext.junit.runners.AndroidJUnit4 import org.junit.Test import org.junit.runner.RunWith import org.junit.Assert.* /** * Instrumented test, which will execute on an Android device. * * See [testing documentation](http://d.android.com/tools/testing). */ @RunWith(AndroidJUnit4::class) class ExampleInstrumentedTest { @Test fun useAppContext() { // Context of the app under test. val appContext = InstrumentationRegistry.getInstrumentation().targetContext assertEquals("io.nekohasekai.sagernet", appContext.packageName) } } ================================================ FILE: app/src/main/AndroidManifest.xml ================================================ ================================================ FILE: app/src/main/aidl/io/nekohasekai/sagernet/aidl/AppStatsList.aidl ================================================ package io.nekohasekai.sagernet.aidl; parcelable AppStatsList; ================================================ 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); void startListeningForBandwidth(in ISagerNetServiceCallback cb, long timeout); oneway void stopListeningForBandwidth(in ISagerNetServiceCallback cb); void startListeningForStats(in ISagerNetServiceCallback cb, long timeout); oneway void stopListeningForStats(in ISagerNetServiceCallback cb); oneway void unregisterCallback(in ISagerNetServiceCallback cb); oneway void protect(int fd); int urlTest(); oneway void resetTrafficStats(); boolean getTrafficStatsEnabled(); } ================================================ FILE: app/src/main/aidl/io/nekohasekai/sagernet/aidl/ISagerNetServiceCallback.aidl ================================================ package io.nekohasekai.sagernet.aidl; import io.nekohasekai.sagernet.aidl.TrafficStats; import io.nekohasekai.sagernet.aidl.AppStatsList; oneway interface ISagerNetServiceCallback { void stateChanged(int state, String profileName, String msg); void trafficUpdated(long profileId, in TrafficStats stats, boolean isCurrent); void statsUpdated(in AppStatsList statsList); void observatoryResultsUpdated(long groupId); // Traffic data has persisted to database, listener should refetch their data from database void profilePersisted(long profileId); void missingPlugin(String profileName, String pluginName); void routeAlert(int type, String routeName); } ================================================ FILE: app/src/main/aidl/io/nekohasekai/sagernet/aidl/TrafficStats.aidl ================================================ package io.nekohasekai.sagernet.aidl; parcelable TrafficStats; ================================================ 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/terminal.properties ================================================ # https://github.com/chriskempson/base16-xresources/blob/master/base16-google.light.256.xresources # Base16 Google # Scheme: Seth Wright (http://sethawright.com) foreground=#373b41 background=#ffffff cursor=#373b41 color0=#1d1f21 color1=#CC342B color2=#198844 color3=#FBA922 color4=#3971ED color5=#A36AC7 color6=#3971ED color7=#c5c8c6 color8=#969896 color9=#CC342B color10=#198844 color11=#FBA922 color12=#3971ED color13=#A36AC7 color14=#3971ED color15=#ffffff color16=#F96A38 color17=#3971ED color18=#282a2e color19=#373b41 color20=#b4b7b4 color21=#e0e0e0 ================================================ FILE: app/src/main/java/cn/hutool/cache/impl/AbstractCacheWithoutLock.java ================================================ /****************************************************************************** * * * 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 cn.hutool.cache.impl; import java.util.Iterator; import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; import cn.hutool.cache.Cache; import cn.hutool.cache.CacheListener; import cn.hutool.core.collection.CopiedIter; import cn.hutool.core.lang.func.Func0; /** * 超时和限制大小的缓存的默认实现
* 继承此抽象缓存需要:
*
    *
  • 创建一个新的Map
  • *
  • 实现 {@code prune} 策略
  • *
* * @param 键类型 * @param 值类型 * @author Looly, jodd */ public abstract class AbstractCacheWithoutLock implements Cache { private static final long serialVersionUID = 1L; protected Map> cacheMap; /** * 写的时候每个key一把锁,降低锁的粒度 */ protected final Map keyLockMap = new ConcurrentHashMap<>(); /** * 返回缓存容量,{@code 0}表示无大小限制 */ protected int capacity; /** * 缓存失效时长, {@code 0} 表示无限制,单位毫秒 */ protected long timeout; /** * 每个对象是否有单独的失效时长,用于决定清理过期对象是否有必要。 */ protected boolean existCustomTimeout; /** * 命中数,即命中缓存计数 */ protected AtomicLong hitCount = new AtomicLong(); /** * 丢失数,即未命中缓存计数 */ protected AtomicLong missCount = new AtomicLong(); /** * 缓存监听 */ protected CacheListener listener; // ---------------------------------------------------------------- put start @Override public void put(K key, V object) { put(key, object, timeout); } @Override public void put(K key, V object, long timeout) { putWithoutLock(key, object, timeout); } /** * 加入元素,无锁 * * @param key 键 * @param object 值 * @param timeout 超时时长 * @since 4.5.16 */ public void putWithoutLock(K key, V object, long timeout) { CacheObj co = new CacheObj<>(key, object, timeout); if (timeout != 0) { existCustomTimeout = true; } if (isFull()) { pruneCache(); } cacheMap.put(key, co); } // ---------------------------------------------------------------- put end // ---------------------------------------------------------------- get start @Override public boolean containsKey(K key) { // 不存在或已移除 final CacheObj co = cacheMap.get(key); if (co == null) { return false; } if (false == co.isExpired()) { // 命中 return true; } // 过期 remove(key, true); return false; } /** * @return 命中数 */ public long getHitCount() { return hitCount.get(); } /** * @return 丢失数 */ public long getMissCount() { return missCount.get(); } @Override public V get(K key, boolean isUpdateLastAccess, Func0 supplier) { V v = get(key, isUpdateLastAccess); if (null == v && null != supplier) { //每个key单独获取一把锁,降低锁的粒度提高并发能力,see pr#1385@Github final Lock keyLock = keyLockMap.computeIfAbsent(key, k -> new ReentrantLock()); keyLock.lock(); try { // 双重检查锁,防止在竞争锁的过程中已经有其它线程写入 final CacheObj co = cacheMap.get(key); if (null == co || co.isExpired()) { try { v = supplier.call(); } catch (Exception e) { throw new RuntimeException(e); } put(key, v, this.timeout); } else { v = co.get(isUpdateLastAccess); } } finally { keyLock.unlock(); keyLockMap.remove(key); } } return v; } @Override public V get(K key, boolean isUpdateLastAccess) { // 尝试读取缓存,使用乐观读锁 CacheObj co = cacheMap.get(key); // 未命中 if (null == co) { missCount.incrementAndGet(); return null; } else if (false == co.isExpired()) { hitCount.incrementAndGet(); return co.get(isUpdateLastAccess); } // 过期,既不算命中也不算非命中 remove(key, true); return null; } // ---------------------------------------------------------------- get end @Override public Iterator iterator() { CacheObjIterator copiedIterator = (CacheObjIterator) this.cacheObjIterator(); return new CacheValuesIterator<>(copiedIterator); } @Override public Iterator> cacheObjIterator() { CopiedIter> copiedIterator; copiedIterator = CopiedIter.copyOf(this.cacheMap.values().iterator()); return new CacheObjIterator<>(copiedIterator); } // ---------------------------------------------------------------- prune start /** * 清理实现
* 子类实现此方法时无需加锁 * * @return 清理数 */ protected abstract int pruneCache(); @Override public final int prune() { return pruneCache(); } // ---------------------------------------------------------------- prune end // ---------------------------------------------------------------- common start @Override public int capacity() { return capacity; } /** * @return 默认缓存失效时长。
* 每个对象可以单独设置失效时长 */ @Override public long timeout() { return timeout; } /** * 只有设置公共缓存失效时长或每个对象单独的失效时长时清理可用 * * @return 过期对象清理是否可用,内部使用 */ protected boolean isPruneExpiredActive() { return (timeout != 0) || existCustomTimeout; } @Override public boolean isFull() { return (capacity > 0) && (cacheMap.size() >= capacity); } @Override public void remove(K key) { remove(key, false); } @Override public void clear() { cacheMap.clear(); } @Override public int size() { return cacheMap.size(); } @Override public boolean isEmpty() { return cacheMap.isEmpty(); } @Override public String toString() { return this.cacheMap.toString(); } // ---------------------------------------------------------------- common end /** * 设置监听 * * @param listener 监听 * @return this * @since 5.5.2 */ @Override public AbstractCacheWithoutLock setListener(CacheListener listener) { this.listener = listener; return this; } /** * 返回所有键 * * @return 所有键 * @since 5.5.9 */ public Set keySet() { return this.cacheMap.keySet(); } /** * 对象移除回调。默认无动作
* 子类可重写此方法用于监听移除事件,如果重写,listener将无效 * * @param key 键 * @param cachedObject 被缓存的对象 */ protected void onRemove(K key, V cachedObject) { final CacheListener listener = this.listener; if (null != listener) { listener.onRemove(key, cachedObject); } } /** * 移除key对应的对象 * * @param key 键 * @param withMissCount 是否计数丢失数 */ private void remove(K key, boolean withMissCount) { CacheObj co = removeWithoutLock(key, withMissCount); if (null != co) { onRemove(co.key, co.obj); } } /** * 移除key对应的对象,不加锁 * * @param key 键 * @param withMissCount 是否计数丢失数 * @return 移除的对象,无返回null */ private CacheObj removeWithoutLock(K key, boolean withMissCount) { final CacheObj co = cacheMap.remove(key); if (withMissCount) { // 在丢失计数有效的情况下,移除一般为get时的超时操作,此处应该丢失数+1 this.missCount.incrementAndGet(); } return co; } } ================================================ FILE: app/src/main/java/cn/hutool/cache/impl/LFUCacheCompact.java ================================================ /****************************************************************************** * * * 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 cn.hutool.cache.impl; import android.os.Build; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import cn.hutool.cache.Cache; public class LFUCacheCompact { protected int capacity; protected long timeout; public LFUCacheCompact(int capacity, long timeout) { if (Integer.MAX_VALUE == capacity) { capacity -= 1; } this.capacity = capacity; this.timeout = timeout; } protected void onRemove(K key, V cachedObject) { } public Cache build(boolean async) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { return new LFUCache(capacity, timeout) { { if (async) { cacheMap = new ConcurrentHashMap<>(); } } @Override protected void onRemove(K key, V cachedObject) { LFUCacheCompact.this.onRemove(key, cachedObject); } }; } else { return new LFUCacheWithoutLock(capacity, timeout) { @Override protected Map> createCacheMap() { return new ConcurrentHashMap<>(); } @Override protected void onRemove(K key, V cachedObject) { LFUCacheCompact.this.onRemove(key, cachedObject); } }; } } } ================================================ FILE: app/src/main/java/cn/hutool/cache/impl/LFUCacheWithoutLock.java ================================================ /****************************************************************************** * * * 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 cn.hutool.cache.impl; import java.util.HashMap; import java.util.Iterator; import java.util.Map; /** * LFU(least frequently used) 最少使用率缓存
* 根据使用次数来判定对象是否被持续缓存
* 使用率是通过访问次数计算的。
* 当缓存满时清理过期对象。
* 清理后依旧满的情况下清除最少访问(访问计数最小)的对象并将其他对象的访问数减去这个最小访问数,以便新对象进入后可以公平计数。 * * @param 键类型 * @param 值类型 * @author Looly, jodd */ public class LFUCacheWithoutLock extends AbstractCacheWithoutLock { private static final long serialVersionUID = 1L; /** * 构造 * * @param capacity 容量 */ public LFUCacheWithoutLock(int capacity) { this(capacity, 0); } /** * 构造 * * @param capacity 容量 * @param timeout 过期时长 */ public LFUCacheWithoutLock(int capacity, long timeout) { if (Integer.MAX_VALUE == capacity) { capacity -= 1; } this.capacity = capacity; this.timeout = timeout; cacheMap = createCacheMap(); } protected Map> createCacheMap() { return new HashMap<>(capacity + 1, 1.0f); } // ---------------------------------------------------------------- prune /** * 清理过期对象。
* 清理后依旧满的情况下清除最少访问(访问计数最小)的对象并将其他对象的访问数减去这个最小访问数,以便新对象进入后可以公平计数。 * * @return 清理个数 */ @Override protected int pruneCache() { int count = 0; CacheObj comin = null; // 清理过期对象并找出访问最少的对象 Iterator> values = cacheMap.values().iterator(); CacheObj co; while (values.hasNext()) { co = values.next(); if (co.isExpired() == true) { values.remove(); onRemove(co.key, co.obj); count++; continue; } //找出访问最少的对象 if (comin == null || co.accessCount.get() < comin.accessCount.get()) { comin = co; } } // 减少所有对象访问量,并清除减少后为0的访问对象 if (isFull() && comin != null) { long minAccessCount = comin.accessCount.get(); values = cacheMap.values().iterator(); CacheObj co1; while (values.hasNext()) { co1 = values.next(); if (co1.accessCount.addAndGet(-minAccessCount) <= 0) { values.remove(); onRemove(co1.key, co1.obj); count++; } } } return count; } } ================================================ FILE: app/src/main/java/com/github/shadowsocks/plugin/ConfigurationActivity.kt ================================================ /****************************************************************************** * * * Copyright (C) 2021 by nekohasekai * * Copyright (C) 2021 by Max Lv * * Copyright (C) 2021 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 com.github.shadowsocks.plugin import android.app.Activity import android.content.Intent /** * Base class for configuration activity. A configuration activity is started when user wishes to configure the * selected plugin. To create a configuration activity, extend this class, implement abstract methods, invoke * `saveChanges(options)` and `discardChanges()` when appropriate, and add it to your manifest like this: * *
<manifest>
 *    ...
 *    <application>
 *        ...
 *        <activity android:name=".ConfigureActivity">
 *            <intent-filter>
 *                <action android:name="com.github.shadowsocks.plugin.ACTION_CONFIGURE"/>
 *                <category android:name="android.intent.category.DEFAULT"/>
 *                <data android:scheme="plugin"
 *                         android:host="com.github.shadowsocks"
 *                         android:path="/$PLUGIN_ID"/>
 *            </intent-filter>
 *        </activity>
 *        ...
 *    </application>
 *</manifest>
*/ abstract class ConfigurationActivity : OptionsCapableActivity() { /** * Equivalent to setResult(RESULT_CANCELED). */ fun discardChanges() = setResult(Activity.RESULT_CANCELED) /** * Equivalent to setResult(RESULT_OK, args_with_correct_format). * * @param options PluginOptions to save. */ fun saveChanges(options: PluginOptions) = setResult(Activity.RESULT_OK, Intent().putExtra(PluginContract.EXTRA_OPTIONS, options.toString())) /** * Finish this activity and request manual editor to pop up instead. */ fun fallbackToManualEditor() { setResult(PluginContract.RESULT_FALLBACK) finish() } } ================================================ FILE: app/src/main/java/com/github/shadowsocks/plugin/HelpActivity.kt ================================================ /****************************************************************************** * * * Copyright (C) 2021 by nekohasekai * * Copyright (C) 2021 by Max Lv * * Copyright (C) 2021 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 com.github.shadowsocks.plugin /** * Base class for a help activity. A help activity is started when user taps help when configuring options for your * plugin. To create a help activity, just extend this class, and add it to your manifest like this: * *
<manifest>
 *    ...
 *    <application>
 *        ...
 *        <activity android:name=".HelpActivity">
 *            <intent-filter>
 *                <action android:name="com.github.shadowsocks.plugin.ACTION_HELP"/>
 *                <category android:name="android.intent.category.DEFAULT"/>
 *                <data android:scheme="plugin"
 *                         android:host="com.github.shadowsocks"
 *                         android:path="/$PLUGIN_ID"/>
 *            </intent-filter>
 *        </activity>
 *        ...
 *    </application>
 *</manifest>
*/ abstract class HelpActivity : OptionsCapableActivity() { override fun onInitializePluginOptions(options: PluginOptions) { } } ================================================ FILE: app/src/main/java/com/github/shadowsocks/plugin/HelpCallback.kt ================================================ /****************************************************************************** * * * Copyright (C) 2021 by nekohasekai * * Copyright (C) 2021 by Max Lv * * Copyright (C) 2021 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 com.github.shadowsocks.plugin import android.content.Intent /** * HelpCallback is an HelpActivity but you just need to produce a CharSequence help message instead of having to * provide UI. To create a help callback, just extend this class, implement abstract methods, and add it to your * manifest following the same procedure as adding a HelpActivity. */ abstract class HelpCallback : HelpActivity() { abstract fun produceHelpMessage(options: PluginOptions): CharSequence override fun onInitializePluginOptions(options: PluginOptions) { setResult(RESULT_OK, Intent().putExtra(PluginContract.EXTRA_HELP_MESSAGE, produceHelpMessage(options))) finish() } } ================================================ FILE: app/src/main/java/com/github/shadowsocks/plugin/InternalPlugin.kt ================================================ /****************************************************************************** * * * Copyright (C) 2021 by nekohasekai * * Copyright (C) 2021 by Max Lv * * Copyright (C) 2021 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 com.github.shadowsocks.plugin class InternalPlugin(override val id: String, override val label: CharSequence) : Plugin() { companion object { val SIMPLE_OBFS = InternalPlugin("obfs-local", "Simple Obfs (Internal)") val V2RAY_PLUGIN = InternalPlugin("v2ray-plugin", "V2Ray Plugin (Internal)") } } ================================================ FILE: app/src/main/java/com/github/shadowsocks/plugin/NativePlugin.kt ================================================ /****************************************************************************** * * * Copyright (C) 2021 by nekohasekai * * Copyright (C) 2021 by Max Lv * * Copyright (C) 2021 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 com.github.shadowsocks.plugin import android.content.pm.ResolveInfo class NativePlugin(resolveInfo: ResolveInfo) : ResolvedPlugin(resolveInfo) { init { check(resolveInfo.providerInfo != null) } override val componentInfo get() = resolveInfo.providerInfo!! } ================================================ FILE: app/src/main/java/com/github/shadowsocks/plugin/NativePluginProvider.kt ================================================ /****************************************************************************** * * * Copyright (C) 2021 by nekohasekai * * Copyright (C) 2021 by Max Lv * * Copyright (C) 2021 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 com.github.shadowsocks.plugin import android.content.ContentProvider import android.content.ContentValues import android.database.Cursor import android.database.MatrixCursor import android.net.Uri import android.os.Bundle import android.os.ParcelFileDescriptor import androidx.core.os.bundleOf /** * Base class for a native plugin provider. A native plugin provider offers read-only access to files that are required * to run a plugin, such as binary files and other configuration files. To create a native plugin provider, extend this * class, implement the abstract methods, and add it to your manifest like this: * *
<manifest>
 *    ...
 *    <application>
 *        ...
 *        <provider android:name="com.github.shadowsocks.$PLUGIN_ID.BinaryProvider"
 *                     android:authorities="com.github.shadowsocks.plugin.$PLUGIN_ID.BinaryProvider">
 *            <intent-filter>
 *                <category android:name="com.github.shadowsocks.plugin.ACTION_NATIVE_PLUGIN" />
 *            </intent-filter>
 *        </provider>
 *        ...
 *    </application>
 *</manifest>
*/ abstract class NativePluginProvider : ContentProvider() { override fun getType(uri: Uri): String? = "application/x-elf" override fun onCreate(): Boolean = true /** * Provide all files needed for native plugin. * * @param provider A helper object to use to add files. */ protected abstract fun populateFiles(provider: PathProvider) override fun query(uri: Uri, projection: Array?, selection: String?, selectionArgs: Array?, sortOrder: String?): Cursor? { check(selection == null && selectionArgs == null && sortOrder == null) val result = MatrixCursor(projection) populateFiles(PathProvider(uri, result)) return result } /** * Returns executable entry absolute path. * This is used for fast mode initialization where ss-local launches your native binary at the path given directly. * In order for this to work, plugin app is encouraged to have the following in its AndroidManifest.xml: * - android:installLocation="internalOnly" for * - android:extractNativeLibs="true" for * * Default behavior is throwing UnsupportedOperationException. If you don't wish to use this feature, use the * default behavior. * * @return Absolute path for executable entry. */ open fun getExecutable(): String = throw UnsupportedOperationException() abstract fun openFile(uri: Uri): ParcelFileDescriptor override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor { check(mode == "r") return openFile(uri) } override fun call(method: String, arg: String?, extras: Bundle?): Bundle? = when (method) { PluginContract.METHOD_GET_EXECUTABLE -> bundleOf(Pair(PluginContract.EXTRA_ENTRY, getExecutable())) else -> super.call(method, arg, extras) } // Methods that should not be used override fun insert(uri: Uri, values: ContentValues?): Uri? = throw UnsupportedOperationException() override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array?): Int = throw UnsupportedOperationException() override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int = throw UnsupportedOperationException() } ================================================ FILE: app/src/main/java/com/github/shadowsocks/plugin/NoPlugin.kt ================================================ /****************************************************************************** * * * Copyright (C) 2021 by nekohasekai * * Copyright (C) 2021 by Max Lv * * Copyright (C) 2021 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 com.github.shadowsocks.plugin import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.SagerNet object NoPlugin : Plugin() { override val id: String get() = "" override val label: CharSequence get() = SagerNet.application.getText(R.string.plugin_disabled) } ================================================ FILE: app/src/main/java/com/github/shadowsocks/plugin/OptionsCapableActivity.kt ================================================ /****************************************************************************** * * * Copyright (C) 2021 by nekohasekai * * Copyright (C) 2021 by Max Lv * * Copyright (C) 2021 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 com.github.shadowsocks.plugin import android.content.Intent import android.os.Bundle import android.widget.Toast import androidx.appcompat.app.AppCompatActivity import io.nekohasekai.sagernet.ui.ThemedActivity /** * Activity that's capable of getting EXTRA_OPTIONS input. */ abstract class OptionsCapableActivity : ThemedActivity() { protected fun pluginOptions(intent: Intent = this.intent) = try { PluginOptions("", intent.getStringExtra(PluginContract.EXTRA_OPTIONS)) } catch (exc: IllegalArgumentException) { Toast.makeText(this, exc.message, Toast.LENGTH_SHORT).show() PluginOptions() } /** * Populate args to your user interface. * * @param options PluginOptions parsed. */ protected abstract fun onInitializePluginOptions(options: PluginOptions = pluginOptions()) override fun onPostCreate(savedInstanceState: Bundle?) { super.onPostCreate(savedInstanceState) if (savedInstanceState == null) onInitializePluginOptions() } } ================================================ FILE: app/src/main/java/com/github/shadowsocks/plugin/PathProvider.kt ================================================ /****************************************************************************** * * * Copyright (C) 2021 by nekohasekai * * Copyright (C) 2021 by Max Lv * * Copyright (C) 2021 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 com.github.shadowsocks.plugin import android.database.MatrixCursor import android.net.Uri import java.io.File /** * Helper class to provide relative paths of files to copy. */ class PathProvider internal constructor(baseUri: Uri, private val cursor: MatrixCursor) { private val basePath = baseUri.path?.trim('/') ?: "" fun addPath(path: String, mode: Int = 0b110100100): PathProvider { val trimmed = path.trim('/') if (trimmed.startsWith(basePath)) cursor.newRow() .add(PluginContract.COLUMN_PATH, trimmed) .add(PluginContract.COLUMN_MODE, mode) return this } fun addTo(file: File, to: String = "", mode: Int = 0b110100100): PathProvider { var sub = to + file.name if (basePath.startsWith(sub)) if (file.isDirectory) { sub += '/' file.listFiles()!!.forEach { addTo(it, sub, mode) } } else addPath(sub, mode) return this } fun addAt(file: File, at: String = "", mode: Int = 0b110100100): PathProvider { if (basePath.startsWith(at)) { if (file.isDirectory) file.listFiles()!!.forEach { addTo(it, at, mode) } else addPath(at, mode) } return this } } ================================================ FILE: app/src/main/java/com/github/shadowsocks/plugin/Plugin.kt ================================================ /****************************************************************************** * * * Copyright (C) 2021 by nekohasekai * * Copyright (C) 2021 by Max Lv * * Copyright (C) 2021 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 com.github.shadowsocks.plugin import android.graphics.drawable.Drawable abstract class Plugin { abstract val id: String open val idAliases get() = emptyArray() abstract val label: CharSequence open val icon: Drawable? get() = null open val defaultConfig: String? get() = null open val packageName: String get() = "" open val trusted: Boolean get() = true open val directBootAware: Boolean get() = true override fun equals(other: Any?): Boolean { if (this === other) return true if (javaClass != other?.javaClass) return false return id == (other as Plugin).id } override fun hashCode() = id.hashCode() } ================================================ FILE: app/src/main/java/com/github/shadowsocks/plugin/PluginConfiguration.kt ================================================ /****************************************************************************** * * * Copyright (C) 2021 by nekohasekai * * Copyright (C) 2021 by Max Lv * * Copyright (C) 2021 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 com.github.shadowsocks.plugin import io.nekohasekai.sagernet.ktx.Logs import io.nekohasekai.sagernet.utils.Commandline import java.util.* class PluginConfiguration(val pluginsOptions: MutableMap, var selected: String) { private constructor(plugins: List) : this( plugins.filter { it.id.isNotEmpty() }.associateBy { it.id }.toMutableMap(), if (plugins.isEmpty()) "" else plugins[0].id) constructor(): this(listOf()) constructor(plugin: String) : this(plugin.split('\n').map { line -> if (line.startsWith("kcptun ")) { val opt = PluginOptions() opt.id = "kcptun" try { val iterator = Commandline.translateCommandline(line).drop(1).iterator() while (iterator.hasNext()) { val option = iterator.next() when { option == "--nocomp" -> opt["nocomp"] = null option.startsWith("--") -> opt[option.substring(2)] = iterator.next() else -> throw IllegalArgumentException("Unknown kcptun parameter: $option") } } } catch (exc: Exception) { Logs.w(exc) } opt } else PluginOptions(line) }) fun getOptions( id: String = selected, defaultConfig: () -> String? = { PluginManager.fetchPlugins(true).lookup[id]?.defaultConfig } ) = if (id.isEmpty()) PluginOptions() else pluginsOptions[id] ?: PluginOptions(id, defaultConfig()) override fun toString(): String { val result = LinkedList() for ((id, opt) in pluginsOptions) if (id == this.selected) result.addFirst(opt) else result.addLast(opt) if (!pluginsOptions.contains(selected)) result.addFirst(getOptions()) return result.joinToString("\n") { it.toString(false) } } } ================================================ FILE: app/src/main/java/com/github/shadowsocks/plugin/PluginContract.kt ================================================ /****************************************************************************** * * * Copyright (C) 2021 by nekohasekai * * Copyright (C) 2021 by Max Lv * * Copyright (C) 2021 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 com.github.shadowsocks.plugin /** * The contract between the plugin provider and host. Contains definitions for the supported actions, extras, etc. * * This class is written in Java to keep Java interoperability. */ object PluginContract { /** * ContentProvider Action: Used for NativePluginProvider. * * Constant Value: "com.github.shadowsocks.plugin.ACTION_NATIVE_PLUGIN" */ const val ACTION_NATIVE_PLUGIN = "com.github.shadowsocks.plugin.ACTION_NATIVE_PLUGIN" /** * Activity Action: Used for ConfigurationActivity. * * Constant Value: "com.github.shadowsocks.plugin.ACTION_CONFIGURE" */ const val ACTION_CONFIGURE = "com.github.shadowsocks.plugin.ACTION_CONFIGURE" /** * Activity Action: Used for HelpActivity or HelpCallback. * * Constant Value: "com.github.shadowsocks.plugin.ACTION_HELP" */ const val ACTION_HELP = "com.github.shadowsocks.plugin.ACTION_HELP" /** * The lookup key for a string that provides the plugin entry binary. * * Example: "/data/data/com.github.shadowsocks.plugin.obfs_local/lib/libobfs-local.so" * * Constant Value: "com.github.shadowsocks.plugin.EXTRA_ENTRY" */ const val EXTRA_ENTRY = "com.github.shadowsocks.plugin.EXTRA_ENTRY" /** * The lookup key for a string that provides the options as a string. * * Example: "obfs=http;obfs-host=www.baidu.com" * * Constant Value: "com.github.shadowsocks.plugin.EXTRA_OPTIONS" */ const val EXTRA_OPTIONS = "com.github.shadowsocks.plugin.EXTRA_OPTIONS" /** * The lookup key for a CharSequence that provides user relevant help message. * * Example: "obfs=|tls> Enable obfuscating: HTTP or TLS (Experimental). * obfs-host= Hostname for obfuscating (Experimental)." * * Constant Value: "com.github.shadowsocks.plugin.EXTRA_HELP_MESSAGE" */ const val EXTRA_HELP_MESSAGE = "com.github.shadowsocks.plugin.EXTRA_HELP_MESSAGE" /** * The metadata key to retrieve plugin version. Required for plugin applications. * * Constant Value: "com.github.shadowsocks.plugin.version" */ const val METADATA_KEY_VERSION = "com.github.shadowsocks.plugin.version" /** * The metadata key to retrieve plugin id. Required for plugins. * * Constant Value: "com.github.shadowsocks.plugin.id" */ const val METADATA_KEY_ID = "com.github.shadowsocks.plugin.id" /** * The metadata key to retrieve plugin id aliases. * Can be a string (representing one alias) or a resource to a string or string array. * * Constant Value: "com.github.shadowsocks.plugin.id.aliases" */ const val METADATA_KEY_ID_ALIASES = "com.github.shadowsocks.plugin.id.aliases" /** * The metadata key to retrieve default configuration. Default value is empty. * * Constant Value: "com.github.shadowsocks.plugin.default_config" */ const val METADATA_KEY_DEFAULT_CONFIG = "com.github.shadowsocks.plugin.default_config" /** * The metadata key to retrieve executable path to your native binary. * This path should be relative to your application's nativeLibraryDir. * * If this is set, the host app will prefer this value and (probably) not launch your app at all (aka faster mode). * In order for this to work, plugin app is encouraged to have the following in its AndroidManifest.xml: * - android:installLocation="internalOnly" for * - android:extractNativeLibs="true" for * * Do not use this if you plan to do some setup work before giving away your binary path, * or your native binary is not at a fixed location relative to your application's nativeLibraryDir. * * Since plugin lib: 1.3.0 * * Constant Value: "com.github.shadowsocks.plugin.executable_path" */ const val METADATA_KEY_EXECUTABLE_PATH = "com.github.shadowsocks.plugin.executable_path" const val METHOD_GET_EXECUTABLE = "shadowsocks:getExecutable" /** ConfigurationActivity result: fallback to manual edit mode. */ const val RESULT_FALLBACK = 1 /** * Relative to the file to be copied. This column is required. * * Example: "kcptun", "doc/help.txt" * * Type: String */ const val COLUMN_PATH = "path" /** * File mode bits. Default value is 644 in octal. * * Example: 0b110100100 (for 755 in octal) * * Type: Int or String (deprecated) */ const val COLUMN_MODE = "mode" /** * The scheme for general plugin actions. */ const val SCHEME = "plugin" /** * The authority for general plugin actions. */ const val AUTHORITY = "com.github.shadowsocks" } ================================================ FILE: app/src/main/java/com/github/shadowsocks/plugin/PluginList.kt ================================================ /****************************************************************************** * * * Copyright (C) 2021 by nekohasekai * * Copyright (C) 2021 by Max Lv * * Copyright (C) 2021 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 com.github.shadowsocks.plugin import android.content.Intent import android.content.pm.PackageManager import android.widget.Toast import io.nekohasekai.sagernet.SagerNet class PluginList(skipInternal: Boolean) : ArrayList() { init { add(NoPlugin) if (!skipInternal) { add(InternalPlugin.SIMPLE_OBFS) add(InternalPlugin.V2RAY_PLUGIN) } addAll(SagerNet.application.packageManager.queryIntentContentProviders( Intent(PluginContract.ACTION_NATIVE_PLUGIN), PackageManager.GET_META_DATA) .filter { it.providerInfo.exported }.map { NativePlugin(it) }) } val lookup = mutableMapOf().apply { for (plugin in this@PluginList.toList()) { fun check(old: Plugin?) { if (old != null && old != plugin) { this@PluginList.remove(old) } // skip check /*if (old != null && old !== plugin) { val packages = this@PluginList.filter { it.id == plugin.id }.joinToString { it.packageName } val message = "Conflicting plugins found from: $packages" Toast.makeText(SagerNet.application, message, Toast.LENGTH_LONG).show() throw IllegalStateException(message) }*/ } check(put(plugin.id, plugin)) for (alias in plugin.idAliases) check(put(alias, plugin)) } } } ================================================ FILE: app/src/main/java/com/github/shadowsocks/plugin/PluginManager.kt ================================================ /****************************************************************************** * * * Copyright (C) 2021 by nekohasekai * * Copyright (C) 2021 by Max Lv * * Copyright (C) 2021 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 com.github.shadowsocks.plugin import android.annotation.SuppressLint import android.content.BroadcastReceiver import android.content.ContentResolver import android.content.Intent import android.content.pm.ComponentInfo import android.content.pm.PackageManager import android.content.pm.ProviderInfo import android.content.pm.Signature import android.database.Cursor import android.net.Uri import android.os.Build import android.system.Os import android.util.Base64 import android.widget.Toast import androidx.core.os.bundleOf import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.SagerNet import io.nekohasekai.sagernet.bg.BaseService import io.nekohasekai.sagernet.ktx.Logs import io.nekohasekai.sagernet.ktx.listenForPackageChanges import io.nekohasekai.sagernet.ktx.signaturesCompat 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) } /** * Trusted signatures by the app. Third-party fork should add their public key to their fork if the developer wishes * to publish or has published plugins for this app. You can obtain your public key by executing: * * $ keytool -export -alias key-alias -keystore /path/to/keystore.jks -rfc * * If you don't plan to publish any plugin but is developing/has developed some, it's not necessary to add your * public key yet since it will also automatically trust packages signed by the same signatures, e.g. debug keys. */ val trustedSignatures by lazy { SagerNet.packageInfo.signaturesCompat.toSet() + Signature(Base64.decode( // @Mygod """ |MIIDWzCCAkOgAwIBAgIEUzfv8DANBgkqhkiG9w0BAQsFADBdMQswCQYDVQQGEwJD |TjEOMAwGA1UECBMFTXlnb2QxDjAMBgNVBAcTBU15Z29kMQ4wDAYDVQQKEwVNeWdv |ZDEOMAwGA1UECxMFTXlnb2QxDjAMBgNVBAMTBU15Z29kMCAXDTE0MDUwMjA5MjQx |OVoYDzMwMTMwOTAyMDkyNDE5WjBdMQswCQYDVQQGEwJDTjEOMAwGA1UECBMFTXln |b2QxDjAMBgNVBAcTBU15Z29kMQ4wDAYDVQQKEwVNeWdvZDEOMAwGA1UECxMFTXln |b2QxDjAMBgNVBAMTBU15Z29kMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKC |AQEAjm5ikHoP3w6zavvZU5bRo6Birz41JL/nZidpdww21q/G9APA+IiJMUeeocy0 |L7/QY8MQZABVwNq79LXYWJBcmmFXM9xBPgDqQP4uh9JsvazCI9bvDiMn92mz9HiS |Sg9V4KGg0AcY0r230KIFo7hz+2QBp1gwAAE97myBfA3pi3IzJM2kWsh4LWkKQMfL |M6KDhpb4mdDQnHlgi4JWe3SYbLtpB6whnTqjHaOzvyiLspx1tmrb0KVxssry9KoX |YQzl56scfE/QJX0jJ5qYmNAYRCb4PibMuNSGB2NObDabSOMAdT4JLueOcHZ/x9tw |agGQ9UdymVZYzf8uqc+29ppKdQIDAQABoyEwHzAdBgNVHQ4EFgQUBK4uJ0cqmnho |6I72VmOVQMvVCXowDQYJKoZIhvcNAQELBQADggEBABZQ3yNESQdgNJg+NRIcpF9l |YSKZvrBZ51gyrC7/2ZKMpRIyXruUOIrjuTR5eaONs1E4HI/uA3xG1eeW2pjPxDnO |zgM4t7EPH6QbzibihoHw1MAB/mzECzY8r11PBhDQlst0a2hp+zUNR8CLbpmPPqTY |RSo6EooQ7+NBejOXysqIF1q0BJs8Y5s/CaTOmgbL7uPCkzArB6SS/hzXgDk5gw6v |wkGeOtzcj1DlbUTvt1s5GlnwBTGUmkbLx+YUje+n+IBgMbohLUDYBtUHylRVgMsc |1WS67kDqeJiiQZvrxvyW6CZZ/MIGI+uAkkj3DqJpaZirkwPgvpcOIrjZy0uFvQM= """, Base64.DEFAULT)) + Signature(Base64.decode( // @madeye """ |MIICQzCCAaygAwIBAgIETV9OhjANBgkqhkiG9w0BAQUFADBmMQswCQYDVQQGEwJjbjERMA8GA1UE |CBMIU2hhbmdoYWkxDzANBgNVBAcTBlB1ZG9uZzEUMBIGA1UEChMLRnVkYW4gVW5pdi4xDDAKBgNV |BAsTA1BQSTEPMA0GA1UEAxMGTWF4IEx2MB4XDTExMDIxOTA1MDA1NFoXDTM2MDIxMzA1MDA1NFow |ZjELMAkGA1UEBhMCY24xETAPBgNVBAgTCFNoYW5naGFpMQ8wDQYDVQQHEwZQdWRvbmcxFDASBgNV |BAoTC0Z1ZGFuIFVuaXYuMQwwCgYDVQQLEwNQUEkxDzANBgNVBAMTBk1heCBMdjCBnzANBgkqhkiG |9w0BAQEFAAOBjQAwgYkCgYEAq6lA8LqdeEI+es9SDX85aIcx8LoL3cc//iRRi+2mFIWvzvZ+bLKr |4Wd0rhu/iU7OeMm2GvySFyw/GdMh1bqh5nNPLiRxAlZxpaZxLOdRcxuvh5Nc5yzjM+QBv8ECmuvu |AOvvT3UDmA0AMQjZqSCmxWIxc/cClZ/0DubreBo2st0CAwEAATANBgkqhkiG9w0BAQUFAAOBgQAQ |Iqonxpwk2ay+Dm5RhFfZyG9SatM/JNFx2OdErU16WzuK1ItotXGVJaxCZv3u/tTwM5aaMACGED5n |AvHaDGCWynY74oDAopM4liF/yLe1wmZDu6Zo/7fXrH+T03LBgj2fcIkUfN1AA4dvnBo8XWAm9VrI |1iNuLIssdhDz3IL9Yg== """, Base64.DEFAULT)) } private var receiver: BroadcastReceiver? = null private var cachedPlugins: PluginList? = null private var cachedPluginsSkipInternal: PluginList? = null fun fetchPlugins(skipInternal: Boolean) = synchronized(this) { if (receiver == null) receiver = SagerNet.application.listenForPackageChanges { synchronized(this) { receiver = null cachedPlugins = null cachedPluginsSkipInternal = null } } if (skipInternal) { if (cachedPlugins == null) cachedPlugins = PluginList(skipInternal) cachedPlugins!! } else { if (cachedPluginsSkipInternal == null) cachedPluginsSkipInternal = PluginList(skipInternal) cachedPluginsSkipInternal!! } } private fun buildUri(id: String) = Uri.Builder() .scheme(PluginContract.SCHEME) .authority(PluginContract.AUTHORITY) .path("/$id") .build() fun buildIntent(id: String, action: String): Intent = Intent(action, buildUri(id)) data class InitResult( val path: String, val options: PluginOptions, val isV2: Boolean = false, ) // the following parts are meant to be used by :bg @Throws(Throwable::class) fun init(configuration: PluginConfiguration): InitResult? { if (configuration.selected.isEmpty()) return null var throwable: Throwable? = null try { val result = initNative(configuration) if (result != null) return result } catch (t: Throwable) { if (throwable == null) throwable = t else Logs.w(t) } // add other plugin types here throw throwable ?: PluginNotFoundException(configuration.selected) } private fun initNative(configuration: PluginConfiguration): InitResult? { 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 providers = SagerNet.application.packageManager.queryIntentContentProviders( Intent(PluginContract.ACTION_NATIVE_PLUGIN, buildUri(configuration.selected)), flags) .filter { it.providerInfo.exported } if (providers.isEmpty()) return null if (providers.size > 1) { val message = "Conflicting plugins found from: ${providers.joinToString { it.providerInfo.packageName }}" Toast.makeText(SagerNet.application, message, Toast.LENGTH_LONG).show() throw IllegalStateException(message) } val provider = providers.single().providerInfo val options = configuration.getOptions { provider.loadString(PluginContract.METADATA_KEY_DEFAULT_CONFIG) } val isV2 = provider.applicationInfo.metaData?.getString(PluginContract.METADATA_KEY_VERSION) ?.substringBefore('.')?.toIntOrNull() ?: 0 >= 2 var failure: Throwable? = null try { initNativeFaster(provider)?.also { return InitResult(it, options, isV2) } } catch (t: Throwable) { Logs.w("Initializing native plugin faster mode failed") failure = t } val uri = Uri.Builder().apply { scheme(ContentResolver.SCHEME_CONTENT) authority(provider.authority) }.build() try { return initNativeFast(SagerNet.application.contentResolver, options, uri)?.let { InitResult(it, options, isV2) } } catch (t: Throwable) { Logs.w("Initializing native plugin fast mode failed") failure?.also { t.addSuppressed(it) } failure = t } try { return initNativeSlow(SagerNet.application.contentResolver, options, uri)?.let { InitResult(it, options, isV2) } } catch (t: Throwable) { failure?.also { t.addSuppressed(it) } throw t } } private fun initNativeFaster(provider: ProviderInfo): String? { return provider.loadString(PluginContract.METADATA_KEY_EXECUTABLE_PATH)?.let { relativePath -> File(provider.applicationInfo.nativeLibraryDir).resolve(relativePath).apply { check(canExecute()) }.absolutePath } } private fun initNativeFast(cr: ContentResolver, options: PluginOptions, uri: Uri): String? { return cr.call(uri, PluginContract.METHOD_GET_EXECUTABLE, null, bundleOf(PluginContract.EXTRA_OPTIONS to options.id))?.getString(PluginContract.EXTRA_ENTRY)?.also { check(File(it).canExecute()) } } @SuppressLint("Recycle") private fun initNativeSlow(cr: ContentResolver, options: PluginOptions, uri: Uri): String? { var initialized = false fun entryNotFound(): Nothing = throw IndexOutOfBoundsException("Plugin entry binary not found") val pluginDir = File(SagerNet.deviceStorage.noBackupFilesDir, "plugin") (cr.query(uri, arrayOf(PluginContract.COLUMN_PATH, PluginContract.COLUMN_MODE), null, null, null) ?: return null).use { cursor -> if (!cursor.moveToFirst()) entryNotFound() pluginDir.deleteRecursively() if (!pluginDir.mkdirs()) throw FileNotFoundException("Unable to create plugin directory") val pluginDirPath = pluginDir.absolutePath + '/' do { val path = cursor.getString(0) val file = File(pluginDir, path) check(file.absolutePath.startsWith(pluginDirPath)) cr.openInputStream(uri.buildUpon().path(path).build())!!.use { inStream -> file.outputStream().use { outStream -> inStream.copyTo(outStream) } } Os.chmod(file.absolutePath, when (cursor.getType(1)) { Cursor.FIELD_TYPE_INTEGER -> cursor.getInt(1) Cursor.FIELD_TYPE_STRING -> cursor.getString(1).toInt(8) else -> throw IllegalArgumentException("File mode should be of type int") }) if (path == options.id) initialized = true } while (cursor.moveToNext()) } if (!initialized) entryNotFound() return File(pluginDir, options.id).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/com/github/shadowsocks/plugin/PluginOptions.kt ================================================ /****************************************************************************** * * * Copyright (C) 2021 by nekohasekai * * Copyright (C) 2021 by Max Lv * * Copyright (C) 2021 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 com.github.shadowsocks.plugin import java.util.* /** * Helper class for processing plugin options. * * Based on: https://github.com/apache/ant/blob/588ce1f/src/main/org/apache/tools/ant/types/Commandline.java */ class PluginOptions : HashMap { var id = "" constructor() : super() constructor(initialCapacity: Int) : super(initialCapacity) constructor(initialCapacity: Int, loadFactor: Float) : super(initialCapacity, loadFactor) private constructor(options: String?, parseId: Boolean) : this() { @Suppress("NAME_SHADOWING") var parseId = parseId if (options.isNullOrEmpty()) return check(options.all { !it.isISOControl() }) { "No control characters allowed." } val tokenizer = StringTokenizer("$options;", "\\=;", true) val current = StringBuilder() var key: String? = null while (tokenizer.hasMoreTokens()) when (val nextToken = tokenizer.nextToken()) { "\\" -> current.append(tokenizer.nextToken()) "=" -> if (key == null) { key = current.toString() current.setLength(0) } else current.append(nextToken) ";" -> { if (key != null) { put(key, current.toString()) key = null } else if (current.isNotEmpty()) { if (parseId) id = current.toString() else put(current.toString(), null) } current.setLength(0) parseId = false } else -> current.append(nextToken) } } constructor(options: String?) : this(options, true) constructor(id: String, options: String?) : this(options, false) { this.id = id } /** * Put but if value is null or default, the entry is deleted. * * @return Old value before put. */ fun putWithDefault(key: String, value: String?, default: String? = null) = if (value == null || value == default) remove(key) else put(key, value) private fun append(result: StringBuilder, str: String) = str.indices.map { str[it] }.forEach { when (it) { '\\', '=', ';' -> { result.append('\\') // intentionally no break result.append(it) } else -> result.append(it) } } fun toString(trimId: Boolean): String { val result = StringBuilder() if (!trimId) if (id.isEmpty()) return "" else append(result, id) for ((key, value) in entries) { if (result.isNotEmpty()) result.append(';') append(result, key) if (value != null) { result.append('=') append(result, value) } } return result.toString() } override fun toString(): String = toString(true) override fun equals(other: Any?): Boolean { if (this === other) return true return javaClass == other?.javaClass && super.equals(other) && id == (other as PluginOptions).id } override fun hashCode(): Int = Objects.hash(super.hashCode(), id) } ================================================ FILE: app/src/main/java/com/github/shadowsocks/plugin/ResolvedPlugin.kt ================================================ /****************************************************************************** * * * Copyright (C) 2021 by nekohasekai * * Copyright (C) 2021 by Max Lv * * Copyright (C) 2021 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 com.github.shadowsocks.plugin import android.content.pm.ComponentInfo import android.content.pm.ResolveInfo import android.graphics.drawable.Drawable import android.os.Build import com.github.shadowsocks.plugin.PluginManager.loadString import io.nekohasekai.sagernet.SagerNet import io.nekohasekai.sagernet.ktx.signaturesCompat abstract class ResolvedPlugin(protected val resolveInfo: ResolveInfo) : Plugin() { protected abstract val componentInfo: ComponentInfo override val id by lazy { componentInfo.loadString(PluginContract.METADATA_KEY_ID)!! } override val idAliases: Array by lazy { when (val value = componentInfo.metaData.get(PluginContract.METADATA_KEY_ID_ALIASES)) { is String -> arrayOf(value) is Int -> SagerNet.application.packageManager.getResourcesForApplication(componentInfo.applicationInfo) .run { when (getResourceTypeName(value)) { "string" -> arrayOf(getString(value)) else -> getStringArray(value) } } null -> emptyArray() else -> error("unknown type for plugin meta-data idAliases") } } override val label: CharSequence get() = resolveInfo.loadLabel(SagerNet.application.packageManager) override val icon: Drawable get() = resolveInfo.loadIcon(SagerNet.application.packageManager) override val defaultConfig by lazy { componentInfo.loadString(PluginContract.METADATA_KEY_DEFAULT_CONFIG) } override val packageName: String get() = componentInfo.packageName override val trusted by lazy { SagerNet.application.getPackageInfo(packageName).signaturesCompat.any(PluginManager.trustedSignatures::contains) } override val directBootAware get() = Build.VERSION.SDK_INT < 24 || componentInfo.directBootAware } ================================================ FILE: app/src/main/java/com/github/shadowsocks/plugin/Utils.kt ================================================ /****************************************************************************** * * * Copyright (C) 2021 by nekohasekai * * Copyright (C) 2021 by Max Lv * * Copyright (C) 2021 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 . * * * ******************************************************************************/ @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 ================================================ /****************************************************************************** * * * Copyright (C) 2021 by nekohasekai * * Copyright (C) 2021 by Max Lv * * Copyright (C) 2021 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 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/com/github/shadowsocks/preference/PluginConfigurationDialogFragment.kt ================================================ /****************************************************************************** * * * Copyright (C) 2021 by nekohasekai * * Copyright (C) 2021 by Max Lv * * Copyright (C) 2021 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 com.github.shadowsocks.preference import android.view.View import android.widget.EditText import androidx.appcompat.app.AlertDialog import androidx.core.os.bundleOf import androidx.preference.EditTextPreferenceDialogFragmentCompat import androidx.preference.PreferenceDialogFragmentCompat import com.github.shadowsocks.plugin.PluginContract import com.github.shadowsocks.plugin.PluginManager import io.nekohasekai.sagernet.ktx.app import io.nekohasekai.sagernet.ui.profile.ShadowsocksSettingsActivity import io.nekohasekai.sagernet.ui.profile.TrojanGoSettingsActivity class PluginConfigurationDialogFragment : EditTextPreferenceDialogFragmentCompat() { companion object { private const val PLUGIN_ID_FRAGMENT_TAG = "com.github.shadowsocks.preference.PluginConfigurationDialogFragment.PLUGIN_ID" } fun setArg(key: String, plugin: String) { arguments = bundleOf(PreferenceDialogFragmentCompat.ARG_KEY to key, PLUGIN_ID_FRAGMENT_TAG to plugin) } private lateinit var editText: EditText override fun onPrepareDialogBuilder(builder: AlertDialog.Builder) { super.onPrepareDialogBuilder(builder) val intent = PluginManager.buildIntent(arguments?.getString(PLUGIN_ID_FRAGMENT_TAG)!!, PluginContract.ACTION_HELP) val activity = activity if (activity is ShadowsocksSettingsActivity) { if (intent.resolveActivity(app.packageManager) != null) builder.setNeutralButton("?") { _, _ -> activity.pluginHelp.launch(intent.putExtra(PluginContract.EXTRA_OPTIONS, editText.text.toString())) } } else { activity as TrojanGoSettingsActivity if (intent.resolveActivity(app.packageManager) != null) builder.setNeutralButton( "?") { _, _ -> activity.pluginHelp.launch(intent.putExtra(PluginContract.EXTRA_OPTIONS, editText.text.toString())) } } } override fun onBindDialogView(view: View) { super.onBindDialogView(view) editText = view.findViewById(android.R.id.edit) } } ================================================ FILE: app/src/main/java/com/github/shadowsocks/preference/PluginPreference.kt ================================================ /****************************************************************************** * * * Copyright (C) 2021 by nekohasekai * * Copyright (C) 2021 by Max Lv * * Copyright (C) 2021 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 com.github.shadowsocks.preference import android.content.Context import android.graphics.drawable.Drawable import android.util.AttributeSet import androidx.preference.ListPreference import com.github.shadowsocks.plugin.PluginList import com.github.shadowsocks.plugin.PluginManager import io.nekohasekai.sagernet.R class PluginPreference(context: Context, attrs: AttributeSet? = null) : ListPreference( context, attrs ) { companion object FallbackProvider : SummaryProvider { override fun provideSummary(preference: PluginPreference) = preference.selectedEntry?.label ?: preference.unknownValueSummary.format(preference.value) } lateinit var plugins: PluginList val selectedEntry get() = plugins.lookup[value] private val entryIcon: Drawable? get() = selectedEntry?.icon private val unknownValueSummary = context.getString(R.string.plugin_unknown) private var listener: OnPreferenceChangeListener? = null override fun getOnPreferenceChangeListener(): OnPreferenceChangeListener? = listener override fun setOnPreferenceChangeListener(listener: OnPreferenceChangeListener?) { this.listener = listener } init { super.setOnPreferenceChangeListener { preference, newValue -> val listener = listener if (listener == null || listener.onPreferenceChange(preference, newValue)) { value = newValue.toString() icon = entryIcon true } else false } } fun init(skipInternal: Boolean = false) { plugins = PluginManager.fetchPlugins(skipInternal) entryValues = plugins.lookup.map { it.key }.toTypedArray() icon = entryIcon summaryProvider = FallbackProvider } override fun onSetInitialValue(defaultValue: Any?) { super.onSetInitialValue(defaultValue) init() } } ================================================ FILE: app/src/main/java/com/github/shadowsocks/preference/PluginPreferenceDialogFragment.kt ================================================ /****************************************************************************** * * * Copyright (C) 2021 by nekohasekai * * Copyright (C) 2021 by Max Lv * * Copyright (C) 2021 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 com.github.shadowsocks.preference import android.app.Dialog import android.content.ActivityNotFoundException import android.content.Intent import android.graphics.Typeface import android.net.Uri import android.os.Bundle import android.provider.Settings import android.view.View import android.view.ViewGroup import androidx.appcompat.widget.TooltipCompat import androidx.core.os.bundleOf import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.fragment.app.setFragmentResult import androidx.preference.PreferenceDialogFragmentCompat import androidx.recyclerview.widget.DefaultItemAnimator import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.github.shadowsocks.plugin.Plugin import com.google.android.material.bottomsheet.BottomSheetDialog import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.databinding.LayoutIconListItem2Binding class PluginPreferenceDialogFragment : PreferenceDialogFragmentCompat() { companion object { const val KEY_SELECTED_ID = "id" } private inner class IconListViewHolder(val dialog: BottomSheetDialog, binding: LayoutIconListItem2Binding) : RecyclerView.ViewHolder(binding.root), View.OnClickListener, View.OnLongClickListener { private lateinit var plugin: Plugin private val text1 = binding.text1 private val text2 = binding.text2 private val icon = binding.icon private val unlock = binding.unlock.apply { TooltipCompat.setTooltipText(this, getText(R.string.plugin_auto_connect_unlock_only)) } init { binding.root.setOnClickListener(this) binding.root.setOnLongClickListener(this) } fun bind(plugin: Plugin, selected: Boolean = false) { this.plugin = plugin val label = plugin.label text1.text = label text2.text = plugin.id val typeface = if (selected) Typeface.BOLD else Typeface.NORMAL text1.setTypeface(null, typeface) text2.setTypeface(null, typeface) text2.isVisible = plugin.id.isNotEmpty() && label != plugin.id icon.setImageDrawable(plugin.icon) unlock.isGone = plugin.directBootAware || !DataStore.persistAcrossReboot } override fun onClick(v: View?) { clicked = plugin dialog.dismiss() } override fun onLongClick(v: View?) = try { startActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.Builder() .scheme("package").opaquePart(plugin.packageName).build())) true } catch (_: ActivityNotFoundException) { false } } private inner class IconListAdapter(private val dialog: BottomSheetDialog) : RecyclerView.Adapter() { override fun getItemCount(): Int = preference.plugins.size override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = IconListViewHolder(dialog, LayoutIconListItem2Binding.inflate(layoutInflater, parent, false)) override fun onBindViewHolder(holder: IconListViewHolder, position: Int) { if (selected < 0) holder.bind(preference.plugins[position]) else when (position) { 0 -> holder.bind(preference.selectedEntry!!, true) in selected + 1..Int.MAX_VALUE -> holder.bind(preference.plugins[position]) else -> holder.bind(preference.plugins[position - 1]) } } } fun setArg(key: String) { arguments = bundleOf(ARG_KEY to key) } private val preference by lazy { getPreference() as PluginPreference } private val selected by lazy { preference.plugins.indexOf(preference.selectedEntry) } private var clicked: Plugin? = null override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { val activity = requireActivity() val dialog = BottomSheetDialog(activity, theme) val recycler = RecyclerView(activity) val padding = resources.getDimensionPixelOffset(R.dimen.bottom_sheet_padding) recycler.setPadding(0, padding, 0, padding) recycler.setHasFixedSize(true) recycler.layoutManager = LinearLayoutManager(activity) recycler.itemAnimator = DefaultItemAnimator() recycler.adapter = IconListAdapter(dialog) recycler.layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) dialog.setContentView(recycler) return dialog } override fun onDialogClosed(positiveResult: Boolean) { val clicked = clicked if (clicked != null && clicked != preference.selectedEntry) { setFragmentResult(javaClass.name, bundleOf(KEY_SELECTED_ID to clicked.id)) } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/BootReceiver.kt ================================================ /****************************************************************************** * * * Copyright (C) 2021 by nekohasekai * * Copyright (C) 2021 by Max Lv * * Copyright (C) 2021 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.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 -> DataStore.directBootAware else -> Build.VERSION.SDK_INT < 24 || SagerNet.user.isUserUnlocked } && DataStore.currentProfile > 0 if (doStart) SagerNet.startService() } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/Constants.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 const val CONNECTION_TEST_URL = "https://api.v2fly.org/checkConnection.svgz" object Key { const val DB_PUBLIC = "configuration.db" const val DB_PROFILE = "sager_net.db" const val DISABLE_AEAD = "V2RAY_VMESS_AEAD_DISABLED" const val PERSIST_ACROSS_REBOOT = "isAutoConnect" const val DIRECT_BOOT_AWARE = "directBootAware" 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 REMOTE_DNS = "remoteDns" const val DIRECT_DNS = "directDns" const val ENABLE_DNS_ROUTING = "enableDnsRouting" const val ENABLE_FAKEDNS = "enableFakeDns" const val DNS_HOSTS = "dnsHosts" 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 DOMAIN_STRATEGY = "domainStrategy" const val TRAFFIC_SNIFFING = "trafficSniffing" const val DESTINATION_OVERRIDE = "destinationOverride" const val RESOLVE_DESTINATION = "resolveDestination" const val BYPASS_LAN = "bypassLan" const val BYPASS_LAN_IN_CORE_ONLY = "bypassLanInCoreOnly" const val SOCKS_PORT = "socksPort" const val ALLOW_ACCESS = "allowAccess" const val SPEED_INTERVAL = "speedInterval" const val SHOW_DIRECT_SPEED = "showDirectSpeed" const val LOCAL_DNS_PORT = "portLocalDns" const val REQUIRE_HTTP = "requireHttp" const val APPEND_HTTP_PROXY = "appendHttpProxy" const val HTTP_PORT = "httpPort" const val REQUIRE_TRANSPROXY = "requireTransproxy" const val TRANSPROXY_MODE = "transproxyMode" const val TRANSPROXY_PORT = "transproxyPort" const val CONNECTION_TEST_URL = "connectionTestURL" const val ENABLE_MUX = "enableMux" const val ENABLE_MUX_FOR_ALL = "enableMuxForAll" const val MUX_CONCURRENCY = "muxConcurrency" const val SHOW_STOP_BUTTON = "showStopButton" const val SECURITY_ADVISORY = "securityAdvisory" const val TCP_KEEP_ALIVE_INTERVAL = "tcpKeepAliveInterval" const val RULES_PROVIDER = "rulesProvider" const val ENABLE_LOG = "enableLog" const val ALWAYS_SHOW_ADDRESS = "alwaysShowAddress" const val PROVIDER_TROJAN = "providerTrojan" const val PROVIDER_SS_AEAD = "providerShadowsocksAEAD" const val PROVIDER_SS_STREAM = "providerShadowsocksStream" const val UTLS_FINGERPRINT = "utlsFingerprint" const val TUN_IMPLEMENTATION = "tunImplementation" const val ENABLE_PCAP = "enablePcap" const val APP_TRAFFIC_STATISTICS = "appTrafficStatistics" 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_STARTED = "profileStarted" 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_PLUGIN = "serverPlugin" const val SERVER_PLUGIN_CONFIGURE = "serverPluginConfigure" const val SERVER_PASSWORD1 = "serverPassword1" const val SERVER_PROTOCOL = "serverProtocol" const val SERVER_PROTOCOL_PARAM = "serverProtocolParam" const val SERVER_OBFS = "serverObfs" const val SERVER_OBFS_PARAM = "serverObfsParam" const val SERVER_USER_ID = "serverUserId" const val SERVER_ALTER_ID = "serverAlterId" const val SERVER_SECURITY = "serverSecurity" const val SERVER_NETWORK = "serverNetwork" const val SERVER_HEADER = "serverHeader" const val SERVER_HOST = "serverHost" const val SERVER_PATH = "serverPath" const val SERVER_SNI = "serverSNI" const val SERVER_TLS = "serverTLS" const val SERVER_ENCRYPTION = "serverEncryption" const val SERVER_ALPN = "serverALPN" const val SERVER_CERTIFICATES = "serverCertificates" const val SERVER_FLOW = "serverFlow" const val SERVER_QUIC_SECURITY = "serverQuicSecurity" const val SERVER_WS_BROWSER_FORWARDING = "serverWsBrowserForwarding" const val SERVER_CONFIG = "serverConfig" const val SERVER_SECURITY_CATEGORY = "serverSecurityCategory" const val SERVER_WS_CATEGORY = "serverWsCategory" const val SERVER_SS_CATEGORY = "serverSsCategory" const val SERVER_HEADERS = "serverHeaders" const val SERVER_MULTI_MODE = "serverMultiMode" 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_VMESS_EXPERIMENTS_CATEGORY = "serverVMessExperimentsCategory" const val SERVER_VMESS_EXPERIMENTAL_AUTHENTICATED_LENGTH = "serverVMessExperimentalAuthenticatedLength" const val SERVER_VMESS_EXPERIMENTAL_NO_TERMINATION_SIGNAL = "serverVMessExperimentalNoTerminationSignal" const val SERVER_PRIVATE_KEY = "serverPrivateKey" const val SERVER_LOCAL_ADDRESS = "serverLocalAddress" const val BALANCER_TYPE = "balancerType" const val BALANCER_GROUP = "balancerGroup" const val BALANCER_STRATEGY = "balancerStrategy" 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_ATTRS = "routeAttrs" const val ROUTE_OUTBOUND = "routeOutbound" const val ROUTE_OUTBOUND_RULE = "routeOutboundRule" const val ROUTE_REVERSE = "routeReverse" const val ROUTE_REDIRECT = "routeRedirect" const val ROUTE_PACKAGES = "routePackages" const val ROUTE_FOREGROUND_STATUS = "routeForegroundStatus" const val GROUP_NAME = "groupName" const val GROUP_TYPE = "groupType" const val GROUP_ORDER = "groupOrder" const val GROUP_SUBSCRIPTION = "groupSubscription" const val SUBSCRIPTION_TYPE = "subscriptionType" const val SUBSCRIPTION_LINK = "subscriptionLink" const val SUBSCRIPTION_TOKEN = "subscriptionToken" const val SUBSCRIPTION_FORCE_RESOLVE = "subscriptionForceResolve" const val SUBSCRIPTION_DEDUPLICATION = "subscriptionDeduplication" const val SUBSCRIPTION_FORCE_VMESS_AEAD = "subscriptionForceVMessAEAD" 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" } object TunImplementation { const val GVISOR = 0 const val LWIP = 1 } object TrojanProvider { const val V2RAY = 0 const val TROJAN = 1 const val TROJAN_GO = 2 } object ShadowsocksProvider { const val V2RAY = 0 const val SHADOWSOCKS_RUST = 1 const val CLASH = 2 const val SHADOWSOCKS_LIBEV = 3 } object ShadowsocksStreamProvider { const val SHADOWSOCKS_RUST = 0 const val CLASH = 1 const val SHADOWSOCKS_LIBEV = 2 } object IPv6Mode { const val DISABLE = 0 const val ENABLE = 1 const val PREFER = 2 const val ONLY = 3 } object PacketStrategy { const val DIRECT = 0 const val DROP = 1 const val REPLY = 2 } object GroupType { const val BASIC = 0 const val SUBSCRIPTION = 1 } object SubscriptionType { const val RAW = 0 const val OOCv1 = 1 const val SIP008 = 2 } object ExtraType { const val NONE = 0 const val OOCv1 = 1 const val SIP008 = 2 } object GroupOrder { const val ORIGIN = 0 const val BY_NAME = 1 const val BY_DELAY = 2 } object AppStatus { const val FOREGROUND = "foreground" const val BACKGROUND = "background" } 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 ABORT = "io.nekohasekai.sagernet.ABORT" const val EXTRA_PROFILE_ID = "io.nekohasekai.sagernet.EXTRA_PROFILE_ID" } ================================================ 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 @Suppress("DEPRECATION") class QuickToggleShortcut : Activity(), SagerConnection.Callback { private val connection = SagerConnection() 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 { connection.connect(this, this) if (Build.VERSION.SDK_INT >= 25) getSystemService()!!.reportShortcutUsed( "toggle") } } override fun onServiceDisconnected() { super.onServiceDisconnected() } override fun onServiceConnected(service: ISagerNetService) { val state = BaseService.State.values()[service.state] when { state.canStop -> SagerNet.stopService() 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/SagerNet.kt ================================================ /****************************************************************************** * * * Copyright (C) 2021 by nekohasekai * * Copyright (C) 2021 by Max Lv * * Copyright (C) 2021 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.annotation.SuppressLint import android.app.* import android.app.admin.DevicePolicyManager import android.content.ClipData import android.content.ClipboardManager import android.content.Context import android.content.Intent import android.content.pm.PackageInfo import android.content.pm.PackageManager import android.content.res.Configuration import android.net.ConnectivityManager import android.os.Build 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.bg.proto.UidDumper 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.checkMT import io.nekohasekai.sagernet.ktx.runOnDefaultDispatcher import io.nekohasekai.sagernet.ui.MainActivity import io.nekohasekai.sagernet.utils.CrashHandler import io.nekohasekai.sagernet.utils.DeviceStorageApp import io.nekohasekai.sagernet.utils.PackageCache import io.nekohasekai.sagernet.utils.Theme import kotlinx.coroutines.DEBUG_PROPERTY_NAME import kotlinx.coroutines.DEBUG_PROPERTY_VALUE_ON import libcore.Libcore import org.conscrypt.Conscrypt import java.security.Security import androidx.work.Configuration as WorkConfiguration class SagerNet : Application(), WorkConfiguration.Provider { override fun attachBaseContext(base: Context) { super.attachBaseContext(base) application = this } val externalAssets by lazy { getExternalFilesDir(null) ?: filesDir } override fun onCreate() { super.onCreate() System.setProperty(DEBUG_PROPERTY_NAME, DEBUG_PROPERTY_VALUE_ON) Thread.setDefaultUncaughtExceptionHandler(CrashHandler) DataStore.init() updateNotificationChannels() Seq.setContext(this) externalAssets.mkdirs() Libcore.initializeV2Ray( filesDir.absolutePath + "/", externalAssets.absolutePath + "/", "v2ray/" ) { DataStore.rulesProvider == 0 } //Libcore.setenv("v2ray.conf.geoloader", "memconservative") Libcore.setUidDumper(UidDumper) runOnDefaultDispatcher { PackageCache.register() checkMT() } Theme.apply(this) Theme.applyNightTheme() Security.insertProviderAt(Conscrypt.newProvider(), 1) if (BuildConfig.DEBUG) StrictMode.setVmPolicy( StrictMode.VmPolicy.Builder() .detectLeakedSqlLiteObjects() .detectLeakedClosableObjects() .detectLeakedRegistrationObjects() .penaltyLog() .build() ) } fun getPackageInfo(packageName: String) = packageManager.getPackageInfo( packageName, if (Build.VERSION.SDK_INT >= 28) PackageManager.GET_SIGNING_CERTIFICATES else @Suppress("DEPRECATION") PackageManager.GET_SIGNATURES )!! override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) updateNotificationChannels() } override fun getWorkManagerConfiguration(): WorkConfiguration { return WorkConfiguration.Builder() .setDefaultProcessName("${BuildConfig.APPLICATION_ID}:bg") .build() } @SuppressLint("InlinedApi") companion object { @Volatile var started = false lateinit var application: SagerNet val isTv by lazy { uiMode.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION } val deviceStorage by lazy { if (Build.VERSION.SDK_INT < 24) application else DeviceStorageApp(application) } 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 packageInfo: PackageInfo by lazy { application.getPackageInfo(application.packageName) } val directBootSupported by lazy { Build.VERSION.SDK_INT >= 24 && try { app.getSystemService()?.storageEncryptionStatus == DevicePolicyManager.ENCRYPTION_STATUS_ACTIVE_PER_USER } catch (_: RuntimeException) { false } } val currentProfile get() = SagerDatabase.proxyDao.getById(DataStore.selectedProxy) 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 ) ) ) } } 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)) } override fun onLowMemory() { super.onLowMemory() } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/aidl/AppStats.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.aidl import android.os.Parcelable import io.nekohasekai.sagernet.database.StatsEntity import kotlinx.parcelize.Parcelize @Parcelize data class AppStats( var packageName: String, var uid: Int, var tcpConnections: Int, var udpConnections: Int, var tcpConnectionsTotal: Int, var udpConnectionsTotal: Int, var uplink: Long, var downlink: Long, var uplinkTotal: Long, var downlinkTotal: Long, var deactivateAt: Int ) : Parcelable { operator fun plusAssign(stats: StatsEntity) { tcpConnectionsTotal += stats.tcpConnections udpConnectionsTotal += stats.udpConnections uplinkTotal += stats.uplink downlinkTotal += stats.downlink } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/aidl/AppStatsList.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.aidl import android.os.Parcelable import kotlinx.parcelize.Parcelize @Parcelize class AppStatsList( var data: List ) : Parcelable ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/aidl/TrafficStats.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.aidl import android.os.Parcelable import kotlinx.parcelize.Parcelize @Parcelize data class TrafficStats( // 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 { operator fun plus(other: TrafficStats) = TrafficStats( txRateProxy + other.txRateProxy, rxRateProxy + other.rxRateProxy, txRateDirect + other.txRateDirect, rxRateDirect + other.rxRateDirect, txTotal + other.txTotal, rxTotal + other.rxTotal) } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/bg/AbstractInstance.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.bg import java.io.Closeable interface AbstractInstance : Closeable { fun launch() } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/bg/BaseService.kt ================================================ /****************************************************************************** * * * Copyright (C) 2021 by nekohasekai * * Copyright (C) 2021 by Max Lv * * Copyright (C) 2021 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.bg import android.app.Service import android.content.Context import android.content.Intent import android.content.IntentFilter import android.os.Build import android.os.IBinder import android.os.RemoteCallbackList import android.os.RemoteException import cn.hutool.json.JSONException import io.nekohasekai.sagernet.Action import io.nekohasekai.sagernet.BootReceiver import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.aidl.AppStatsList import io.nekohasekai.sagernet.aidl.ISagerNetService import io.nekohasekai.sagernet.aidl.ISagerNetServiceCallback import io.nekohasekai.sagernet.aidl.TrafficStats import io.nekohasekai.sagernet.bg.proto.ProxyInstance import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.database.SagerDatabase import io.nekohasekai.sagernet.fmt.TAG_SOCKS import io.nekohasekai.sagernet.ktx.* import io.nekohasekai.sagernet.plugin.PluginManager import io.nekohasekai.sagernet.utils.PackageCache import kotlinx.coroutines.* import libcore.AppStats import libcore.Libcore import libcore.TrafficListener import java.net.UnknownHostException import com.github.shadowsocks.plugin.PluginManager as ShadowsocksPluginPluginManager import io.nekohasekai.sagernet.aidl.AppStats as AidlAppStats class BaseService { enum class State(val canStop: Boolean = false) { /** * Idle state is only used by UI and will never be returned by BaseService. */ Idle, Connecting(true), Connected(true), Stopping, Stopped, } interface ExpectedException class ExpectedExceptionWrapper(e: Exception) : Exception(e.localizedMessage, e), ExpectedException class Data internal constructor(private val service: Interface) { var state = State.Stopped var proxy: ProxyInstance? = null var notification: ServiceNotification? = null val closeReceiver = broadcastReceiver { _, intent -> when (intent.action) { Intent.ACTION_SHUTDOWN -> service.persistStats() Action.RELOAD -> service.forceLoad() else -> service.stopRunner(keepState = false) } } var closeReceiverRegistered = false val binder = Binder(this) var connectingJob: Job? = null fun changeState(s: State, msg: String? = null) { if (state == s && msg == null) return binder.stateChanged(s, msg) state = s } } class Binder(private var data: Data? = null) : ISagerNetService.Stub(), CoroutineScope, AutoCloseable, TrafficListener { private val callbacks = object : RemoteCallbackList() { override fun onCallbackDied(callback: ISagerNetServiceCallback?, cookie: Any?) { super.onCallbackDied(callback, cookie) stopListeningForBandwidth(callback ?: return) stopListeningForStats(callback) } } private val bandwidthListeners = mutableMapOf() // the binder is the real identifier private val statsListeners = mutableMapOf() // the binder is the real identifier override val coroutineContext = Dispatchers.Main.immediate + Job() private var looper: Job? = null private var statsLooper: Job? = null override fun getState(): Int = (data?.state ?: State.Idle).ordinal override fun getProfileName(): String = data?.proxy?.profile?.displayName() ?: "Idle" override fun registerCallback(cb: ISagerNetServiceCallback) { callbacks.register(cb) } fun broadcast(work: (ISagerNetServiceCallback) -> Unit) { val count = callbacks.beginBroadcast() try { repeat(count) { try { work(callbacks.getBroadcastItem(it)) } catch (_: RemoteException) { } catch (e: Exception) { } } } finally { callbacks.finishBroadcast() } } private suspend fun loop() { var lastQueryTime = 0L val showDirectSpeed = DataStore.showDirectSpeed while (true) { val delayMs = bandwidthListeners.values.minOrNull() delay(delayMs ?: return) if (delayMs == 0L) return val queryTime = System.currentTimeMillis() val sinceLastQueryInSeconds = (queryTime - lastQueryTime).toDouble() / 1000L val proxy = data?.proxy ?: continue lastQueryTime = queryTime val (statsOut, outs) = proxy.outboundStats() val stats = TrafficStats( (proxy.uplinkProxy / sinceLastQueryInSeconds).toLong(), (proxy.downlinkProxy / sinceLastQueryInSeconds).toLong(), if (showDirectSpeed) (proxy.uplinkDirect() / sinceLastQueryInSeconds).toLong() else 0L, if (showDirectSpeed) (proxy.downlinkDirect() / sinceLastQueryInSeconds).toLong() else 0L, statsOut.uplinkTotal, statsOut.downlinkTotal ) if (data?.state == State.Connected && bandwidthListeners.isNotEmpty()) { broadcast { item -> if (bandwidthListeners.contains(item.asBinder())) { item.trafficUpdated(proxy.profile.id, stats, true) outs.forEach { (profileId, stats) -> item.trafficUpdated( profileId, TrafficStats( txRateDirect = stats.uplinkTotal, rxTotal = stats.downlinkTotal ), false ) } } } } } } val appStats = ArrayList() override fun updateStats(t: AppStats) { appStats.add(t) } private suspend fun loopStats() { var lastQueryTime = 0L val tun = (data?.proxy?.service as? VpnService)?.getTun() ?: return if (!tun.trafficStatsEnabled) return while (true) { val delayMs = statsListeners.values.minOrNull() if (delayMs == 0L) return val queryTime = System.currentTimeMillis() val sinceLastQueryInSeconds = ((queryTime - lastQueryTime).toDouble() / 1000).toLong() lastQueryTime = queryTime appStats.clear() tun.readAppTraffics(this) val statsList = AppStatsList(appStats.map { val uid = if (it.uid >= 10000) it.uid else 1000 val packageName = if (uid != 1000) { PackageCache.uidMap[it.uid]?.iterator()?.next() ?: "android" } else { "android" } AidlAppStats( packageName, uid, it.tcpConn, it.udpConn, it.tcpConnTotal, it.udpConnTotal, it.uplink / sinceLastQueryInSeconds, it.downlink / sinceLastQueryInSeconds, it.uplinkTotal, it.downlinkTotal, it.deactivateAt ) }) if (data?.state == State.Connected && statsListeners.isNotEmpty()) { broadcast { item -> if (statsListeners.contains(item.asBinder())) { item.statsUpdated(statsList) } } } delay(delayMs ?: return) } } override fun startListeningForBandwidth( cb: ISagerNetServiceCallback, timeout: Long, ) { launch { if (bandwidthListeners.isEmpty() and (bandwidthListeners.put( cb.asBinder(), timeout ) == null) ) { check(looper == null) looper = launch { loop() } } if (data?.state != State.Connected) return@launch val data = data data?.proxy ?: return@launch val sum = TrafficStats() cb.trafficUpdated(0, sum, true) } } override fun stopListeningForBandwidth(cb: ISagerNetServiceCallback) { launch { if (bandwidthListeners.remove(cb.asBinder()) != null && bandwidthListeners.isEmpty()) { looper!!.cancel() looper = null } } } override fun unregisterCallback(cb: ISagerNetServiceCallback) { stopListeningForBandwidth(cb) // saves an RPC, and safer stopListeningForStats(cb) callbacks.unregister(cb) } override fun protect(fd: Int) { (data?.proxy?.service as VpnService?)?.protect(fd) } override fun urlTest(): Int { if (data?.proxy?.v2rayPoint == null) { error("core not started") } try { return Libcore.urlTestV2ray( data!!.proxy!!.v2rayPoint, TAG_SOCKS, DataStore.connectionTestURL, 5000 ) } catch (e: Exception) { var msg = e.readableMessage if (msg.lowercase().contains("timeout")) { msg = app.getString(R.string.connection_test_timeout) } else if (msg.lowercase().contains("refused")) { msg = app.getString(R.string.connection_test_refused) } error(msg) } } override fun startListeningForStats(cb: ISagerNetServiceCallback, timeout: Long) { launch { if (statsListeners.isEmpty() and (statsListeners.put( cb.asBinder(), timeout ) == null) ) { check(statsLooper == null) statsLooper = launch { loopStats() } } } } override fun stopListeningForStats(cb: ISagerNetServiceCallback) { launch { if (statsListeners.remove(cb.asBinder()) != null && statsListeners.isEmpty()) { statsLooper!!.cancel() statsLooper = null } } } override fun resetTrafficStats() { runOnDefaultDispatcher { SagerDatabase.statsDao.deleteAll() (data?.proxy?.service as? VpnService)?.getTun()?.resetAppTraffics() val empty = AppStatsList(emptyList()) broadcast { item -> if (statsListeners.contains(item.asBinder())) { item.statsUpdated(empty) } } } } fun stateChanged(s: State, msg: String?) = launch { val profileName = profileName broadcast { it.stateChanged(s.ordinal, profileName, msg) } } fun profilePersisted(ids: List) = launch { if (bandwidthListeners.isNotEmpty() && ids.isNotEmpty()) broadcast { item -> if (bandwidthListeners.contains(item.asBinder())) ids.forEach(item::profilePersisted) } } fun missingPlugin(pluginName: String) = launch { val profileName = profileName broadcast { it.missingPlugin(profileName, pluginName) } } override fun getTrafficStatsEnabled(): Boolean { return (data?.proxy?.service as? VpnService)?.getTun()?.trafficStatsEnabled ?: false } 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 forceLoad() { if (DataStore.selectedProxy == 0L) { stopRunner(false, (this as Context).getString(R.string.profile_empty)) } val s = data.state when { s == State.Stopped -> startRunner() s.canStop -> stopRunner(true) else -> Logs.w("Illegal state $s when invoking use") } } val isVpnService get() = 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() } fun stopRunner(restart: Boolean = false, msg: String? = null, keepState: Boolean = true) { 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.closeReceiver) data.closeReceiverRegistered = false } data.binder.profilePersisted(listOfNotNull(data.proxy).map { it.profile.id }) data.proxy = null } // change the state data.changeState(State.Stopped, msg) // stop the service if nothing has bound to it if (restart) startRunner() else { // BootReceiver.enabled = false stopSelf() } } } fun persistStats() { Logs.w(Exception()) data.proxy?.persistStats() (this as? VpnService)?.persistAppStats() } suspend fun preInit() {} fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { 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) { registerReceiver(data.closeReceiver, IntentFilter().apply { addAction(Action.RELOAD) addAction(Intent.ACTION_SHUTDOWN) addAction(Action.CLOSE) }, "$packageName.SERVICE", null) data.closeReceiverRegistered = true } data.notification = createNotification(profile.displayName()) data.changeState(State.Connecting) runOnMainDispatcher { try { Executable.killAll() // clean up old processes preInit() try { proxy.init() } catch (jsonEx: JSONException) { error(jsonEx.readableMessage.replace("cn.hutool.json.", "")) } proxy.processes = GuardedProcessPool { Logs.w(it) stopRunner(false, it.readableMessage) } DataStore.currentProfile = profile.id DataStore.startedProfile = profile.id startProcesses() data.changeState(State.Connected) for ((type, routeName) in proxy.config.alerts) { data.binder.broadcast { it.routeAlert(type, routeName) } } } 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) { Logs.d(e.readableMessage) data.binder.missingPlugin(e.plugin) stopRunner(false, null) } catch (e: ShadowsocksPluginPluginManager.PluginNotFoundException) { Logs.d(e.readableMessage) data.binder.missingPlugin("shadowsocks-" + e.plugin) stopRunner(false, null) } catch (exc: Throwable) { if (exc is ExpectedException) Logs.d(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/ClashBasedInstance.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.bg import libcore.ClashBasedInstance abstract class ClashBasedInstance : AbstractInstance { lateinit var instance: ClashBasedInstance abstract fun createInstance() override fun launch() { createInstance() instance.start() } override fun close() { if (::instance.isInitialized) instance.close() } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/bg/Executable.kt ================================================ /****************************************************************************** * * * Copyright (C) 2021 by nekohasekai * * Copyright (C) 2021 by Max Lv * * Copyright (C) 2021 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.bg import android.system.ErrnoException import android.system.Os import android.system.OsConstants import android.text.TextUtils import io.nekohasekai.sagernet.ktx.Logs import java.io.File import java.io.IOException object Executable { const val SS_LOCAL = "libsslocal.so" const val SS_LIBEV_LOCAL = "libss-local.so" private val EXECUTABLES = setOf( SS_LOCAL, SS_LIBEV_LOCAL, "libtrojan.so", "libtrojan-go.so", "libnaive.so", "libbrook.so", "libhysteria.so", "libpingtunnel.so", "librelaybaton.so", "libwg.so" ) fun killAll() { for (process in File("/proc").listFiles { _, name -> TextUtils.isDigitsOnly(name) } ?: 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)) try { Os.kill(process.name.toInt(), OsConstants.SIGKILL) Logs.w("SIGKILL ${exe.nameWithoutExtension} (${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/ExternalInstance.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.bg import io.nekohasekai.sagernet.bg.proto.V2RayInstance import io.nekohasekai.sagernet.database.ProxyEntity import io.nekohasekai.sagernet.fmt.buildCustomConfig import io.nekohasekai.sagernet.ktx.Logs class ExternalInstance( profile: ProxyEntity, val port: Int ) : V2RayInstance(profile) { override fun init() { super.init() Logs.d(config.config) pluginConfigs.forEach { (_, plugin) -> val (_, content) = plugin Logs.d(content) } } override fun buildConfig() { config = buildCustomConfig(profile, port) } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/bg/ForegroundDetectorService.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.bg import android.accessibilityservice.AccessibilityService import android.view.accessibility.AccessibilityEvent import android.view.inputmethod.InputMethodManager import io.nekohasekai.sagernet.ktx.Logs import io.nekohasekai.sagernet.utils.PackageCache import libcore.Libcore class ForegroundDetectorService : AccessibilityService() { class NotStartedException(val routeName: String) : IllegalStateException() val imeApps by lazy { (applicationContext.getSystemService( INPUT_METHOD_SERVICE ) as InputMethodManager).inputMethodList.map { it.packageName } } var fromIme = false override fun onCreate() { super.onCreate() Logs.i("Started") } override fun onAccessibilityEvent(event: AccessibilityEvent) { val packageName = event.packageName?.takeIf { it.isNotBlank() }?.toString() ?: return if (packageName == "com.android.systemui") return if (packageName in imeApps) { val uid = PackageCache[packageName] ?: return PackageCache.awaitLoadSync() Libcore.setForegroundImeUid(uid) fromIme = true Logs.d("Foreground IME changed to ${event.packageName}/${event.className}: uid $uid") return } PackageCache.awaitLoadSync() var uid = PackageCache[packageName] ?: -1 if (uid < 10000) { uid = 1000 } Libcore.setForegroundUid(uid) if (fromIme) { Libcore.setForegroundImeUid(0) fromIme = false Logs.d("Foreground IME changed to none") } Logs.d("Foreground changed to ${event.packageName}/${event.className}: uid $uid") } override fun onInterrupt() { Logs.i("Interrupted") Libcore.setForegroundUid(0) } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/bg/GuardedProcessPool.kt ================================================ /****************************************************************************** * * * Copyright (C) 2021 by nekohasekai * * Copyright (C) 2021 by Max Lv * * Copyright (C) 2021 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.bg import android.os.Build import android.os.SystemClock import android.system.ErrnoException import android.system.Os import android.system.OsConstants import android.util.Log 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 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.deviceStorage.noBackupFilesDir).apply { environment().putAll(env) }.start() } @DelicateCoroutinesApi suspend fun looper(onRestartCallback: (suspend () -> Unit)?) { var running = false val cmdName = File(cmd.first()).nameWithoutExtension val exitChannel = Channel() try { while (true) { thread(name = "stderr-$cmdName") { streamLogger(process.errorStream) { Log.e(cmdName, it) } } thread(name = "stdout-$cmdName") { streamLogger(process.inputStream) { Log.i(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() @MainThread fun start(cmd: List,env: Map = mapOf(), 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) } } } @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 ================================================ /****************************************************************************** * * * Copyright (C) 2021 by nekohasekai * * Copyright (C) 2021 by Max Lv * * Copyright (C) 2021 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.bg import android.app.Service import android.content.Intent 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 fun onBind(intent: Intent) = super.onBind(intent) override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int = super.onStartCommand(intent, flags, startId) override fun onDestroy() { super.onDestroy() data.binder.close() } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/bg/SagerConnection.kt ================================================ /****************************************************************************** * * * Copyright (C) 2021 by nekohasekai * * Copyright (C) 2021 by Max Lv * * Copyright (C) 2021 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.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.SagerNet import io.nekohasekai.sagernet.aidl.* import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.ktx.runOnMainDispatcher class SagerConnection(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 // Key.MODE_TRANS -> TransproxyService::class else -> throw UnknownError() }.java } interface Callback { fun stateChanged(state: BaseService.State, profileName: String?, msg: String?) fun trafficUpdated(profileId: Long, stats: TrafficStats, isCurrent: Boolean) {} fun statsUpdated(stats: List) {} fun observatoryResultsUpdated(groupId: Long) {} fun profilePersisted(profileId: Long) {} fun missingPlugin(profileName: String, pluginName: String) {} fun routeAlert(type: Int, routeName: 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?) { val s = BaseService.State.values()[state] SagerNet.started = s.canStop val callback = callback ?: return runOnMainDispatcher { callback.stateChanged(s, profileName, msg) } } override fun trafficUpdated(profileId: Long, stats: TrafficStats, isCurrent: Boolean) { val callback = callback ?: return runOnMainDispatcher { callback.trafficUpdated(profileId, stats, isCurrent) } } override fun profilePersisted(profileId: Long) { val callback = callback ?: return runOnMainDispatcher { callback.profilePersisted(profileId) } } override fun missingPlugin(profileName: String, pluginName: String) { val callback = callback ?: return runOnMainDispatcher { callback.missingPlugin(profileName, pluginName) } } override fun statsUpdated(statsList: AppStatsList) { val callback = callback ?: return callback.statsUpdated(statsList.data) } override fun routeAlert(type: Int, routeName: String) { val callback = callback ?: return runOnMainDispatcher { callback.routeAlert(type, routeName) } } override fun observatoryResultsUpdated(groupId: Long) { val callback = callback ?: return runOnMainDispatcher { callback.observatoryResultsUpdated(groupId) } } } private var binder: IBinder? = null var bandwidthTimeout = 0L set(value) { try { if (value > 0) service?.startListeningForBandwidth(serviceCallback, value) else service?.stopListeningForBandwidth(serviceCallback) } catch (_: RemoteException) { } field = value } var trafficTimeout = 0L set(value) { try { if (value > 0) service?.startListeningForStats(serviceCallback, value) else service?.stopListeningForStats(serviceCallback) } catch (_: RemoteException) { } field = value } var service: ISagerNetService? = null 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) callbackRegistered = true if (bandwidthTimeout > 0) service.startListeningForBandwidth( serviceCallback, bandwidthTimeout ) if (trafficTimeout > 0) service.startListeningForStats( serviceCallback, trafficTimeout ) } 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 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 try { service?.stopListeningForBandwidth(serviceCallback) service?.stopListeningForStats(serviceCallback) } catch (_: RemoteException) { } service = null callback = null } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/bg/ServiceNotification.kt ================================================ /****************************************************************************** * * * Copyright (C) 2021 by nekohasekai * * Copyright (C) 2021 by Max Lv * * Copyright (C) 2021 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.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.os.Build import android.os.PowerManager import android.text.format.Formatter import androidx.core.app.NotificationCompat import androidx.core.content.getSystemService import io.nekohasekai.sagernet.Action import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.SagerNet import io.nekohasekai.sagernet.aidl.AppStatsList import io.nekohasekai.sagernet.aidl.ISagerNetServiceCallback import io.nekohasekai.sagernet.aidl.TrafficStats import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.ktx.app import io.nekohasekai.sagernet.ktx.getColorAttr import io.nekohasekai.sagernet.utils.Theme /** * 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, profileName: String, channel: String, visible: Boolean = false, ) : BroadcastReceiver() { val trafficStatistics = DataStore.profileTrafficStatistics val showDirectSpeed = DataStore.showDirectSpeed private val callback: ISagerNetServiceCallback by lazy { object : ISagerNetServiceCallback.Stub() { override fun stateChanged(state: Int, profileName: String?, msg: String?) {} // ignore override fun trafficUpdated(profileId: Long, stats: TrafficStats, isCurrent: Boolean) { if (!trafficStatistics || profileId == 0L || !isCurrent) return builder.apply { 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) ) ) setStyle(NotificationCompat.BigTextStyle().bigText(speedDetail)) 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) ) ) setContentText(speedSimple) } setSubText( service.getString( R.string.traffic, Formatter.formatFileSize(service, stats.txTotal), Formatter.formatFileSize(service, stats.rxTotal) ) ) } show() } override fun statsUpdated(statsList: AppStatsList?) { } override fun observatoryResultsUpdated(groupId: Long) { } override fun profilePersisted(profileId: Long) { } override fun missingPlugin(profileName: String?, pluginName: String?) { } override fun routeAlert(type: Int, routeName: String?) { } } } private var callbackRegistered = false private val builder = NotificationCompat.Builder(service as Context, channel).setWhen(0) .setTicker(service.getString(R.string.forward_success)).setContentTitle(profileName) .setContentIntent(SagerNet.configureIntent(service)) .setSmallIcon(R.drawable.ic_service_ax).setCategory(NotificationCompat.CATEGORY_SERVICE) .setPriority(if (visible) NotificationCompat.PRIORITY_LOW else NotificationCompat.PRIORITY_MIN) init { service as Context val closeAction = NotificationCompat.Action.Builder( R.drawable.ic_navigation_close, service.getText(R.string.stop), PendingIntent.getBroadcast( service, 0, Intent(Action.CLOSE).setPackage(service.packageName), if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0 ) ).apply { setShowsUserInterface(false) }.build() if (Build.VERSION.SDK_INT < 24 || DataStore.showStopButton) builder.addAction(closeAction) else builder.addInvisibleAction( closeAction ) Theme.apply(app) Theme.apply(service) builder.color = service.getColorAttr(R.attr.colorPrimary) updateCallback(service.getSystemService()?.isInteractive != false) service.registerReceiver(this, IntentFilter().apply { addAction(Intent.ACTION_SCREEN_ON) addAction(Intent.ACTION_SCREEN_OFF) }) show() } override fun onReceive(context: Context, intent: Intent) { if (service.data.state == BaseService.State.Connected) updateCallback(intent.action == Intent.ACTION_SCREEN_ON) } private fun updateCallback(screenOn: Boolean) { if (!trafficStatistics) return if (screenOn) { service.data.binder.registerCallback(callback) service.data.binder.startListeningForBandwidth( callback, DataStore.speedInterval.toLong() ) callbackRegistered = true } else if (callbackRegistered) { // unregister callback to save battery service.data.binder.unregisterCallback(callback) callbackRegistered = false } } private fun show() = (service as Service).startForeground(1, builder.build()) fun destroy() { (service as Service).stopForeground(true) service.unregisterReceiver(this) updateCallback(false) } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/bg/SubscriptionUpdater.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.bg import android.content.Context import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.work.CoroutineWorker import androidx.work.ExistingPeriodicWorkPolicy 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.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 val minInitDelay = subscriptions.minOf { now - it.subscription!!.lastUpdated - (minDelay * 60) } if (minDelay < 15) minDelay = 15 RemoteWorkManager.getInstance(app).enqueueUniquePeriodicWork( WORK_NAME, ExistingPeriodicWorkPolicy.REPLACE, 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.startedProfile == 0L) { 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) { continue } 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 ================================================ /******************************************************************************* * * * 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.bg import android.app.KeyguardManager import android.graphics.drawable.Icon import android.service.quicksettings.Tile import androidx.annotation.RequiresApi import androidx.core.content.getSystemService import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.SagerNet import io.nekohasekai.sagernet.aidl.ISagerNetService import io.nekohasekai.sagernet.database.DataStore 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_ax) } 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 val keyguard by lazy { getSystemService()!! } private var tapPending = false private val connection = SagerConnection() 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 onStartListening() { super.onStartListening() connection.connect(this, this) } override fun onStopListening() { connection.disconnect(this) super.onStopListening() } override fun onClick() { if (isLocked && !DataStore.canToggleLocked) 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 = iconIdle state = Tile.STATE_ACTIVE } BaseService.State.Connected -> { icon = iconIdle if (!keyguard.isDeviceLocked) label = profileName() state = Tile.STATE_ACTIVE } BaseService.State.Stopping -> { icon = iconIdle 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 ================================================ /****************************************************************************** * * * Copyright (C) 2021 by nekohasekai * * Copyright (C) 2021 by Max Lv * * Copyright (C) 2021 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.bg import android.Manifest import android.app.Service import android.content.Intent import android.content.pm.PackageManager import android.net.Network import android.net.ProxyInfo import android.os.Build import android.os.ParcelFileDescriptor import android.system.ErrnoException import android.system.Os import androidx.annotation.RequiresApi import io.nekohasekai.sagernet.* import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.database.SagerDatabase import io.nekohasekai.sagernet.database.StatsEntity import io.nekohasekai.sagernet.fmt.LOCALHOST import io.nekohasekai.sagernet.ktx.Logs import io.nekohasekai.sagernet.ui.VpnRequestActivity import io.nekohasekai.sagernet.utils.DefaultNetworkListener import io.nekohasekai.sagernet.utils.PackageCache import io.nekohasekai.sagernet.utils.Subnet import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import libcore.AppStats import libcore.Libcore import libcore.TrafficListener import libcore.Tun2ray import java.io.FileDescriptor import android.net.VpnService as BaseVpnService class VpnService : BaseVpnService(), BaseService.Interface, TrafficListener { companion object { var instance: VpnService? = null const val VPN_MTU = 1500 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" const val FAKEDNS_VLAN6_CLIENT = "fc00::" private fun FileDescriptor.use(block: (FileDescriptor) -> T) = try { block(this) } finally { try { Os.close(this) } catch (_: ErrnoException) { } } } lateinit var conn: ParcelFileDescriptor private lateinit var tun: Tun2ray fun getTun(): Tun2ray? { if (!::tun.isInitialized) return null return tun } private var active = false private var metered = false @Volatile private var underlyingNetwork: Network? = null @RequiresApi(Build.VERSION_CODES.LOLLIPOP_MR1) set(value) { field = value if (active && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) { setUnderlyingNetworks(underlyingNetworks) } } private val underlyingNetworks get() = // clearing underlyingNetworks makes Android 9 consider the network to be metered if (Build.VERSION.SDK_INT == 28 && metered) null else underlyingNetwork?.let { arrayOf(it) } override suspend fun startProcesses() { super.startProcesses() startVpn() } @Suppress("EXPERIMENTAL_API_USAGE") override fun killProcesses() { getTun()?.close() if (::conn.isInitialized) conn.close() super.killProcesses() persistAppStats() active = false GlobalScope.launch(Dispatchers.Default) { DefaultNetworkListener.stop(this) } } 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 } override suspend fun preInit() = DefaultNetworkListener.start(this) { underlyingNetwork = it } inner class NullConnectionException : NullPointerException(), BaseService.ExpectedException { override fun getLocalizedMessage() = getString(R.string.reboot_required) } private fun startVpn() { instance = this val profile = data.proxy!!.profile val builder = Builder().setConfigureIntent(SagerNet.configureIntent(this)) .setSession(profile.displayName()) .setMtu(VPN_MTU) val useFakeDns = DataStore.enableFakeDns val ipv6Mode = DataStore.ipv6Mode builder.addAddress(PRIVATE_VLAN4_CLIENT, 30) if (ipv6Mode != IPv6Mode.DISABLE) { builder.addAddress(PRIVATE_VLAN6_CLIENT, 126) } if (useFakeDns) { if (ipv6Mode != IPv6Mode.ONLY) { builder.addAddress(FAKEDNS_VLAN4_CLIENT, 15) } else { builder.addAddress(FAKEDNS_VLAN6_CLIENT, 18) } } if (DataStore.bypassLan && !DataStore.bypassLanInCoreOnly) { 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) // 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) } } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) { builder.setUnderlyingNetworks(underlyingNetworks) } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) builder.setMetered(metered) val packageName = packageName val proxyApps = DataStore.proxyApps val needBypassRootUid = data.proxy!!.config.outboundTagsAll.values.any { it.ptBean != null } val needIncludeSelf = data.proxy!!.config.index.any { !it.isBalancer && it.chain.size > 1 } if (proxyApps || needBypassRootUid) { var bypass = DataStore.bypass 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 } individual.apply { if (bypass xor needIncludeSelf) add(packageName) else remove(packageName) }.forEach { try { if (bypass) { builder.addDisallowedApplication(it) Logs.d("Add bypass: $it") } else { builder.addAllowedApplication(it) Logs.d("Add allow: $it") } } catch (ex: PackageManager.NameNotFoundException) { Logs.w(ex) } } } else { builder.addDisallowedApplication(packageName) } builder.addDnsServer(PRIVATE_VLAN4_ROUTER) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && DataStore.appendHttpProxy && DataStore.requireHttp) { builder.setHttpProxy(ProxyInfo.buildDirectProxy(LOCALHOST, DataStore.httpPort)) } metered = DataStore.meteredNetwork active = true // possible race condition here? if (Build.VERSION.SDK_INT >= 29) builder.setMetered(metered) conn = builder.establish() ?: throw NullConnectionException() tun = Libcore.newTun2ray( conn.fd, VPN_MTU, data.proxy!!.v2rayPoint, PRIVATE_VLAN4_ROUTER, DataStore.tunImplementation == TunImplementation.GVISOR, true, DataStore.trafficSniffing, DataStore.destinationOverride, DataStore.enableFakeDns, DataStore.enableLog, data.proxy!!.config.dumpUid, DataStore.appTrafficStatistics, DataStore.enablePcap ) } val appStats = mutableListOf() override fun updateStats(stats: AppStats) { appStats.add(stats) } fun persistAppStats() { if (!DataStore.appTrafficStatistics) return val tun = getTun() ?: return appStats.clear() tun.readAppTraffics(this) val toUpdate = mutableListOf() val all = SagerDatabase.statsDao.all().associateBy { it.packageName } for (stats in appStats) { val packageName = if (stats.uid >= 10000) { PackageCache.uidMap[stats.uid]?.iterator()?.next() ?: "android" } else { "android" } if (!all.containsKey(packageName)) { SagerDatabase.statsDao.create( StatsEntity( packageName = packageName, tcpConnections = stats.tcpConnTotal, udpConnections = stats.udpConnTotal, uplink = stats.uplinkTotal, downlink = stats.downlinkTotal ) ) } else { val entity = all[packageName]!! entity.tcpConnections += stats.tcpConnTotal entity.udpConnections += stats.udpConnTotal entity.uplink += stats.uplinkTotal entity.downlink += stats.downlinkTotal toUpdate.add(entity) } if (toUpdate.isNotEmpty()) { SagerDatabase.statsDao.update(toUpdate) } } } override fun onRevoke() = stopRunner() override fun onDestroy() { super.onDestroy() data.binder.close() } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/bg/proto/ApiInstance.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.bg.proto import android.os.Build import android.provider.Settings import io.nekohasekai.sagernet.bg.AbstractInstance import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.ktx.app import libcore.ApiInstance class ApiInstance : AbstractInstance { lateinit var point: ApiInstance override fun launch() { var deviceName = Settings.Secure.getString(app.contentResolver, "bluetooth_name") if (deviceName.isNullOrBlank()) { deviceName = Build.DEVICE if (!deviceName.startsWith(Build.MANUFACTURER)) { deviceName = Build.MANUFACTURER + " " + deviceName } } point = ApiInstance( deviceName, DataStore.socksPort, DataStore.localDNSPort, DataStore.enableLog, DataStore.bypassLan ) point.start() } override fun close() { if (::point.isInitialized) point.close() } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/bg/proto/ProxyInstance.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.bg.proto //import io.nekohasekai.sagernet.BuildConfig //import io.nekohasekai.sagernet.bg.test.DebugInstance //import com.xray.app.observatory.ObservationResult //import com.xray.app.observatory.OutboundStatus import io.nekohasekai.sagernet.SagerNet import io.nekohasekai.sagernet.bg.BaseService import io.nekohasekai.sagernet.bg.VpnService import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.database.ProxyEntity import io.nekohasekai.sagernet.database.SagerDatabase import io.nekohasekai.sagernet.ktx.Logs import io.nekohasekai.sagernet.utils.DirectBoot import kotlinx.coroutines.Job import kotlinx.coroutines.runBlocking import libcore.Libcore import java.io.IOException class ProxyInstance(profile: ProxyEntity, val service: BaseService.Interface) : V2RayInstance( profile ) { lateinit var observatoryJob: Job override fun init() { if (service is VpnService) { Libcore.setProtector { service.protect(it) } } else { Libcore.setProtector { true } } super.init() Logs.d(config.config) pluginConfigs.forEach { (_, plugin) -> val (_, content) = plugin Logs.d(content) } } override fun launch() { super.launch() /* if (config.observatoryTags.isNotEmpty()) { observatoryJob = runOnDefaultDispatcher { sendInitStatuses() val interval = 10000L while (isActive) { try { loopObservatoryResults() } catch (e: Exception) { if (e.message?.contains("unavailable") == false) { Logs.w(e) } break } delay(interval) } } }*/ if (DataStore.allowAccess) { val api = ApiInstance() try { api.launch() externalInstances[11451] = api } catch (e: Exception) { Logs.w("Failed to start api server", e) } } /* if (BuildConfig.DEBUG && DataStore.enableLog) { externalInstances[9999] = DebugInstance().apply { launch() } }*/ SagerNet.started = true } fun sendInitStatuses() { /*val time = (System.currentTimeMillis() / 1000) - 300 for (observatoryTag in config.observatoryTags) { val profileId = observatoryTag.substringAfter("global-") if (NumberUtil.isLong(profileId)) { val id = profileId.toLong() val profile = when { id == profile.id -> profile statsOutbounds.containsKey(id) -> statsOutbounds[id]!!.proxyEntity else -> SagerDatabase.proxyDao.getById(id) } ?: continue if (profile.status > 0) v2rayPoint.updateStatus( observatoryTag, OutboundStatus.newBuilder() .setOutboundTag(observatoryTag) .setAlive(profile.status == 1) .setDelay(profile.ping.toLong()) .setLastErrorReason(profile.error ?: "") .setLastTryTime(time) .setLastSeenTime(time) .build() .toByteArray() ) } }*/ } /* suspend fun loopObservatoryResults() { val statusPb = v2rayPoint.observatoryStatus if (statusPb == null || statusPb.isEmpty()) { return } val statusList = ObservationResult.parseFrom(statusPb) val notify = mutableSetOf() for (status in statusList.statusList) { val profileId = status.outboundTag.substringAfter("global-") if (NumberUtil.isLong(profileId)) { val id = profileId.toLong() var flush = false val profile = when { id == profile.id -> profile statsOutbounds.containsKey(id) -> statsOutbounds[id]!!.proxyEntity else -> { flush = true SagerDatabase.proxyDao.getById(id) } } if (profile != null) { val newStatus = if (status.alive) 1 else 3 val newDelay = status.delay.toInt() val newErrorReason = status.lastErrorReason if (profile.status != newStatus || profile.ping != newDelay || profile.error != newErrorReason) { profile.status = newStatus profile.ping = newDelay profile.error = newErrorReason notify.add(profile.groupId) if (flush) SagerDatabase.proxyDao.updateProxy(profile) Logs.d("Send result for #$profileId ${profile.displayName()}") } } else { Logs.d("Profile with id #$profileId not found") } } else { Logs.d("Persist skipped on outbound ${status.outboundTag}") } } if (notify.isNotEmpty()) { onMainDispatcher { service.data.binder.broadcast { for (groupId in notify) it.observatoryResultsUpdated(groupId) } } } }*/ override fun close() { SagerNet.started = false persistStats() super.close() if (::observatoryJob.isInitialized) observatoryJob.cancel() } // ------------- stats ------------- private suspend fun queryStats(tag: String, direct: String): Long { return v2rayPoint.queryStats(tag, direct) } private val currentTags by lazy { mapOf(* config.outboundTagsCurrent.map { it to config.outboundTagsAll[it] }.toTypedArray()) } private val statsTags by lazy { mapOf(* config.outboundTags.toMutableList().apply { removeAll(config.outboundTagsCurrent) }.map { it to config.outboundTagsAll[it] }.toTypedArray()) } private val interTags by lazy { config.outboundTagsAll.filterKeys { !config.outboundTags.contains(it) } } class OutboundStats( val proxyEntity: ProxyEntity, var uplinkTotal: Long = 0L, var downlinkTotal: Long = 0L ) private val statsOutbounds = hashMapOf() private fun registerStats( proxyEntity: ProxyEntity, uplink: Long? = null, downlink: Long? = null ) { if (proxyEntity.id == outboundStats.proxyEntity.id) return val stats = statsOutbounds.getOrPut(proxyEntity.id) { OutboundStats(proxyEntity) } if (uplink != null) { stats.uplinkTotal += uplink } if (downlink != null) { stats.downlinkTotal += downlink } } var uplinkProxy = 0L var downlinkProxy = 0L var uplinkTotalDirect = 0L var downlinkTotalDirect = 0L private val outboundStats = OutboundStats(profile) suspend fun outboundStats(): Pair> { if (!isInitialized()) return outboundStats to statsOutbounds uplinkProxy = 0L downlinkProxy = 0L val currentUpLink = currentTags.map { (tag, profile) -> queryStats(tag, "uplink").apply { profile?.also { registerStats(it, uplink = this) } } } val currentDownLink = currentTags.map { (tag, profile) -> queryStats(tag, "downlink").apply { profile?.also { registerStats(it, downlink = this) } } } uplinkProxy += currentUpLink.fold(0L) { acc, l -> acc + l } downlinkProxy += currentDownLink.fold(0L) { acc, l -> acc + l } outboundStats.uplinkTotal += uplinkProxy outboundStats.downlinkTotal += downlinkProxy if (statsTags.isNotEmpty()) { uplinkProxy += statsTags.map { (tag, profile) -> queryStats(tag, "uplink").apply { profile?.also { registerStats(it, uplink = this) } } }.fold(0L) { acc, l -> acc + l } downlinkProxy += statsTags.map { (tag, profile) -> queryStats(tag, "downlink").apply { profile?.also { registerStats(it, downlink = this) } } }.fold(0L) { acc, l -> acc + l } } if (interTags.isNotEmpty()) { interTags.map { (tag, profile) -> queryStats(tag, "uplink").also { registerStats(profile, uplink = it) } } interTags.map { (tag, profile) -> queryStats(tag, "downlink").also { registerStats(profile, downlink = it) } } } return outboundStats to statsOutbounds } suspend fun bypassStats(direct: String): Long { if (!isInitialized()) return 0L return queryStats(config.bypassTag, direct) } suspend fun uplinkDirect() = bypassStats("uplink").also { uplinkTotalDirect += it } suspend fun downlinkDirect() = bypassStats("downlink").also { downlinkTotalDirect += it } fun persistStats() { runBlocking { try { outboundStats() val toUpdate = mutableListOf() if (outboundStats.uplinkTotal + outboundStats.downlinkTotal != 0L) { profile.tx += outboundStats.uplinkTotal profile.rx += outboundStats.downlinkTotal toUpdate.add(profile) } statsOutbounds.values.forEach { if (it.uplinkTotal + it.downlinkTotal != 0L) { it.proxyEntity.tx += it.uplinkTotal it.proxyEntity.rx += it.downlinkTotal toUpdate.add(it.proxyEntity) } } if (toUpdate.isNotEmpty()) { SagerDatabase.proxyDao.updateProxy(toUpdate) } } catch (e: IOException) { if (!DataStore.directBootAware) throw e // we should only reach here because we're in direct boot val profile = DirectBoot.getDeviceProfile()!! profile.tx += outboundStats.uplinkTotal profile.rx += outboundStats.downlinkTotal profile.dirty = true DirectBoot.update(profile) DirectBoot.listenForUnlock() } } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/bg/proto/SSHInstance.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.bg.proto import io.nekohasekai.sagernet.bg.ClashBasedInstance import io.nekohasekai.sagernet.fmt.ssh.SSHBean import libcore.Libcore class SSHInstance(val server: SSHBean, val socksPort: Int) : ClashBasedInstance() { override fun createInstance() { instance = Libcore.newSSHInstance( socksPort, server.finalAddress, server.finalPort, server.username, server.authType, server.password, server.privateKey, server.privateKeyPassphrase, server.publicKey ) } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/bg/proto/ShadowsocksInstance.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.bg.proto import cn.hutool.json.JSONObject import com.github.shadowsocks.plugin.PluginConfiguration import io.nekohasekai.sagernet.bg.ClashBasedInstance import io.nekohasekai.sagernet.fmt.shadowsocks.ShadowsocksBean import libcore.Libcore class ShadowsocksInstance(val server: ShadowsocksBean, val port: Int) : ClashBasedInstance() { override fun createInstance() { var pluginName = "" val pluginOpts = JSONObject() if (server.plugin.isNotBlank()) { val plugin = PluginConfiguration(server.plugin) pluginName = plugin.selected val options = plugin.getOptions() when (pluginName) { "obfs-local" -> { pluginOpts["mode"] = options["obfs"] pluginOpts["host"] = options["obfs-host"] } "v2ray-plugin" -> { pluginOpts["mode"] = options["mode"] pluginOpts["host"] = options["host"] pluginOpts["path"] = options["path"] if (options.containsKey("tls")) { pluginOpts["tls"] = true } if (options.containsKey("mux")) { pluginOpts["mux"] = true } } } } instance = Libcore.newShadowsocksInstance( port, server.finalAddress, server.finalPort, server.password, server.method, pluginName, pluginOpts.toStringPretty() ) } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/bg/proto/ShadowsocksRInstance.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.bg.proto import io.nekohasekai.sagernet.bg.ClashBasedInstance import io.nekohasekai.sagernet.fmt.shadowsocksr.ShadowsocksRBean import libcore.Libcore class ShadowsocksRInstance(val server: ShadowsocksRBean, val port: Int) : ClashBasedInstance() { override fun createInstance() { instance = Libcore.newShadowsocksRInstance( port, server.finalAddress, server.finalPort, server.password, server.method, server.obfs, server.obfsParam, server.protocol, server.protocolParam ) } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/bg/proto/SnellInstance.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.bg.proto import io.nekohasekai.sagernet.bg.ClashBasedInstance import io.nekohasekai.sagernet.fmt.snell.SnellBean import libcore.Libcore class SnellInstance(val server: SnellBean, val port: Int) : ClashBasedInstance() { override fun createInstance() { instance = Libcore.newSnellInstance( port, server.finalAddress, server.finalPort, server.psk, server.obfsMode, server.obfsHost, server.version ) } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/bg/proto/UidDumper.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.bg.proto import android.annotation.SuppressLint import android.os.Build import android.system.OsConstants import cn.hutool.cache.impl.LFUCacheCompact import cn.hutool.core.util.HexUtil import io.nekohasekai.sagernet.SagerNet import io.nekohasekai.sagernet.utils.PackageCache import libcore.UidDumper import libcore.UidInfo import java.io.File import java.net.InetAddress import java.net.InetSocketAddress object UidDumper : UidDumper { private val TCP_IPV4_PROC = File("/proc/net/tcp") private val TCP_IPV6_PROC = File("/proc/net/tcp6") private val UDP_IPV4_PROC = File("/proc/net/udp") private val UDP_IPV6_PROC = File("/proc/net/udp6") private data class ProcStats constructor(val remoteAddress: InetSocketAddress, val uid: Int) private fun mkMap() = LFUCacheCompact(-1, 5 * 60 * 1000L).build(false) private val uidCacheMapTcp = mkMap() private val uidCacheMapTcp6 = mkMap() private val uidCacheMapUdp = mkMap() private val uidCacheMapUdp6 = mkMap() private val canReadProc = Build.VERSION.SDK_INT < Build.VERSION_CODES.Q private val useApi = !canReadProc/* || BuildConfig.DEBUG && tun.enableLog)*/ override fun dumpUid( ipv6: Boolean, udp: Boolean, srcIp: String, srcPort: Int, destIp: String, destPort: Int ): Int { return dumpUid( ipv6, udp, InetSocketAddress(srcIp, srcPort), InetSocketAddress(destIp, destPort) ) } override fun getUidInfo(uid: Int): UidInfo { PackageCache.awaitLoadSync() if (uid <= 1000L) { val uidInfo = UidInfo() uidInfo.label = PackageCache.loadLabel("android") uidInfo.packageName = "android" return uidInfo } val packageNames = PackageCache.uidMap[uid.toInt()] if (!packageNames.isNullOrEmpty()) for (packageName in packageNames) { val uidInfo = UidInfo() uidInfo.label = PackageCache.loadLabel(packageName) uidInfo.packageName = packageName return uidInfo } error("unknown uid $uid") } @SuppressLint("NewApi") fun dumpUid( ipv6: Boolean, udp: Boolean, local: InetSocketAddress, remote: InetSocketAddress ): Int { if (useApi) return SagerNet.connectivity.getConnectionOwnerUid( if (!udp) OsConstants.IPPROTO_TCP else OsConstants.IPPROTO_UDP, local, remote ) val proc = if (!udp) { if (!ipv6) TCP_IPV4_PROC else TCP_IPV6_PROC } else { if (!ipv6) UDP_IPV4_PROC else UDP_IPV6_PROC } val cacheMap = if (!udp) { if (!ipv6) uidCacheMapTcp else uidCacheMapTcp6 } else { if (!ipv6) uidCacheMapUdp else uidCacheMapUdp6 } if (cacheMap.containsKey(local.port)) { val cache = cacheMap[local.port] if (cache.remoteAddress == remote) return cache.uid } var lines = proc.readLines().map { line -> line.split(" ").filterNot { it.isBlank() } } lines = lines.subList(1, lines.size) for (process in lines) { val localPort = process[1].substringAfter(":").toInt(16) val remoteAddress = InetAddress.getByAddress( HexUtil.decodeHex( process[2].substringBefore( ":" ) ) ) val remotePort = process[2].substringAfter(":").toInt(16) val uid = process[7].toInt() cacheMap.put( localPort, ProcStats(InetSocketAddress(remoteAddress, remotePort), uid) ) } return cacheMap[local.port]?.uid ?: -1 } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/bg/proto/V2RayInstance.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.bg.proto import android.annotation.SuppressLint import android.os.Build import android.os.SystemClock import android.webkit.WebResourceError import android.webkit.WebResourceRequest import android.webkit.WebView import android.webkit.WebViewClient import io.nekohasekai.sagernet.SagerNet import io.nekohasekai.sagernet.ShadowsocksProvider import io.nekohasekai.sagernet.TrojanProvider import io.nekohasekai.sagernet.bg.AbstractInstance import io.nekohasekai.sagernet.bg.Executable import io.nekohasekai.sagernet.bg.ExternalInstance import io.nekohasekai.sagernet.bg.GuardedProcessPool import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.database.ProxyEntity import io.nekohasekai.sagernet.fmt.LOCALHOST import io.nekohasekai.sagernet.fmt.V2rayBuildResult import io.nekohasekai.sagernet.fmt.brook.BrookBean import io.nekohasekai.sagernet.fmt.brook.internalUri import io.nekohasekai.sagernet.fmt.buildV2RayConfig import io.nekohasekai.sagernet.fmt.hysteria.HysteriaBean import io.nekohasekai.sagernet.fmt.hysteria.buildHysteriaConfig import io.nekohasekai.sagernet.fmt.internal.ConfigBean import io.nekohasekai.sagernet.fmt.naive.NaiveBean import io.nekohasekai.sagernet.fmt.naive.buildNaiveConfig import io.nekohasekai.sagernet.fmt.pingtunnel.PingTunnelBean import io.nekohasekai.sagernet.fmt.relaybaton.RelayBatonBean import io.nekohasekai.sagernet.fmt.relaybaton.buildRelayBatonConfig import io.nekohasekai.sagernet.fmt.shadowsocks.ShadowsocksBean import io.nekohasekai.sagernet.fmt.shadowsocks.buildShadowsocksConfig import io.nekohasekai.sagernet.fmt.shadowsocksr.ShadowsocksRBean import io.nekohasekai.sagernet.fmt.snell.SnellBean import io.nekohasekai.sagernet.fmt.ssh.SSHBean import io.nekohasekai.sagernet.fmt.trojan.TrojanBean import io.nekohasekai.sagernet.fmt.trojan.buildTrojanConfig import io.nekohasekai.sagernet.fmt.trojan.buildTrojanGoConfig import io.nekohasekai.sagernet.fmt.trojan_go.TrojanGoBean import io.nekohasekai.sagernet.fmt.trojan_go.buildCustomTrojanConfig import io.nekohasekai.sagernet.fmt.trojan_go.buildTrojanGoConfig import io.nekohasekai.sagernet.fmt.wireguard.WireGuardBean import io.nekohasekai.sagernet.fmt.wireguard.buildWireGuardUapiConf import io.nekohasekai.sagernet.ktx.* import io.nekohasekai.sagernet.plugin.PluginManager import kotlinx.coroutines.* import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.delay import kotlinx.coroutines.plus import libcore.Libcore import libcore.V2RayInstance import okhttp3.internal.closeQuietly import java.io.File import java.util.concurrent.atomic.AtomicBoolean abstract class V2RayInstance( val profile: ProxyEntity ) : AbstractInstance { lateinit var config: V2rayBuildResult lateinit var v2rayPoint: V2RayInstance private lateinit var wsForwarder: WebView val pluginPath = hashMapOf() val pluginConfigs = hashMapOf>() val externalInstances = hashMapOf() open lateinit var processes: GuardedProcessPool private var cacheFiles = ArrayList() var closed by AtomicBoolean() fun isInitialized(): Boolean { return ::config.isInitialized } protected fun initPlugin(name: String): PluginManager.InitResult { return pluginPath.getOrPut(name) { PluginManager.init(name)!! } } protected open fun buildConfig() { config = buildV2RayConfig(profile) } protected open fun loadConfig() { v2rayPoint.loadConfig(config.config) } open fun init() { v2rayPoint = V2RayInstance() buildConfig() for ((isBalancer, chain) in config.index) { chain.entries.forEachIndexed { index, (port, profile) -> val needChain = !isBalancer && index != chain.size - 1 val mux = DataStore.enableMux && (isBalancer || chain.size == 0) when (val bean = profile.requireBean()) { is ShadowsocksBean -> when (val provider = profile.pickShadowsocksProvider()) { ShadowsocksProvider.CLASH -> { externalInstances[port] = ShadowsocksInstance(bean, port) } else -> { pluginConfigs[port] = provider to bean.buildShadowsocksConfig( port ) } } is ShadowsocksRBean -> { externalInstances[port] = ShadowsocksRInstance(bean, port) } is TrojanBean -> { when (DataStore.providerTrojan) { TrojanProvider.TROJAN -> { initPlugin("trojan-plugin") pluginConfigs[port] = profile.type to bean.buildTrojanConfig( port ) } TrojanProvider.TROJAN_GO -> { initPlugin("trojan-go-plugin") pluginConfigs[port] = profile.type to bean.buildTrojanGoConfig( port, mux ) } } } is TrojanGoBean -> { initPlugin("trojan-go-plugin") pluginConfigs[port] = profile.type to bean.buildTrojanGoConfig( port, mux ) } is NaiveBean -> { initPlugin("naive-plugin") pluginConfigs[port] = profile.type to bean.buildNaiveConfig(port, mux) } is PingTunnelBean -> { if (needChain) error("PingTunnel is incompatible with chain") initPlugin("pingtunnel-plugin") } is RelayBatonBean -> { initPlugin("relaybaton-plugin") pluginConfigs[port] = profile.type to bean.buildRelayBatonConfig(port) } is BrookBean -> { initPlugin("brook-plugin") } is HysteriaBean -> { initPlugin("hysteria-plugin") pluginConfigs[port] = profile.type to bean.buildHysteriaConfig(port) { File( app.noBackupFilesDir, "hysteria_" + SystemClock.elapsedRealtime() + ".ca" ).apply { parentFile?.mkdirs() cacheFiles.add(this) } } } is WireGuardBean -> { initPlugin("wireguard-plugin") pluginConfigs[port] = profile.type to bean.buildWireGuardUapiConf() } is ConfigBean -> { when (bean.type) { "trojan-go" -> { initPlugin("trojan-go-plugin") pluginConfigs[port] = profile.type to buildCustomTrojanConfig( bean.content, port ) } else -> { externalInstances[port] = ExternalInstance( profile, port ).apply { init() } } } } is SnellBean -> { externalInstances[port] = SnellInstance(bean, port) } is SSHBean -> { externalInstances[port] = SSHInstance(bean, port) } } } } loadConfig() } @SuppressLint("SetJavaScriptEnabled") override fun launch() { val context = if (Build.VERSION.SDK_INT < 24 || SagerNet.user.isUserUnlocked) SagerNet.application else SagerNet.deviceStorage for ((isBalancer, chain) in config.index) { chain.entries.forEachIndexed { index, (port, profile) -> val bean = profile.requireBean() val needChain = !isBalancer && index != chain.size - 1 val (profileType, config) = pluginConfigs[port] ?: 0 to "" when { externalInstances.containsKey(port) -> { externalInstances[port]!!.launch() } bean is ShadowsocksBean -> { val configFile = File( context.noBackupFilesDir, "shadowsocks_" + SystemClock.elapsedRealtime() + ".json" ) configFile.parentFile?.mkdirs() configFile.writeText(config) cacheFiles.add(configFile) val commands = mutableListOf( File( SagerNet.application.applicationInfo.nativeLibraryDir, when (profileType) { ShadowsocksProvider.SHADOWSOCKS_RUST -> Executable.SS_LOCAL else -> Executable.SS_LIBEV_LOCAL } ).absolutePath, "-c", configFile.absolutePath ) if (profileType == ShadowsocksProvider.SHADOWSOCKS_RUST) { commands.add("--log-without-time") } else { commands.addAll(arrayOf("-u", "-t", "600")) } if (DataStore.enableLog) commands.add("-v") processes.start(commands) } bean is TrojanBean -> { val configFile = File( context.noBackupFilesDir, "trojan_" + SystemClock.elapsedRealtime() + ".json" ) configFile.parentFile?.mkdirs() configFile.writeText(config) cacheFiles.add(configFile) val commands = listOf( when (DataStore.providerTrojan) { TrojanProvider.TROJAN -> initPlugin("trojan-plugin") else -> initPlugin("trojan-go-plugin") }.path, "--config", configFile.absolutePath ) processes.start(commands) } bean is TrojanGoBean || bean is ConfigBean && bean.type == "trojan-go" -> { val configFile = File( context.noBackupFilesDir, "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 NaiveBean -> { val configFile = File( context.noBackupFilesDir, "naive_" + SystemClock.elapsedRealtime() + ".json" ) configFile.parentFile?.mkdirs() configFile.writeText(config) cacheFiles.add(configFile) val commands = mutableListOf( initPlugin("naive-plugin").path, configFile.absolutePath ) processes.start(commands) } bean is PingTunnelBean -> { if (needChain) error("PingTunnel is incompatible with chain") val commands = mutableListOf( "su", "-c", initPlugin("pingtunnel-plugin").path, "-type", "client", "-sock5", "1", "-l", "$LOCALHOST:$port", "-s", bean.serverAddress ) if (bean.key.isNotBlank() && bean.key != "1") { commands.add("-key") commands.add(bean.key) } processes.start(commands) } bean is RelayBatonBean -> { val configFile = File( context.noBackupFilesDir, "rb_" + SystemClock.elapsedRealtime() + ".toml" ) configFile.parentFile?.mkdirs() configFile.writeText(config) cacheFiles.add(configFile) val commands = mutableListOf( initPlugin("relaybaton-plugin").path, "client", "--config", configFile.absolutePath ) processes.start(commands) } bean is BrookBean -> { val commands = mutableListOf(initPlugin("brook-plugin").path) when (bean.protocol) { "ws" -> { commands.add("wsclient") commands.add("--wsserver") } "wss" -> { commands.add("wssclient") commands.add("--wssserver") } else -> { commands.add("client") commands.add("--server") } } commands.add(bean.internalUri()) if (bean.password.isNotBlank()) { commands.add("--password") commands.add(bean.password) } commands.add("--socks5") commands.add("$LOCALHOST:$port") processes.start(commands) } bean is HysteriaBean -> { val configFile = File( context.noBackupFilesDir, "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.enableLog) "trace" else "warn", "client" ) processes.start(commands) } bean is WireGuardBean -> { val configFile = File( context.noBackupFilesDir, "wg_" + SystemClock.elapsedRealtime() + ".conf" ) configFile.parentFile?.mkdirs() configFile.writeText(config) cacheFiles.add(configFile) val commands = mutableListOf( initPlugin("wireguard-plugin").path, "-a", bean.localAddress.split("\n").joinToString(","), "-b", "127.0.0.1:$port", "-c", configFile.absolutePath, "-d", "127.0.0.1:${DataStore.localDNSPort}" ) processes.start(commands) } } } } lateinit var wsUrl: String if (config.requireWs) { val wsPort = mkPort() wsUrl = "http://$LOCALHOST:$wsPort/" Libcore.setenv("XRAY_BROWSER_DIALER", "$LOCALHOST:$wsPort") } else { Libcore.unsetenv("XRAY_BROWSER_DIALER") } v2rayPoint.start() if (config.requireWs) { runOnMainDispatcher { wsForwarder = WebView(context) wsForwarder.settings.javaScriptEnabled = true wsForwarder.webViewClient = object : WebViewClient() { override fun onReceivedError( view: WebView?, request: WebResourceRequest?, error: WebResourceError?, ) { Logs.d("WebView load r: $error") runOnMainDispatcher { wsForwarder.loadUrl("about:blank") delay(1000L) wsForwarder.loadUrl(wsUrl) } } override fun onPageFinished(view: WebView, url: String) { super.onPageFinished(view, url) Logs.d("WebView loaded: ${view.title}") } } wsForwarder.loadUrl(wsUrl) } } } @Suppress("EXPERIMENTAL_API_USAGE") override fun close() { for (instance in externalInstances.values) { instance.closeQuietly() } cacheFiles.removeAll { it.delete(); true } if (::wsForwarder.isInitialized) { runBlocking { onMainDispatcher { wsForwarder.loadUrl("about:blank") wsForwarder.destroy() } } } if (::processes.isInitialized) processes.close(GlobalScope + Dispatchers.IO) if (::v2rayPoint.isInitialized) { v2rayPoint.close() } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/bg/test/DebugInstance.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.bg.test //import libcore.DebugInstance import io.nekohasekai.sagernet.bg.AbstractInstance class DebugInstance : AbstractInstance { // lateinit var instance: DebugInstance override fun launch() { // instance = Libcore.newDebugInstance() } override fun close() { // if (::instance.isInitialized) instance.close() } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/bg/test/LocalDnsInstance.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.bg.test import cn.hutool.core.util.NumberUtil import io.nekohasekai.sagernet.bg.AbstractInstance import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.fmt.LOCALHOST import io.nekohasekai.sagernet.fmt.TAG_DNS_IN import io.nekohasekai.sagernet.fmt.TAG_DNS_OUT import io.nekohasekai.sagernet.fmt.gson.gson import io.nekohasekai.sagernet.fmt.v2ray.V2RayConfig import io.nekohasekai.sagernet.fmt.v2ray.V2RayConfig.* import io.nekohasekai.sagernet.ktx.isIpAddress import libcore.Libcore import libcore.V2RayInstance import java.io.Closeable class LocalDnsInstance : AbstractInstance, Closeable { lateinit var instance: V2RayInstance override fun launch() { val bind = LOCALHOST val directDNS = DataStore.directDns.split("\n") .mapNotNull { dns -> dns.trim().takeIf { it.isNotBlank() && !it.startsWith("#") } } val config = V2RayConfig().apply { dns = DnsObject().apply { servers = directDNS.map { DnsObject.StringOrServerObject().apply { valueY = DnsObject.ServerObject().apply { address = it } } } } inbounds = listOf(InboundObject().apply { tag = TAG_DNS_IN listen = bind port = DataStore.localDNSPort protocol = "dokodemo-door" settings = LazyInboundConfigurationObject(this, DokodemoDoorInboundConfigurationObject().apply { address = "1.0.0.1" network = "tcp,udp" port = 53 }) }) outbounds = mutableListOf() outbounds.add(OutboundObject().apply { protocol = "freedom" settings = LazyOutboundConfigurationObject(this, FreedomOutboundConfigurationObject().apply { domainStrategy = "UseIP" }) }) outbounds.add(OutboundObject().apply { protocol = "dns" tag = TAG_DNS_OUT settings = LazyOutboundConfigurationObject(this, DNSOutboundConfigurationObject().apply { var dns = directDNS.first() if (dns.contains(":")) { val lPort = dns.substringAfterLast(":") dns = dns.substringBeforeLast(":") if (NumberUtil.isInteger(lPort)) { port = lPort.toInt() } } if (dns.isIpAddress()) { address = dns } else if (dns.contains("://")) { network = "tcp" address = dns.substringAfter("://") } }) }) routing = RoutingObject().apply { domainStrategy = "AsIs" rules = listOf(RoutingObject.RuleObject().apply { type = "field" inboundTag = listOf(TAG_DNS_IN) outboundTag = TAG_DNS_OUT }) } } val i = Libcore.newV2rayInstance() i.loadConfig(gson.toJson(config)) i.start() instance = i } override fun close() { if (::instance.isInitialized) instance.close() } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/bg/test/UrlTest.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.bg.test import io.nekohasekai.sagernet.bg.proto.SSHInstance import io.nekohasekai.sagernet.bg.proto.ShadowsocksInstance import io.nekohasekai.sagernet.bg.proto.ShadowsocksRInstance import io.nekohasekai.sagernet.bg.proto.SnellInstance import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.database.ProxyEntity import libcore.Libcore class UrlTest { val link = DataStore.connectionTestURL val timeout = 5000 suspend fun doTest(profile: ProxyEntity): Int { if (profile.useClashBased()) { val instance = when (profile.type) { ProxyEntity.TYPE_SS -> ShadowsocksInstance(profile.ssBean!!, 0) ProxyEntity.TYPE_SSR -> ShadowsocksRInstance(profile.ssrBean!!, 0) ProxyEntity.TYPE_SNELL -> SnellInstance(profile.snellBean!!, 0) ProxyEntity.TYPE_SSH -> SSHInstance(profile.sshBean!!, 0) else -> error("unexpected") } instance.createInstance() return Libcore.urlTestClashBased(instance.instance, link, timeout).toInt() } return V2RayTestInstance(profile, link, timeout).doTest() } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/bg/test/V2RayTestInstance.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.bg.test import io.nekohasekai.sagernet.bg.GuardedProcessPool import io.nekohasekai.sagernet.bg.proto.V2RayInstance import io.nekohasekai.sagernet.database.ProxyEntity import io.nekohasekai.sagernet.fmt.buildV2RayConfig 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 libcore.Libcore import kotlin.coroutines.suspendCoroutine class V2RayTestInstance(profile: ProxyEntity, val link: String, val timeout: Int) : V2RayInstance( profile ) { suspend fun doTest(): Int { return suspendCoroutine { c -> processes = GuardedProcessPool { Logs.w(it) c.tryResumeWithException(it) } runOnDefaultDispatcher { use { try { init() launch() c.tryResume(Libcore.urlTestV2ray(v2rayPoint, "", link, timeout)) } catch (e: Exception) { c.tryResumeWithException(e) } } } } } override fun buildConfig() { config = buildV2RayConfig(profile, true) } override fun loadConfig() { v2rayPoint.loadConfig(config.config) } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/database/DataStore.kt ================================================ /****************************************************************************** * * * Copyright (C) 2021 by nekohasekai * * Copyright (C) 2021 by Max Lv * * Copyright (C) 2021 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.database import android.os.Binder import android.os.Build import androidx.preference.PreferenceDataStore import io.nekohasekai.sagernet.* 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.* import io.nekohasekai.sagernet.utils.DirectBoot object DataStore : OnPreferenceDataStoreChangeListener { val configurationStore = RoomPreferenceDataStore(PublicDatabase.kvPairDao) val profileCacheStore = RoomPreferenceDataStore(SagerDatabase.profileCacheDao) fun init() { if (Build.VERSION.SDK_INT >= 24) { SagerNet.deviceStorage.moveDatabaseFrom(SagerNet.application, Key.DB_PUBLIC) } if (Build.VERSION.SDK_INT >= 24 && directBootAware && SagerNet.user.isUserUnlocked) { DirectBoot.flushTrafficStats() } } var selectedProxy by configurationStore.long(Key.PROFILE_ID) var currentProfile by configurationStore.long(Key.PROFILE_CURRENT) var startedProfile by configurationStore.long(Key.PROFILE_STARTED) var selectedGroup by configurationStore.long(Key.PROFILE_GROUP) { SagerNet.currentProfile?.groupId ?: 0L } fun currentGroupId(): Long { val currentSelected = selectedGroup 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 = selectedGroup 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 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 domainStrategy by configurationStore.string(Key.DOMAIN_STRATEGY) { "AsIs" } var trafficSniffing by configurationStore.boolean(Key.TRAFFIC_SNIFFING) { true } var destinationOverride by configurationStore.boolean(Key.DESTINATION_OVERRIDE) var resolveDestination by configurationStore.boolean(Key.RESOLVE_DESTINATION) var tcpKeepAliveInterval by configurationStore.stringToInt(Key.TCP_KEEP_ALIVE_INTERVAL) { 15 } var bypassLan by configurationStore.boolean(Key.BYPASS_LAN) var bypassLanInCoreOnly by configurationStore.boolean(Key.BYPASS_LAN_IN_CORE_ONLY) var allowAccess by configurationStore.boolean(Key.ALLOW_ACCESS) var speedInterval by configurationStore.stringToInt(Key.SPEED_INTERVAL) // https://github.com/SagerNet/SagerNet/issues/180 var remoteDns by configurationStore.string(Key.REMOTE_DNS) { "https://1.0.0.1/dns-query" } var directDns by configurationStore.string(Key.DIRECT_DNS) { "https+local://223.5.5.5/dns-query" } var enableDnsRouting by configurationStore.boolean(Key.ENABLE_DNS_ROUTING) var enableFakeDns by configurationStore.boolean(Key.ENABLE_FAKEDNS) var hosts by configurationStore.string(Key.DNS_HOSTS) { "domain:googleapis.cn googleapis.com" } var securityAdvisory by configurationStore.boolean(Key.SECURITY_ADVISORY) { true } var rulesProvider by configurationStore.stringToInt(Key.RULES_PROVIDER) var enableLog by configurationStore.boolean(Key.ENABLE_LOG) { BuildConfig.DEBUG } var enablePcap by configurationStore.boolean(Key.ENABLE_PCAP) // hopefully hashCode = mHandle doesn't change, currently this is true from KitKat to Nougat private val userIndex by lazy { Binder.getCallingUserHandle().hashCode() } var socksPort: Int get() = getLocalPort(Key.SOCKS_PORT, 2081) set(value) = saveLocalPort(Key.SOCKS_PORT, value) var localDNSPort: Int get() = getLocalPort(Key.LOCAL_DNS_PORT, 6451) set(value) { saveLocalPort(Key.LOCAL_DNS_PORT, value) } var httpPort: Int get() = getLocalPort(Key.HTTP_PORT, 9081) set(value) = saveLocalPort(Key.HTTP_PORT, value) var transproxyPort: Int get() = getLocalPort(Key.TRANSPROXY_PORT, 9201) set(value) = saveLocalPort(Key.TRANSPROXY_PORT, value) fun initGlobal() { if (configurationStore.getString(Key.SOCKS_PORT) == null) { socksPort = socksPort } if (configurationStore.getString(Key.LOCAL_DNS_PORT) == null) { localDNSPort = localDNSPort } if (configurationStore.getString(Key.HTTP_PORT) == null) { httpPort = httpPort } if (configurationStore.getString(Key.TRANSPROXY_PORT) == null) { transproxyPort = transproxyPort } } 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.ENABLE } 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 enableMux by configurationStore.boolean(Key.ENABLE_MUX) var enableMuxForAll by configurationStore.boolean(Key.ENABLE_MUX_FOR_ALL) var muxConcurrency by configurationStore.stringToInt(Key.MUX_CONCURRENCY) { 8 } var showStopButton by configurationStore.boolean(Key.SHOW_STOP_BUTTON) var showDirectSpeed by configurationStore.boolean(Key.SHOW_DIRECT_SPEED) val persistAcrossReboot by configurationStore.boolean(Key.PERSIST_ACROSS_REBOOT) { true } val canToggleLocked: Boolean get() = configurationStore.getBoolean(Key.DIRECT_BOOT_AWARE) == true val directBootAware: Boolean get() = SagerNet.directBootSupported && canToggleLocked var requireHttp by configurationStore.boolean(Key.REQUIRE_HTTP) { true } var appendHttpProxy by configurationStore.boolean(Key.APPEND_HTTP_PROXY) { true } var requireTransproxy by configurationStore.boolean(Key.REQUIRE_TRANSPROXY) var transproxyMode by configurationStore.stringToInt(Key.TRANSPROXY_MODE) var connectionTestURL by configurationStore.string(Key.CONNECTION_TEST_URL) { CONNECTION_TEST_URL } var alwaysShowAddress by configurationStore.boolean(Key.ALWAYS_SHOW_ADDRESS) var utlsFingerprint by configurationStore.string(Key.UTLS_FINGERPRINT) var tunImplementation by configurationStore.stringToInt(Key.TUN_IMPLEMENTATION) { TunImplementation.GVISOR } var appTrafficStatistics by configurationStore.boolean(Key.APP_TRAFFIC_STATISTICS) var profileTrafficStatistics by configurationStore.boolean(Key.PROFILE_TRAFFIC_STATISTICS) { true } // protocol var providerTrojan by configurationStore.stringToInt(Key.PROVIDER_TROJAN) var providerShadowsocksAEAD by configurationStore.stringToInt(Key.PROVIDER_SS_AEAD) var providerShadowsocksStream by configurationStore.stringToInt(Key.PROVIDER_SS_STREAM) // cache 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 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 serverPlugin by profileCacheStore.string(Key.SERVER_PLUGIN) var serverProtocol by profileCacheStore.string(Key.SERVER_PROTOCOL) var serverProtocolParam by profileCacheStore.string(Key.SERVER_PROTOCOL_PARAM) var serverObfs by profileCacheStore.string(Key.SERVER_OBFS) var serverObfsParam by profileCacheStore.string(Key.SERVER_OBFS_PARAM) var serverUserId by profileCacheStore.string(Key.SERVER_USER_ID) var serverAlterId by profileCacheStore.stringToInt(Key.SERVER_ALTER_ID) var serverSecurity by profileCacheStore.string(Key.SERVER_SECURITY) var serverNetwork by profileCacheStore.string(Key.SERVER_NETWORK) var serverHeader by profileCacheStore.string(Key.SERVER_HEADER) 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 serverTLS by profileCacheStore.boolean(Key.SERVER_TLS) 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 serverFlow by profileCacheStore.string(Key.SERVER_FLOW) var serverQuicSecurity by profileCacheStore.string(Key.SERVER_QUIC_SECURITY) var serverWsBrowserForwarding by profileCacheStore.boolean(Key.SERVER_WS_BROWSER_FORWARDING) var serverHeaders by profileCacheStore.string(Key.SERVER_HEADERS) var serverAllowInsecure by profileCacheStore.boolean(Key.SERVER_ALLOW_INSECURE) var serverMultiMode by profileCacheStore.boolean(Key.SERVER_MULTI_MODE) var serverVMessExperimentalAuthenticatedLength by profileCacheStore.boolean(Key.SERVER_VMESS_EXPERIMENTAL_AUTHENTICATED_LENGTH) var serverVMessExperimentalNoTerminationSignal by profileCacheStore.boolean(Key.SERVER_VMESS_EXPERIMENTAL_NO_TERMINATION_SIGNAL) 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 serverProtocolVersion by profileCacheStore.stringToInt(Key.SERVER_PROTOCOL) var serverPrivateKey by profileCacheStore.string(Key.SERVER_PRIVATE_KEY) var serverLocalAddress by profileCacheStore.string(Key.SERVER_LOCAL_ADDRESS) var balancerType by profileCacheStore.stringToInt(Key.BALANCER_TYPE) var balancerGroup by profileCacheStore.stringToLong(Key.BALANCER_GROUP) var balancerStrategy by profileCacheStore.string(Key.BALANCER_STRATEGY) 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 routeAttrs by profileCacheStore.string(Key.ROUTE_ATTRS) var routeOutbound by profileCacheStore.stringToInt(Key.ROUTE_OUTBOUND) var routeOutboundRule by profileCacheStore.long(Key.ROUTE_OUTBOUND_RULE) var routeReverse by profileCacheStore.boolean(Key.ROUTE_REVERSE) var routeRedirect by profileCacheStore.string(Key.ROUTE_REDIRECT) var routePackages by profileCacheStore.string(Key.ROUTE_PACKAGES) var routeForegroundStatus by profileCacheStore.string(Key.ROUTE_FOREGROUND_STATUS) var serverConfig by profileCacheStore.string(Key.SERVER_CONFIG) 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 subscriptionType by profileCacheStore.stringToInt(Key.SUBSCRIPTION_TYPE) var subscriptionLink by profileCacheStore.string(Key.SUBSCRIPTION_LINK) var subscriptionToken by profileCacheStore.string(Key.SUBSCRIPTION_TOKEN) var subscriptionForceResolve by profileCacheStore.boolean(Key.SUBSCRIPTION_FORCE_RESOLVE) var subscriptionDeduplication by profileCacheStore.boolean(Key.SUBSCRIPTION_DEDUPLICATION) var subscriptionForceVMessAEAD by profileCacheStore.boolean(Key.SUBSCRIPTION_FORCE_VMESS_AEAD) { true } 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") var systemDnsFinal by profileCacheStore.string("systemDnsFinal") override fun onPreferenceDataStoreChanged(store: PreferenceDataStore, key: String) { when (key) { Key.PROFILE_ID -> if (directBootAware) DirectBoot.update() } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/database/GroupManager.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.database import io.nekohasekai.sagernet.GroupType import io.nekohasekai.sagernet.bg.SubscriptionUpdater import io.nekohasekai.sagernet.ktx.applyDefaultValues import io.nekohasekai.sagernet.utils.DirectBoot 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) if (DataStore.directBootAware) DirectBoot.clean() 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/ProfileManager.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.database import android.database.sqlite.SQLiteCantOpenDatabaseException import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.aidl.TrafficStats 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 io.nekohasekai.sagernet.utils.DirectBoot import java.io.IOException import java.sql.SQLException import java.util.* object ProfileManager { interface Listener { suspend fun onAdd(profile: ProxyEntity) suspend fun onUpdated(profileId: Long, trafficStats: TrafficStats) suspend fun onUpdated(profile: ProxyEntity) 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) } } suspend fun updateProfile(profiles: List) { SagerDatabase.proxyDao.updateProxy(profiles) profiles.forEach { iterator { onUpdated(it) } } } suspend fun deleteProfile(groupId: Long, profileId: Long) { if (SagerDatabase.proxyDao.deleteById(profileId) == 0) return if (DataStore.selectedProxy == profileId) { if (DataStore.directBootAware) DirectBoot.clean() 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() } } suspend fun postUpdate(profileId: Long) { postUpdate(getProfile(profileId) ?: return) } suspend fun postUpdate(profile: ProxyEntity) { iterator { onUpdated(profile) } } suspend fun postTrafficUpdated(profileId: Long, stats: TrafficStats) { iterator { onUpdated(profileId, stats) } } 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_ads), domains = "geosite:category-ads-all", outbound = -2 ) ) var country = Locale.getDefault().country.lowercase() var displayCountry = Locale.getDefault().displayCountry if (country in arrayOf( "ir" ) ) { createRule( RuleEntity( name = app.getString(R.string.route_bypass_domain, displayCountry), domains = "domain:$country", outbound = -1 ), false ) } else { country = Locale.CHINA.country.lowercase() displayCountry = Locale.CHINA.displayCountry 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 ================================================ /****************************************************************************** * * * 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.database import android.content.Context import android.content.Intent import android.os.Build import android.os.Parcel import android.os.Parcelable import androidx.room.* import com.github.shadowsocks.plugin.PluginConfiguration import com.github.shadowsocks.plugin.PluginManager import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.ShadowsocksProvider import io.nekohasekai.sagernet.ShadowsocksStreamProvider import io.nekohasekai.sagernet.TrojanProvider import io.nekohasekai.sagernet.aidl.TrafficStats import io.nekohasekai.sagernet.fmt.AbstractBean import io.nekohasekai.sagernet.fmt.KryoConverters import io.nekohasekai.sagernet.fmt.brook.BrookBean import io.nekohasekai.sagernet.fmt.buildV2RayConfig import io.nekohasekai.sagernet.fmt.http.HttpBean import io.nekohasekai.sagernet.fmt.http.toUri import io.nekohasekai.sagernet.fmt.hysteria.HysteriaBean import io.nekohasekai.sagernet.fmt.hysteria.buildHysteriaConfig import io.nekohasekai.sagernet.fmt.internal.BalancerBean import io.nekohasekai.sagernet.fmt.internal.ChainBean import io.nekohasekai.sagernet.fmt.internal.ConfigBean 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.pingtunnel.PingTunnelBean import io.nekohasekai.sagernet.fmt.pingtunnel.toUri import io.nekohasekai.sagernet.fmt.relaybaton.RelayBatonBean import io.nekohasekai.sagernet.fmt.relaybaton.buildRelayBatonConfig import io.nekohasekai.sagernet.fmt.shadowsocks.ShadowsocksBean import io.nekohasekai.sagernet.fmt.shadowsocks.buildShadowsocksConfig import io.nekohasekai.sagernet.fmt.shadowsocks.methodsXray import io.nekohasekai.sagernet.fmt.shadowsocks.toUri import io.nekohasekai.sagernet.fmt.shadowsocks.* import io.nekohasekai.sagernet.fmt.shadowsocksr.ShadowsocksRBean import io.nekohasekai.sagernet.fmt.shadowsocksr.buildShadowsocksRConfig import io.nekohasekai.sagernet.fmt.shadowsocksr.toUri import io.nekohasekai.sagernet.fmt.snell.SnellBean 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.toUniversalLink import io.nekohasekai.sagernet.fmt.trojan.TrojanBean import io.nekohasekai.sagernet.fmt.trojan.toUri 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.v2ray.StandardV2RayBean import io.nekohasekai.sagernet.fmt.v2ray.VLESSBean import io.nekohasekai.sagernet.fmt.v2ray.VMessBean import io.nekohasekai.sagernet.fmt.v2ray.toUri import io.nekohasekai.sagernet.fmt.wireguard.WireGuardBean import io.nekohasekai.sagernet.ktx.app import io.nekohasekai.sagernet.ktx.applyDefaultValues import io.nekohasekai.sagernet.ktx.ssSecureList import io.nekohasekai.sagernet.ui.profile.* @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 ssrBean: ShadowsocksRBean? = null, var vmessBean: VMessBean? = null, var vlessBean: VLESSBean? = null, var trojanBean: TrojanBean? = null, var trojanGoBean: TrojanGoBean? = null, var naiveBean: NaiveBean? = null, var ptBean: PingTunnelBean? = null, var rbBean: RelayBatonBean? = null, var brookBean: BrookBean? = null, var hysteriaBean: HysteriaBean? = null, var snellBean: SnellBean? = null, var sshBean: SSHBean? = null, var wgBean: WireGuardBean? = null, var configBean: ConfigBean? = null, var chainBean: ChainBean? = null, var balancerBean: BalancerBean? = null ) : Parcelable { companion object { const val TYPE_SOCKS = 0 const val TYPE_HTTP = 1 const val TYPE_SS = 2 const val TYPE_SSR = 3 const val TYPE_VMESS = 4 const val TYPE_VLESS = 5 const val TYPE_TROJAN = 6 const val TYPE_TROJAN_GO = 7 const val TYPE_NAIVE = 9 const val TYPE_PING_TUNNEL = 10 const val TYPE_RELAY_BATON = 11 const val TYPE_BROOK = 12 const val TYPE_HYSTERIA = 15 const val TYPE_SNELL = 16 const val TYPE_SSH = 17 const val TYPE_WG = 18 const val TYPE_CHAIN = 8 const val TYPE_BALANCER = 14 const val TYPE_CONFIG = 13 val chainName by lazy { app.getString(R.string.proxy_chain) } val configName by lazy { app.getString(R.string.custom_config) } val balancerName by lazy { app.getString(R.string.balancer) } private val placeHolderBean = SOCKSBean().applyDefaultValues() @JvmField val CREATOR = object : Parcelable.Creator { override fun createFromParcel(parcel: Parcel): ProxyEntity { return ProxyEntity(parcel) } override fun newArray(size: Int): Array { return arrayOfNulls(size) } } } @Ignore @Transient var dirty: Boolean = false @Ignore @Transient var stats: TrafficStats? = null constructor(parcel: Parcel) : this( parcel.readLong(), parcel.readLong(), parcel.readInt(), parcel.readLong(), parcel.readLong(), parcel.readLong() ) { dirty = parcel.readByte() > 0 val byteArray = ByteArray(parcel.readInt()) parcel.readByteArray(byteArray) putByteArray(byteArray) } 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_SSR -> ssrBean = KryoConverters.shadowsocksRDeserialize(byteArray) TYPE_VMESS -> vmessBean = KryoConverters.vmessDeserialize(byteArray) TYPE_VLESS -> vlessBean = KryoConverters.vlessDeserialize(byteArray) TYPE_TROJAN -> trojanBean = KryoConverters.trojanDeserialize(byteArray) TYPE_TROJAN_GO -> trojanGoBean = KryoConverters.trojanGoDeserialize(byteArray) TYPE_NAIVE -> naiveBean = KryoConverters.naiveDeserialize(byteArray) TYPE_PING_TUNNEL -> ptBean = KryoConverters.pingTunnelDeserialize(byteArray) TYPE_RELAY_BATON -> rbBean = KryoConverters.relayBatonDeserialize(byteArray) TYPE_BROOK -> brookBean = KryoConverters.brookDeserialize(byteArray) TYPE_HYSTERIA -> hysteriaBean = KryoConverters.hysteriaDeserialize(byteArray) TYPE_SNELL -> snellBean = KryoConverters.snellDeserialize(byteArray) TYPE_SSH -> sshBean = KryoConverters.sshDeserialize(byteArray) TYPE_WG -> wgBean = KryoConverters.wireguardDeserialize(byteArray) TYPE_CONFIG -> configBean = KryoConverters.configDeserialize(byteArray) TYPE_CHAIN -> chainBean = KryoConverters.chainDeserialize(byteArray) TYPE_BALANCER -> balancerBean = KryoConverters.balancerBeanDeserialize(byteArray) } } override fun writeToParcel(parcel: Parcel, flags: Int) { parcel.writeLong(id) parcel.writeLong(groupId) parcel.writeInt(type) parcel.writeLong(userOrder) parcel.writeLong(tx) parcel.writeLong(rx) parcel.writeByte(if (dirty) 1 else 0) val byteArray = KryoConverters.serialize(requireBean()) parcel.writeInt(byteArray.size) parcel.writeByteArray(byteArray) } fun displayType() = when (type) { TYPE_SOCKS -> socksBean!!.protocolName() TYPE_HTTP -> if (httpBean!!.tls) "HTTPS" else "HTTP" TYPE_SS -> "Shadowsocks" TYPE_SSR -> "ShadowsocksR" TYPE_VMESS -> "VMess" TYPE_VLESS -> "VLESS" TYPE_TROJAN -> "Trojan" TYPE_TROJAN_GO -> "Trojan-Go" TYPE_NAIVE -> "Naïve" TYPE_PING_TUNNEL -> "PingTunnel" TYPE_RELAY_BATON -> "relaybaton" TYPE_BROOK -> "Brook" TYPE_HYSTERIA -> "Hysteria" TYPE_SNELL -> "Snell" TYPE_SSH -> "SSH" TYPE_WG -> "WireGuard" TYPE_CHAIN -> chainName TYPE_CONFIG -> configName TYPE_BALANCER -> balancerName 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_SSR -> ssrBean TYPE_VMESS -> vmessBean TYPE_VLESS -> vlessBean TYPE_TROJAN -> trojanBean TYPE_TROJAN_GO -> trojanGoBean TYPE_NAIVE -> naiveBean TYPE_PING_TUNNEL -> ptBean TYPE_RELAY_BATON -> rbBean TYPE_BROOK -> brookBean TYPE_HYSTERIA -> hysteriaBean TYPE_SNELL -> snellBean TYPE_SSH -> sshBean TYPE_WG -> wgBean TYPE_CONFIG -> configBean TYPE_CHAIN -> chainBean TYPE_BALANCER -> balancerBean else -> error("Undefined type $type") } ?: error("Null ${displayType()} profile") } fun haveLink(): Boolean { return when (type) { TYPE_CHAIN -> false TYPE_CONFIG -> false TYPE_BALANCER -> false else -> true } } fun haveStandardLink(): Boolean { return when (requireBean()) { is RelayBatonBean -> false is BrookBean -> false is ConfigBean -> false is HysteriaBean -> false is SnellBean -> false is SSHBean -> false is WireGuardBean -> false else -> true } } fun toLink(): String? = with(requireBean()) { when (this) { is SOCKSBean -> toUri() is HttpBean -> toUri() is ShadowsocksBean -> toUri() is ShadowsocksRBean -> toUri() is VMessBean -> toUri() is VLESSBean -> toUri() is TrojanBean -> toUri() is TrojanGoBean -> toUri() is NaiveBean -> toUri() is PingTunnelBean -> toUri() is RelayBatonBean -> toUniversalLink() is BrookBean -> toUniversalLink() is ConfigBean -> toUniversalLink() is HysteriaBean -> toUniversalLink() is SnellBean -> toUniversalLink() is SSHBean -> toUniversalLink() is WireGuardBean -> toUniversalLink() else -> null } } fun exportConfig(): Pair { var name = "profile.json" return with(requireBean()) { StringBuilder().apply { val config = buildV2RayConfig(this@ProxyEntity) append(config.config) if (!config.index.all { it.chain.isEmpty() }) { name = "profiles.txt" } for ((isBalancer, chain) in config.index) { chain.entries.forEachIndexed { index, (port, profile) -> val needChain = !isBalancer && index != chain.size - 1 val needMux = index == 0 && DataStore.enableMux when (val bean = profile.requireBean()) { is ShadowsocksBean -> { append("\n\n") append(bean.buildShadowsocksConfig(port)) } is ShadowsocksRBean -> { append("\n\n") append(bean.buildShadowsocksRConfig()) } is TrojanGoBean -> { append("\n\n") append(bean.buildTrojanGoConfig(port, needMux)) } is NaiveBean -> { append("\n\n") append(bean.buildNaiveConfig(port, needMux)) } is RelayBatonBean -> { append("\n\n") append(bean.buildRelayBatonConfig(port)) } is HysteriaBean -> { append("\n\n") append(bean.buildHysteriaConfig(port, null)) } } } } }.toString() } to name } fun needExternal(): Boolean { return when (type) { TYPE_SOCKS -> false TYPE_HTTP -> false TYPE_SS -> pickShadowsocksProvider() != ShadowsocksProvider.V2RAY TYPE_VMESS -> false TYPE_VLESS -> false TYPE_TROJAN -> DataStore.providerTrojan != TrojanProvider.V2RAY TYPE_CHAIN -> false TYPE_BALANCER -> false else -> true } } fun useClashBased(): Boolean { if (!needExternal()) return false return when (type) { TYPE_SS -> pickShadowsocksProvider() == ShadowsocksProvider.CLASH TYPE_SSR -> true TYPE_SNELL -> true TYPE_SSH -> true else -> false } } fun isV2RayNetworkTcp(): Boolean { val bean = requireBean() as StandardV2RayBean return when (bean.type) { "tcp", "ws", "http" -> true else -> false } } fun needCoreMux(): Boolean { val enableMuxForAll by lazy { DataStore.enableMuxForAll } return when (type) { TYPE_VMESS, TYPE_VLESS -> isV2RayNetworkTcp() TYPE_TROJAN_GO -> false else -> enableMuxForAll } } fun pickShadowsocksProvider(): Int { val bean = ssBean ?: return -1 if (bean.method.contains(ssSecureList)) { val prefer = DataStore.providerShadowsocksAEAD when { prefer == ShadowsocksProvider.V2RAY && bean.method in methodsXray && bean.plugin.isBlank() -> { return ShadowsocksProvider.V2RAY } prefer == ShadowsocksProvider.CLASH && bean.method in methodsClash && ssPluginSupportedByClash( true ) -> { return ShadowsocksProvider.CLASH } prefer == ShadowsocksProvider.SHADOWSOCKS_RUST && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && bean.method in methodsSsRust && !ssPluginSupportedByClash( false ) -> { return ShadowsocksProvider.SHADOWSOCKS_RUST } prefer == ShadowsocksProvider.SHADOWSOCKS_LIBEV && bean.method in methodsSsLibev && !ssPluginSupportedByClash( false ) -> { return ShadowsocksProvider.SHADOWSOCKS_LIBEV } } return if (ssPreferClash()) { ShadowsocksProvider.CLASH } else if (bean.method in methodsXray && bean.plugin.isBlank()) { ShadowsocksProvider.V2RAY } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { ShadowsocksProvider.SHADOWSOCKS_RUST } else { ShadowsocksProvider.SHADOWSOCKS_LIBEV } } else { val prefer = DataStore.providerShadowsocksStream when { prefer == ShadowsocksStreamProvider.CLASH && bean.method in methodsClash && ssPluginSupportedByClash( true ) -> { return ShadowsocksProvider.CLASH } prefer == ShadowsocksStreamProvider.SHADOWSOCKS_RUST && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && bean.method in methodsSsRust && !ssPluginSupportedByClash( false ) -> { return ShadowsocksProvider.SHADOWSOCKS_RUST } prefer == ShadowsocksStreamProvider.SHADOWSOCKS_LIBEV && bean.method in methodsSsLibev && !ssPluginSupportedByClash( false ) -> { return ShadowsocksProvider.SHADOWSOCKS_LIBEV } } return if (ssPreferClash()) { ShadowsocksProvider.CLASH } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { ShadowsocksProvider.SHADOWSOCKS_RUST } else { ShadowsocksProvider.SHADOWSOCKS_LIBEV } } } fun ssPluginSupportedByClash(prefer: Boolean): Boolean { val bean = ssBean ?: return false if (bean.plugin.isNotBlank()) { val plugin = PluginConfiguration(bean.plugin) if (plugin.selected !in arrayOf("obfs-local", "v2ray-plugin")) return false if (plugin.selected == "v2ray-plugin") { if (plugin.getOptions()["mode"] != "websocket") return false } try { PluginManager.init(plugin) return prefer } catch (e: Exception) { } return true } return prefer } fun ssPreferClash(): Boolean { val bean = ssBean ?: return false val onlyClash = bean.method !in methodsXray && bean.method !in methodsSsRust && bean.method !in methodsSsLibev return onlyClash || ssPluginSupportedByClash(false) } fun putBean(bean: AbstractBean): ProxyEntity { socksBean = null httpBean = null ssBean = null ssrBean = null vmessBean = null vlessBean = null trojanBean = null trojanGoBean = null naiveBean = null ptBean = null rbBean = null brookBean = null hysteriaBean = null snellBean = null sshBean = null wgBean = null configBean = null chainBean = null balancerBean = 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 ShadowsocksRBean -> { type = TYPE_SSR ssrBean = bean } is VMessBean -> { type = TYPE_VMESS vmessBean = bean } is VLESSBean -> { type = TYPE_VLESS vlessBean = bean } is TrojanBean -> { type = TYPE_TROJAN trojanBean = bean } is TrojanGoBean -> { type = TYPE_TROJAN_GO trojanGoBean = bean } is NaiveBean -> { type = TYPE_NAIVE naiveBean = bean } is PingTunnelBean -> { type = TYPE_PING_TUNNEL ptBean = bean } is RelayBatonBean -> { type = TYPE_RELAY_BATON rbBean = bean } is BrookBean -> { type = TYPE_BROOK brookBean = bean } is HysteriaBean -> { type = TYPE_HYSTERIA hysteriaBean = bean } is SnellBean -> { type = TYPE_SNELL snellBean = bean } is SSHBean -> { type = TYPE_SSH sshBean = bean } is WireGuardBean -> { type = TYPE_WG wgBean = bean } is ConfigBean -> { type = TYPE_CONFIG configBean = bean } is ChainBean -> { type = TYPE_CHAIN chainBean = bean } is BalancerBean -> { type = TYPE_BALANCER balancerBean = 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_SSR -> ShadowsocksRSettingsActivity::class.java TYPE_VMESS -> VMessSettingsActivity::class.java TYPE_VLESS -> VLESSSettingsActivity::class.java TYPE_TROJAN -> TrojanSettingsActivity::class.java TYPE_TROJAN_GO -> TrojanGoSettingsActivity::class.java TYPE_NAIVE -> NaiveSettingsActivity::class.java TYPE_PING_TUNNEL -> PingTunnelSettingsActivity::class.java TYPE_RELAY_BATON -> RelayBatonSettingsActivity::class.java TYPE_BROOK -> BrookSettingsActivity::class.java TYPE_HYSTERIA -> HysteriaSettingsActivity::class.java TYPE_SNELL -> SnellSettingsActivity::class.java TYPE_SSH -> SSHSettingsActivity::class.java TYPE_WG -> WireGuardSettingsActivity::class.java TYPE_CONFIG -> ConfigSettingsActivity::class.java TYPE_CHAIN -> ChainSettingsActivity::class.java TYPE_BALANCER -> BalancerSettingsActivity::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 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 @Query("DELETE FROM proxy_entities WHERE groupId = :groupId") fun deleteAll(groupId: Long): Int } override fun describeContents(): Int { return 0 } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/database/ProxyGroup.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.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, ) : 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) } } } 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) } } } 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) } companion object CREATOR : 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 ================================================ /****************************************************************************** * * * 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.database import android.os.Parcelable import androidx.room.* import io.nekohasekai.sagernet.AppStatus import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.ktx.app import kotlinx.parcelize.Parcelize @Entity(tableName = "rules") @Parcelize data class RuleEntity( @PrimaryKey(autoGenerate = true) var id: Long = 0L, var name: 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 attrs: String = "", var outbound: Long = 0, var reverse: Boolean = false, var redirect: String = "", var packages: List = listOf(), var appStatus: List = listOf(), ) : Parcelable { fun isBypassRule(): Boolean { return (domains.isNotBlank() && ip.isBlank() || ip.isNotBlank() && domains.isBlank()) && port.isBlank() && sourcePort.isBlank() && network.isBlank() && source.isBlank() && protocol.isBlank() && attrs.isBlank() && !reverse && redirect.isBlank() && outbound == -1L && packages.isEmpty() && appStatus.isEmpty() } fun displayName(): String { return name.takeIf { it.isNotBlank() } ?: "Rule $id" } fun mkSummary(): String { var summary = "" if (domains.isNotBlank()) summary += "$domains\n" if (ip.isNotBlank()) summary += "$ip\n" if (sourcePort.isNotBlank()) summary += "$sourcePort\n" if (network.isNotBlank()) summary += "$network\n" if (source.isNotBlank()) summary += "$source\n" if (protocol.isNotBlank()) summary += "$protocol\n" if (attrs.isNotBlank()) summary += "$attrs\n" if (reverse) summary += "$redirect\n" if (packages.isNotEmpty()) summary += app.getString( R.string.apps_message, packages.size ) + "\n" if (appStatus.isNotEmpty()) summary += displayAppStatus().joinToString("\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 { if (reverse) { return app.getString(R.string.route_reverse) } 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.route_proxy) } } fun displayAppStatus(): List { return appStatus.map { when (it) { AppStatus.FOREGROUND -> app.getString(R.string.foreground) /*AppStatus.BACKGROUND*/ else -> app.getString(R.string.background) } } } @androidx.room.Dao interface Dao { @Query("SELECT * from rules WHERE (appStatus != '' OR 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 deleteAll() } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/database/SagerDatabase.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.database 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.database.preference.KeyValuePair import io.nekohasekai.sagernet.fmt.KryoConverters import io.nekohasekai.sagernet.fmt.gson.GsonConverters import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch @Database( entities = [ProxyGroup::class, ProxyEntity::class, RuleEntity::class, StatsEntity::class, KeyValuePair::class], version = 10 ) @TypeConverters(value = [KryoConverters::class, GsonConverters::class]) @GenerateRoomMigrations abstract class SagerDatabase : RoomDatabase() { companion object { @Suppress("EXPERIMENTAL_API_USAGE") private 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()) .allowMainThreadQueries() .enableMultiInstanceInvalidation() .fallbackToDestructiveMigration() .setQueryExecutor { GlobalScope.launch { it.run() } } .build() } val profileCacheDao get() = instance.profileCacheDao() val groupDao get() = instance.groupDao() val proxyDao get() = instance.proxyDao() val rulesDao get() = instance.rulesDao() val statsDao get() = instance.statsDao() } abstract fun profileCacheDao(): KeyValuePair.Dao abstract fun groupDao(): ProxyGroup.Dao abstract fun proxyDao(): ProxyEntity.Dao abstract fun rulesDao(): RuleEntity.Dao abstract fun statsDao(): StatsEntity.Dao } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/database/StatsEntity.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.database import android.os.Parcelable import androidx.room.* import io.nekohasekai.sagernet.aidl.AppStats import io.nekohasekai.sagernet.utils.PackageCache import kotlinx.parcelize.Parcelize @Entity( tableName = "stats", indices = [Index( "packageName", unique = true )] ) @Parcelize class StatsEntity( @PrimaryKey(autoGenerate = true) var id: Int = 0, var packageName: String = "", var tcpConnections: Int = 0, var udpConnections: Int = 0, var uplink: Long = 0L, var downlink: Long = 0L ) : Parcelable { fun toStats(): AppStats { return AppStats( packageName, PackageCache[packageName] ?: 1000, 0, 0, tcpConnections, udpConnections, 0, 0, uplink, downlink, 0 ) } @androidx.room.Dao interface Dao { @Query("SELECT * FROM stats") fun all(): List @Query("SELECT * FROM stats WHERE packageName = :packageName") operator fun get(packageName: String): StatsEntity? @Query("DELETE FROM stats WHERE packageName = :packageName") fun delete(packageName: String): Int @Insert fun create(stats: StatsEntity) @Update fun update(stats: List) @Query("DELETE FROM stats") fun deleteAll() } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/database/SubscriptionBean.java ================================================ /****************************************************************************** * * * 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.database; import androidx.annotation.NonNull; import com.esotericsoftware.kryo.io.ByteBufferInput; import com.esotericsoftware.kryo.io.ByteBufferOutput; import java.util.ArrayList; import java.util.LinkedHashSet; import java.util.List; import java.util.Set; import io.nekohasekai.sagernet.SubscriptionType; import io.nekohasekai.sagernet.fmt.Serializable; import io.nekohasekai.sagernet.ktx.KryosKt; public class SubscriptionBean extends Serializable { public Integer type; public String link; public String token; public Boolean forceResolve; public Boolean deduplication; public Boolean forceVMessAEAD; 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; public Set selectedGroups; public Set selectedOwners; public Set selectedTags; public SubscriptionBean() { } @Override public void serializeToBuffer(ByteBufferOutput output) { output.writeInt(1); output.writeInt(type); if (type == SubscriptionType.OOCv1) { output.writeString(token); } else { output.writeString(link); } output.writeBoolean(forceResolve); output.writeBoolean(deduplication); output.writeBoolean(forceVMessAEAD); output.writeBoolean(updateWhenConnectedOnly); output.writeString(customUserAgent); output.writeBoolean(autoUpdate); output.writeInt(autoUpdateDelay); output.writeInt(lastUpdated); if (type != SubscriptionType.RAW) { output.writeLong(bytesUsed); output.writeLong(bytesRemaining); } if (type == SubscriptionType.OOCv1) { output.writeString(username); output.writeInt(expiryDate); KryosKt.writeStringList(output, protocols); KryosKt.writeStringList(output, selectedGroups); KryosKt.writeStringList(output, selectedOwners); KryosKt.writeStringList(output, selectedTags); } } public void serializeForShare(ByteBufferOutput output) { output.writeInt(0); output.writeInt(type); if (type == SubscriptionType.OOCv1) { output.writeString(token); } else { output.writeString(link); } output.writeBoolean(forceResolve); output.writeBoolean(deduplication); output.writeBoolean(forceVMessAEAD); output.writeBoolean(updateWhenConnectedOnly); output.writeString(customUserAgent); if (type != SubscriptionType.RAW) { output.writeLong(bytesUsed); output.writeLong(bytesRemaining); } if (type == SubscriptionType.OOCv1) { output.writeString(username); output.writeInt(expiryDate); KryosKt.writeStringList(output, protocols); } } @Override public void deserializeFromBuffer(ByteBufferInput input) { int version = input.readInt(); type = input.readInt(); if (type == SubscriptionType.OOCv1) { token = input.readString(); } else { link = input.readString(); } forceResolve = input.readBoolean(); deduplication = input.readBoolean(); forceVMessAEAD = input.readBoolean(); updateWhenConnectedOnly = input.readBoolean(); customUserAgent = input.readString(); autoUpdate = input.readBoolean(); autoUpdateDelay = input.readInt(); lastUpdated = input.readInt(); if (type != SubscriptionType.RAW) { bytesUsed = input.readLong(); bytesRemaining = input.readLong(); } if (type == SubscriptionType.OOCv1) { username = input.readString(); expiryDate = input.readInt(); protocols = KryosKt.readStringList(input); if (input.canReadVarInt()) { selectedGroups = KryosKt.readStringSet(input); if (version >= 1) { selectedOwners = KryosKt.readStringSet(input); } selectedTags = KryosKt.readStringSet(input); } } } public void deserializeFromShare(ByteBufferInput input) { int version = input.readInt(); type = input.readInt(); if (type == SubscriptionType.OOCv1) { token = input.readString(); } else { link = input.readString(); } forceResolve = input.readBoolean(); deduplication = input.readBoolean(); forceVMessAEAD = input.readBoolean(); updateWhenConnectedOnly = input.readBoolean(); customUserAgent = input.readString(); if (type != SubscriptionType.RAW) { bytesUsed = input.readLong(); bytesRemaining = input.readLong(); } if (type == SubscriptionType.OOCv1) { username = input.readString(); expiryDate = input.readInt(); protocols = KryosKt.readStringList(input); } } @Override public void initializeDefaultValues() { if (type == null) type = SubscriptionType.RAW; if (link == null) link = ""; if (token == null) token = ""; if (forceResolve == null) forceResolve = false; if (deduplication == null) deduplication = false; if (forceVMessAEAD == null) forceVMessAEAD = 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<>(); if (selectedGroups == null) selectedGroups = new LinkedHashSet<>(); if (selectedOwners == null) selectedOwners = new LinkedHashSet<>(); if (selectedTags == null) selectedTags = new LinkedHashSet<>(); } 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 ================================================ /****************************************************************************** * * * Copyright (C) 2021 by nekohasekai * * Copyright (C) 2021 by Max Lv * * Copyright (C) 2021 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.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 ================================================ /****************************************************************************** * * * Copyright (C) 2021 by nekohasekai * * Copyright (C) 2021 by Max Lv * * Copyright (C) 2021 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.database.preference import androidx.room.* import java.io.ByteArrayOutputStream import java.nio.ByteBuffer @Entity class KeyValuePair() { 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 } @androidx.room.Dao interface Dao { @Query("SELECT * FROM `KeyValuePair`") fun all(): List @Query("DELETE FROM `KeyValuePair`") fun deleteAll(): Int @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 } @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" } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/database/preference/OnPreferenceDataStoreChangeListener.kt ================================================ /****************************************************************************** * * * Copyright (C) 2021 by nekohasekai * * Copyright (C) 2021 by Max Lv * * Copyright (C) 2021 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.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 ================================================ /****************************************************************************** * * * Copyright (C) 2021 by nekohasekai * * Copyright (C) 2021 by Max Lv * * Copyright (C) 2021 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.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 { private val instance by lazy { SagerNet.deviceStorage.getDatabasePath(Key.DB_PROFILE).parentFile?.mkdirs() Room.databaseBuilder(SagerNet.deviceStorage, PublicDatabase::class.java, Key.DB_PUBLIC) .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 ================================================ /****************************************************************************** * * * Copyright (C) 2021 by nekohasekai * * Copyright (C) 2021 by Max Lv * * Copyright (C) 2021 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.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 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 ================================================ /****************************************************************************** * * * 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.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.ArrayList; import java.util.Arrays; import java.util.List; import cn.hutool.core.clone.Cloneable; import cn.hutool.core.util.StrUtil; import cn.hutool.json.JSONUtil; import io.nekohasekai.sagernet.ExtraType; import io.nekohasekai.sagernet.fmt.gson.GsonsKt; import io.nekohasekai.sagernet.ktx.KryosKt; import io.nekohasekai.sagernet.ktx.NetsKt; public abstract class AbstractBean extends Serializable implements Cloneable { public String serverAddress; public Integer serverPort; public String name; public transient boolean isChain; public transient String finalAddress; public transient int finalPort; public int extraType; public String profileId; public String group; public String owner; public List tags; public String displayName() { if (StrUtil.isNotBlank(name)) { return name; } else { return serverAddress + ":" + serverPort; } } public String displayAddress() { return 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 (StrUtil.isBlank(serverAddress)) { serverAddress = "127.0.0.1"; } else if (serverAddress.startsWith("[") && serverAddress.endsWith("]")) { serverAddress = NetsKt.unwrapHost(serverAddress); } if (serverPort == null) { serverPort = 1080; } if (name == null) name = ""; finalAddress = serverAddress; finalPort = serverPort; if (profileId == null) profileId = ""; if (group == null) group = ""; if (tags == null) tags = new ArrayList<>(); } private transient boolean serializeWithoutName; @Override public void serializeToBuffer(@NonNull ByteBufferOutput output) { serialize(output); output.writeInt(1); if (!serializeWithoutName) { output.writeString(name); } output.writeInt(extraType); if (extraType == ExtraType.NONE) return; output.writeString(profileId); if (extraType == ExtraType.OOCv1) { output.writeString(group); output.writeString(owner); KryosKt.writeStringList(output, tags); } } @Override public void deserializeFromBuffer(@NonNull ByteBufferInput input) { deserialize(input); int extraVersion = input.readInt(); name = input.readString(); extraType = input.readInt(); if (extraType == ExtraType.NONE) return; profileId = input.readString(); if (extraType == ExtraType.OOCv1) { group = input.readString(); if (extraVersion >= 1) { owner = input.readString(); } tags = KryosKt.readStringList(input); } } 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() + " " + JSONUtil.formatJsonStr(GsonsKt.getGson().toJson(this)); } public void applyFeatureSettings(AbstractBean other) { } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.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.fmt import android.os.Build import cn.hutool.core.util.NumberUtil import cn.hutool.json.JSONArray import cn.hutool.json.JSONObject import com.google.gson.JsonSyntaxException import io.nekohasekai.sagernet.IPv6Mode import io.nekohasekai.sagernet.Key import io.nekohasekai.sagernet.bg.ForegroundDetectorService import io.nekohasekai.sagernet.bg.VpnService import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.database.ProxyEntity import io.nekohasekai.sagernet.database.SagerDatabase import io.nekohasekai.sagernet.fmt.V2rayBuildResult.IndexEntity import io.nekohasekai.sagernet.fmt.brook.BrookBean import io.nekohasekai.sagernet.fmt.gson.gson import io.nekohasekai.sagernet.fmt.http.HttpBean import io.nekohasekai.sagernet.fmt.internal.BalancerBean import io.nekohasekai.sagernet.fmt.internal.ChainBean import io.nekohasekai.sagernet.fmt.shadowsocks.ShadowsocksBean import io.nekohasekai.sagernet.fmt.socks.SOCKSBean import io.nekohasekai.sagernet.fmt.trojan.TrojanBean import io.nekohasekai.sagernet.fmt.v2ray.StandardV2RayBean import io.nekohasekai.sagernet.fmt.v2ray.V2RayConfig import io.nekohasekai.sagernet.fmt.v2ray.V2RayConfig.* import io.nekohasekai.sagernet.fmt.v2ray.VLESSBean import io.nekohasekai.sagernet.fmt.v2ray.VMessBean import io.nekohasekai.sagernet.ktx.isIpAddress import io.nekohasekai.sagernet.ktx.isRunning import io.nekohasekai.sagernet.ktx.mkPort import io.nekohasekai.sagernet.utils.PackageCache import okhttp3.HttpUrl.Companion.toHttpUrlOrNull const val TAG_SOCKS = "socks" const val TAG_HTTP = "http" const val TAG_TRANS = "trans" const val TAG_AGENT = "proxy" const val TAG_DIRECT = "direct" const val TAG_BYPASS = "bypass" const val TAG_BLOCK = "block" const val TAG_DNS_IN = "dns-in" const val TAG_DNS_OUT = "dns-out" const val TAG_API_IN = "api-in" const val LOCALHOST = "127.0.0.1" const val IP6_LOCALHOST = "::1" class V2rayBuildResult( var config: String, var index: List, var requireWs: Boolean, var outboundTags: List, var outboundTagsCurrent: List, var outboundTagsAll: Map, var bypassTag: String, var observatoryTags: Set, val dumpUid: Boolean, val alerts: List>, ) { data class IndexEntity(var isBalancer: Boolean, var chain: LinkedHashMap) } fun buildV2RayConfig( proxy: ProxyEntity, forTest: Boolean = false ): V2rayBuildResult { val outboundTags = ArrayList() val outboundTagsCurrent = ArrayList() val outboundTagsAll = HashMap() val globalOutbounds = ArrayList() fun ProxyEntity.resolveChain(): 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 when (item.type) { ProxyEntity.TYPE_BALANCER -> error("Balancer is incompatible with chain") ProxyEntity.TYPE_CONFIG -> error("Custom config is incompatible with chain") } beanList.addAll(item.resolveChain()) } return beanList.asReversed() } else if (bean is BalancerBean) { val beans = if (bean.type == BalancerBean.TYPE_LIST) { SagerDatabase.proxyDao.getEntities(bean.proxies) } else { SagerDatabase.proxyDao.getByGroup(bean.groupId) } val beansMap = beans.associateBy { it.id } val beanList = ArrayList() for (proxyId in beansMap.keys) { val item = beansMap[proxyId] ?: continue if (item.id == id) continue when (item.type) { ProxyEntity.TYPE_BALANCER -> error("Nested balancers are not supported") ProxyEntity.TYPE_CHAIN -> error("Chain is incompatible with balancer") } beanList.add(item) } return beanList } return mutableListOf(this) } val proxies = proxy.resolveChain() 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()).associate { (it.id to ((it.type == ProxyEntity.TYPE_BALANCER) to lazy { it.balancerBean!!.strategy })) to it.resolveChain() } val allowAccess = DataStore.allowAccess val bind = if (!forTest && 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 val trafficSniffing = DataStore.trafficSniffing val indexMap = ArrayList() var requireWs = false val requireHttp = !forTest && (Build.VERSION.SDK_INT <= Build.VERSION_CODES.M || DataStore.requireHttp) val requireTransproxy = if (forTest) false else DataStore.requireTransproxy val ipv6Mode = if (forTest) IPv6Mode.ENABLE else DataStore.ipv6Mode val resolveDestination = DataStore.resolveDestination val destinationOverride = DataStore.destinationOverride val trafficStatistics = !forTest && DataStore.profileTrafficStatistics val outboundDomainStrategy = when { !resolveDestination -> "AsIs" ipv6Mode == IPv6Mode.DISABLE -> "UseIPv4" ipv6Mode == IPv6Mode.PREFER -> "PreferIPv6" ipv6Mode == IPv6Mode.ONLY -> "UseIPv6" else -> "PreferIPv4" } var dumpUid = false val alerts = mutableListOf>() return V2RayConfig().apply { dns = DnsObject().apply { hosts = DataStore.hosts.split("\n") .filter { it.isNotBlank() } .associate { it.substringBefore(" ") to it.substringAfter(" ") } .toMutableMap() servers = mutableListOf() servers.addAll(remoteDns.map { DnsObject.StringOrServerObject().apply { valueY = DnsObject.ServerObject().apply { address = it concurrent = true } } }) disableFallbackIfMatch = true if (useFakeDns) { fakedns = FakeDnsObject().apply { ipPool = if (ipv6Mode == IPv6Mode.ONLY) { "${VpnService.FAKEDNS_VLAN6_CLIENT}/18" } else { "${VpnService.FAKEDNS_VLAN4_CLIENT}/15" } poolSize = 65535 } } when (ipv6Mode) { IPv6Mode.DISABLE -> { queryStrategy = "UseIPv4" } IPv6Mode.ONLY -> { queryStrategy = "UseIPv6" } } } log = LogObject().apply { loglevel = if (DataStore.enableLog) "debug" else "error" } policy = PolicyObject().apply { levels = mapOf( // dns "1" to PolicyObject.LevelPolicyObject().apply { connIdle = 30 }) if (trafficStatistics) { system = PolicyObject.SystemPolicyObject().apply { statsOutboundDownlink = true statsOutboundUplink = true } } } inbounds = mutableListOf() if (!forTest) inbounds.add(InboundObject().apply { tag = TAG_SOCKS listen = bind port = DataStore.socksPort protocol = "socks" settings = LazyInboundConfigurationObject(this, SocksInboundConfigurationObject().apply { auth = "noauth" udp = true }) if (trafficSniffing || useFakeDns) { sniffing = InboundObject.SniffingObject().apply { enabled = true destOverride = when { useFakeDns && !trafficSniffing -> listOf("fakedns") useFakeDns -> listOf("fakedns", "http", "tls") else -> listOf("http", "tls") } metadataOnly = useFakeDns && !trafficSniffing routeOnly = !destinationOverride } } }) if (requireHttp) { inbounds.add(InboundObject().apply { tag = TAG_HTTP listen = bind port = DataStore.httpPort protocol = "http" settings = LazyInboundConfigurationObject(this, HTTPInboundConfigurationObject().apply { allowTransparent = true }) if (trafficSniffing || useFakeDns) { sniffing = InboundObject.SniffingObject().apply { enabled = true destOverride = when { useFakeDns && !trafficSniffing -> listOf("fakedns") useFakeDns -> listOf("fakedns", "http", "tls") else -> listOf("http", "tls") } metadataOnly = useFakeDns && !trafficSniffing routeOnly = !destinationOverride } } }) } if (requireTransproxy) { inbounds.add(InboundObject().apply { tag = TAG_TRANS listen = bind port = DataStore.transproxyPort protocol = "dokodemo-door" settings = LazyInboundConfigurationObject(this, DokodemoDoorInboundConfigurationObject().apply { network = "tcp,udp" followRedirect = true }) if (trafficSniffing || useFakeDns) { sniffing = InboundObject.SniffingObject().apply { enabled = true destOverride = when { useFakeDns && !trafficSniffing -> listOf("fakedns") useFakeDns -> listOf("fakedns", "http", "tls") else -> listOf("http", "tls") } metadataOnly = useFakeDns && !trafficSniffing routeOnly = !destinationOverride } } when (DataStore.transproxyMode) { 1 -> streamSettings = StreamSettingsObject().apply { sockopt = StreamSettingsObject.SockoptObject().apply { tproxy = "tproxy" } } } }) } outbounds = mutableListOf() routing = RoutingObject().apply { domainStrategy = DataStore.domainStrategy rules = mutableListOf() val wsRules = HashMap() for (proxyEntity in proxies) { val bean = proxyEntity.requireBean() if (bean is StandardV2RayBean && bean.type == "ws" && bean.wsUseBrowserForwarder == true) { val route = RoutingObject.RuleObject().apply { type = "field" outboundTag = TAG_DIRECT when { bean.host.isIpAddress() -> { ip = listOf(bean.host) } bean.host.isNotBlank() -> { domain = listOf(bean.host) } bean.serverAddress.isIpAddress() -> { ip = listOf(bean.serverAddress) } else -> domain = listOf(bean.serverAddress) } } wsRules[bean.host.takeIf { !it.isNullOrBlank() } ?: bean.serverAddress] = route } } rules.addAll(wsRules.values) if (DataStore.bypassLan && (requireHttp || DataStore.bypassLanInCoreOnly)) { rules.add(RoutingObject.RuleObject().apply { type = "field" outboundTag = TAG_BYPASS ip = listOf("geoip:private") }) } } val needIncludeSelf = proxy.balancerBean == null && proxies.size > 1 || extraProxies.any { (key, value) -> val (_, balancer) = key val (isBalancer, _) = balancer isBalancer && value.size > 1 } var rootBalancer: RoutingObject.RuleObject? = null val utlsFingerprint = DataStore.utlsFingerprint fun buildChain( tagOutbound: String, profileList: List, isBalancer: Boolean, balancerStrategy: (() -> String) ): String { var pastExternal = false lateinit var pastOutbound: OutboundObject lateinit var currentOutbound: OutboundObject lateinit var pastInboundTag: String val chainMap = LinkedHashMap() indexMap.add(IndexEntity(isBalancer, chainMap)) val chainOutbounds = ArrayList() var chainOutbound = "" profileList.forEachIndexed { index, proxyEntity -> val bean = proxyEntity.requireBean() currentOutbound = OutboundObject() val tagIn: String val needGlobal: Boolean if (isBalancer || index == profileList.lastIndex && !pastExternal) { tagIn = "$TAG_AGENT-global-${proxyEntity.id}" needGlobal = true } else { tagIn = if (index == 0) tagOutbound else { "$tagOutbound-${proxyEntity.id}" } needGlobal = false } if (index == profileList.lastIndex) { chainOutbound = tagIn } if (needGlobal) { if (globalOutbounds.contains(tagIn)) { return@forEachIndexed } globalOutbounds.add(tagIn) } outboundTagsAll[tagIn] = proxyEntity if (isBalancer || index == 0) { outboundTags.add(tagIn) if (tagOutbound == TAG_AGENT) { outboundTagsCurrent.add(tagIn) } } if (proxyEntity.needExternal()) { val localPort = mkPort() chainMap[localPort] = proxyEntity currentOutbound.apply { protocol = "socks" settings = LazyOutboundConfigurationObject(this, SocksOutboundConfigurationObject().apply { servers = listOf(SocksOutboundConfigurationObject.ServerObject() .apply { address = LOCALHOST port = localPort }) }) } } else { currentOutbound.apply { if (bean is SOCKSBean) { protocol = "socks" settings = LazyOutboundConfigurationObject(this, SocksOutboundConfigurationObject().apply { servers = listOf(SocksOutboundConfigurationObject.ServerObject() .apply { address = bean.serverAddress port = bean.serverPort if (!bean.username.isNullOrBlank()) { users = listOf(SocksOutboundConfigurationObject.ServerObject.UserObject() .apply { user = bean.username pass = bean.password }) } }) version = bean.protocolVersionName() }) if (bean.tls) { streamSettings = StreamSettingsObject().apply { network = "tcp" if (bean.tls) { security = "tls" tlsSettings = TLSObject().apply { if (bean.sni.isNotBlank()) { serverName = bean.sni } if (utlsFingerprint.isNotBlank()) { fingerprint = utlsFingerprint } } } } } } else if (bean is HttpBean) { protocol = "http" settings = LazyOutboundConfigurationObject(this, HTTPOutboundConfigurationObject().apply { servers = listOf(HTTPOutboundConfigurationObject.ServerObject() .apply { address = bean.serverAddress port = bean.serverPort if (!bean.username.isNullOrBlank()) { users = listOf(HTTPInboundConfigurationObject.AccountObject() .apply { user = bean.username pass = bean.password }) } }) }) if (bean.tls) { streamSettings = StreamSettingsObject().apply { network = "tcp" if (bean.tls) { security = "tls" tlsSettings = TLSObject().apply { if (bean.sni.isNotBlank()) { serverName = bean.sni } if (utlsFingerprint.isNotBlank()) { fingerprint = utlsFingerprint } } } } } } else if (bean is StandardV2RayBean) { if (bean is VMessBean) { protocol = "vmess" settings = LazyOutboundConfigurationObject(this, VMessOutboundConfigurationObject().apply { vnext = listOf(VMessOutboundConfigurationObject.ServerObject() .apply { address = bean.serverAddress port = bean.serverPort users = listOf(VMessOutboundConfigurationObject.ServerObject.UserObject() .apply { id = bean.uuidOrGenerate() alterId = bean.alterId security = bean.encryption.takeIf { it.isNotBlank() } ?: "auto" experimental = "" if (bean.experimentalAuthenticatedLength) { experimental += "AuthenticatedLength" } if (bean.experimentalNoTerminationSignal) { experimental += "NoTerminationSignal" } if (experimental.isBlank()) experimental = null; }) }) }) } else if (bean is VLESSBean) { protocol = "vless" settings = LazyOutboundConfigurationObject(this, VLESSOutboundConfigurationObject().apply { vnext = listOf(VLESSOutboundConfigurationObject.ServerObject() .apply { address = bean.serverAddress port = bean.serverPort users = listOf(VLESSOutboundConfigurationObject.ServerObject.UserObject() .apply { id = bean.uuidOrGenerate() encryption = bean.encryption if (bean.security == "xtls") { flow = bean.flow } }) }) }) } streamSettings = StreamSettingsObject().apply { network = bean.type if (bean.security.isNotBlank()) { security = bean.security } val settings = TLSObject().apply { if (bean.sni.isNotBlank()) { serverName = bean.sni } if (bean.alpn.isNotBlank()) { alpn = bean.alpn.split("\n") } if (bean.certificates.isNotBlank()) { disableSystemRoot = true certificates = listOf(TLSObject.CertificateObject().apply { usage = "verify" certificate = bean.certificates.split( "\n" ).filter { it.isNotBlank() } }) } if (bean.allowInsecure) { allowInsecure = true } if (utlsFingerprint.isNotBlank()) { fingerprint = utlsFingerprint } } when (security) { "tls" -> tlsSettings = settings "xtls" -> xtlsSettings = settings } when (network) { "tcp" -> { tcpSettings = TcpObject().apply { if (bean.headerType == "http") { header = TcpObject.HeaderObject().apply { type = "http" if (bean.host.isNotBlank() || bean.path.isNotBlank()) { request = TcpObject.HeaderObject.HTTPRequestObject() .apply { headers = mutableMapOf() if (bean.host.isNotBlank()) { headers["Host"] = TcpObject.HeaderObject.StringOrListObject() .apply { valueY = bean.host.split( "," ).map { it.trim() } } } if (bean.path.isNotBlank()) { path = bean.path.split(",") } } } } } } } "kcp" -> { kcpSettings = KcpObject().apply { mtu = 1350 tti = 50 uplinkCapacity = 12 downlinkCapacity = 100 congestion = false readBufferSize = 1 writeBufferSize = 1 header = KcpObject.HeaderObject().apply { type = bean.headerType } if (bean.mKcpSeed.isNotBlank()) { seed = bean.mKcpSeed } } } "ws" -> { wsSettings = WebSocketObject().apply { headers = mutableMapOf() if (bean.host.isNotBlank()) { headers["Host"] = bean.host } path = bean.path.takeIf { it.isNotBlank() } ?: "/" if (bean.wsUseBrowserForwarder) { requireWs = true } } } "http" -> { network = "http" httpSettings = HttpObject().apply { if (bean.host.isNotBlank()) { host = bean.host.split(",") } path = bean.path.takeIf { it.isNotBlank() } ?: "/" } } "quic" -> { quicSettings = QuicObject().apply { security = bean.quicSecurity.takeIf { it.isNotBlank() } ?: "none" key = bean.quicKey header = QuicObject.HeaderObject().apply { type = bean.headerType.takeIf { it.isNotBlank() } ?: "none" } } } "grpc" -> { grpcSettings = GrpcObject().apply { serviceName = bean.grpcServiceName multiMode = bean.grpcMultiMode } } } } } else if (bean is ShadowsocksBean) { protocol = "shadowsocks" settings = LazyOutboundConfigurationObject(this, ShadowsocksOutboundConfigurationObject().apply { servers = listOf(ShadowsocksOutboundConfigurationObject.ServerObject() .apply { address = bean.serverAddress port = bean.serverPort method = bean.method password = bean.password }) }) } else if (bean is TrojanBean) { protocol = "trojan" settings = LazyOutboundConfigurationObject(this, TrojanOutboundConfigurationObject().apply { servers = listOf(TrojanOutboundConfigurationObject.ServerObject() .apply { address = bean.serverAddress port = bean.serverPort password = bean.password if (bean.security == "xtls") { flow = bean.flow } }) }) streamSettings = StreamSettingsObject().apply { network = "tcp" security = bean.security val settings = TLSObject().apply { if (bean.sni.isNotBlank()) { serverName = bean.sni } if (bean.alpn.isNotBlank()) { alpn = bean.alpn.split("\n") } if (utlsFingerprint.isNotBlank()) { fingerprint = utlsFingerprint } } when (security) { "tls" -> tlsSettings = settings "xtls" -> xtlsSettings = settings } } } if ((isBalancer || index == 0) && proxyEntity.needCoreMux() && DataStore.enableMux) { mux = OutboundObject.MuxObject().apply { enabled = true concurrency = DataStore.muxConcurrency } } } } currentOutbound.tag = tagIn currentOutbound.domainStrategy = outboundDomainStrategy if (!isBalancer && index > 0) { if (!pastExternal) { pastOutbound.proxySettings = OutboundObject.ProxySettingsObject().apply { tag = tagIn transportLayer = true } } else { routing.rules.add(RoutingObject.RuleObject().apply { type = "field" inboundTag = listOf(pastInboundTag) outboundTag = tagIn }) } } if (proxyEntity.needExternal() && !isBalancer && index != profileList.lastIndex) { val mappingPort = mkPort() when (bean) { is BrookBean -> { dns.hosts[bean.serverAddress] = LOCALHOST } else -> { bean.finalAddress = LOCALHOST } } bean.finalPort = mappingPort bean.isChain = true inbounds.add(InboundObject().apply { listen = LOCALHOST port = mappingPort tag = "$tagOutbound-mapping-${proxyEntity.id}" protocol = "dokodemo-door" settings = LazyInboundConfigurationObject(this, DokodemoDoorInboundConfigurationObject().apply { address = bean.serverAddress network = bean.network() port = bean.serverPort }) pastInboundTag = tag }) } else if (bean.canMapping() && proxyEntity.needExternal() && needIncludeSelf) { val mappingPort = mkPort() when (bean) { is BrookBean -> { dns.hosts[bean.serverAddress] = LOCALHOST } else -> { bean.finalAddress = LOCALHOST } } bean.finalPort = mappingPort inbounds.add(InboundObject().apply { listen = LOCALHOST port = mappingPort tag = "$tagOutbound-mapping-${proxyEntity.id}" protocol = "dokodemo-door" settings = LazyInboundConfigurationObject(this, DokodemoDoorInboundConfigurationObject().apply { address = bean.serverAddress network = bean.network() port = bean.serverPort }) routing.rules.add(RoutingObject.RuleObject().apply { type = "field" inboundTag = listOf(tag) outboundTag = TAG_DIRECT }) }) } outbounds.add(currentOutbound) chainOutbounds.add(currentOutbound) pastExternal = proxyEntity.needExternal() pastOutbound = currentOutbound } if (isBalancer) { if (routing.balancers == null) routing.balancers = ArrayList() routing.balancers.add(RoutingObject.BalancerObject().apply { tag = "balancer-$tagOutbound" selector = chainOutbounds.map { it.tag } if (observatory == null) observatory = ObservatoryObject().apply { probeUrl = DataStore.connectionTestURL /*val testInterval = DataStore.probeInterval if (testInterval > 0) { probeInterval = "${testInterval}s" }*/ } if (observatory.subjectSelector == null) observatory.subjectSelector = HashSet() observatory.subjectSelector.addAll(chainOutbounds.map { it.tag }) strategy = RoutingObject.BalancerObject.StrategyObject().apply { type = balancerStrategy().takeIf { it.isNotBlank() } ?: "random" } }) if (tagOutbound == TAG_AGENT) { rootBalancer = RoutingObject.RuleObject().apply { type = "field" inboundTag = mutableListOf() if (!forTest) { inboundTag.add(TAG_SOCKS) } if (requireHttp) inboundTag.add(TAG_HTTP) if (requireTransproxy) inboundTag.add(TAG_TRANS) balancerTag = "balancer-$tagOutbound" } /* outbounds.add(0, OutboundObject().apply { protocol = "loopback" settings = LazyOutboundConfigurationObject(this, LoopbackOutboundConfigurationObject().apply { inboundTag = TAG_SOCKS }) })*/ } } return chainOutbound } val tagProxy = buildChain( TAG_AGENT, proxies, proxy.balancerBean != null ) { proxy.balancerBean!!.strategy } val balancerMap = mutableMapOf() val tagMap = mutableMapOf() extraProxies.forEach { (key, entities) -> val (id, balancer) = key val (isBalancer, strategy) = balancer tagMap[id] = buildChain("$TAG_AGENT-$id", entities, isBalancer, strategy::value) if (isBalancer) { balancerMap[id] = "balancer-$TAG_AGENT-$id" } } val notVpn = DataStore.serviceMode != Key.MODE_VPN val foregroundDetectorServiceStarted = ForegroundDetectorService::class.isRunning() for (rule in extraRules) { if (rule.packages.isNotEmpty() || rule.appStatus.isNotEmpty()) { dumpUid = true if (notVpn) { alerts.add(0 to rule.displayName()) continue } } if (rule.appStatus.isNotEmpty() && !foregroundDetectorServiceStarted) { alerts.add(1 to rule.displayName()) } routing.rules.add(RoutingObject.RuleObject().apply { type = "field" if (rule.packages.isNotEmpty()) { PackageCache.awaitLoadSync() uidList = rule.packages.map { PackageCache[it]?.takeIf { uid -> uid >= 10000 } ?: 1000 }.toHashSet().toList() } if (rule.appStatus.isNotEmpty()) { appStatus = rule.appStatus } if (rule.domains.isNotBlank()) { domain = rule.domains.split("\n") } if (rule.ip.isNotBlank()) { ip = rule.ip.split("\n") } if (rule.port.isNotBlank()) { port = rule.port } if (rule.sourcePort.isNotBlank()) { sourcePort = rule.sourcePort } if (rule.network.isNotBlank()) { network = rule.network } if (rule.source.isNotBlank()) { source = rule.source.split("\n") } if (rule.protocol.isNotBlank()) { protocol = rule.protocol.split("\n") } if (rule.attrs.isNotBlank()) { attrs = rule.attrs } when { rule.reverse -> inboundTag = listOf("reverse-${rule.id}") balancerMap.containsKey(rule.outbound) -> { balancerTag = balancerMap[rule.outbound] } else -> outboundTag = when (val outId = rule.outbound) { 0L -> tagProxy -1L -> TAG_BYPASS -2L -> TAG_BLOCK else -> if (outId == proxy.id) tagProxy else tagMap[outId] } } }) if (rule.reverse) { outbounds.add(OutboundObject().apply { tag = "reverse-out-${rule.id}" protocol = "freedom" settings = LazyOutboundConfigurationObject(this, FreedomOutboundConfigurationObject().apply { redirect = rule.redirect }) }) if (reverse == null) { reverse = ReverseObject().apply { bridges = ArrayList() } } reverse.bridges.add(ReverseObject.BridgeObject().apply { tag = "reverse-${rule.id}" domain = rule.domains.substringAfter("full:") }) routing.rules.add(RoutingObject.RuleObject().apply { type = "field" inboundTag = listOf("reverse-${rule.id}") outboundTag = "reverse-out-${rule.id}" }) } } for (freedom in arrayOf(TAG_DIRECT, TAG_BYPASS)) outbounds.add(OutboundObject().apply { tag = freedom protocol = "freedom" }) outbounds.add(OutboundObject().apply { tag = TAG_BLOCK protocol = "blackhole" /* settings = LazyOutboundConfigurationObject(this, BlackholeOutboundConfigurationObject().apply { keepConnection = true })*/ }) if (!forTest) { inbounds.add(InboundObject().apply { tag = TAG_DNS_IN listen = bind port = DataStore.localDNSPort protocol = "dokodemo-door" settings = LazyInboundConfigurationObject(this, DokodemoDoorInboundConfigurationObject().apply { address = if (!remoteDns.first().isIpAddress()) { "1.0.0.1" } else { remoteDns.first() } network = "tcp,udp" port = 53 }) }) outbounds.add(OutboundObject().apply { protocol = "dns" tag = TAG_DNS_OUT settings = LazyOutboundConfigurationObject(this, DNSOutboundConfigurationObject().apply { userLevel = 1 var dns = remoteDns.first() if (dns.contains(":")) { val lPort = dns.substringAfterLast(":") dns = dns.substringBeforeLast(":") if (NumberUtil.isInteger(lPort)) { port = lPort.toInt() } } if (dns.isIpAddress()) { address = dns } else if (dns.contains("://")) { network = "tcp" address = dns.substringAfter("://") } }) }) } for (dns in remoteDns) { if (!dns.isIpAddress()) continue routing.rules.add(0, RoutingObject.RuleObject().apply { type = "field" outboundTag = tagProxy ip = listOf(dns) }) } for (dns in directDNS) { if (!dns.isIpAddress()) continue routing.rules.add(0, RoutingObject.RuleObject().apply { type = "field" outboundTag = TAG_DIRECT ip = listOf(dns) }) } val bypassIP = HashSet() val bypassDomain = HashSet() (proxies + extraProxies.values.flatten()).filter { !it.requireBean().isChain }.forEach { it.requireBean().apply { if (!serverAddress.isIpAddress()) { bypassDomain.add("full:$serverAddress") } else { bypassIP.add(serverAddress) } } } if (bypassIP.isNotEmpty()) { routing.rules.add(0, RoutingObject.RuleObject().apply { type = "field" ip = bypassIP.toList() outboundTag = TAG_DIRECT }) } if (enableDnsRouting) { for (bypassRule in extraRules.filter { it.isBypassRule() }) { if (bypassRule.domains.isNotBlank()) { bypassDomain.addAll(bypassRule.domains.split("\n")) } } } remoteDns.forEach { var address = it if (address.contains("://")) { address = address.substringAfter("://") } "https://$address".toHttpUrlOrNull()?.apply { if (!host.isIpAddress()) { bypassDomain.add("full:$host") } } } if (bypassDomain.isNotEmpty()) { dns.servers.addAll(directDNS.map { DnsObject.StringOrServerObject().apply { valueY = DnsObject.ServerObject().apply { address = it domains = bypassDomain.toList() skipFallback = true concurrent = true } } }) } if (useFakeDns) { dns.servers.add(0, DnsObject.StringOrServerObject().apply { valueX = "fakedns" }) } if (!forTest) routing.rules.add(0, RoutingObject.RuleObject().apply { type = "field" inboundTag = listOf(TAG_DNS_IN) outboundTag = TAG_DNS_OUT }) if (allowAccess) { // temp: fix crash routing.rules.add(RoutingObject.RuleObject().apply { type = "field" ip = listOf("255.255.255.255") outboundTag = TAG_BLOCK }) } if (rootBalancer != null) routing.rules.add(rootBalancer) if (trafficStatistics) stats = emptyMap() }.let { V2rayBuildResult( gson.toJson(it), indexMap, requireWs, outboundTags, outboundTagsCurrent, outboundTagsAll, TAG_BYPASS, it.observatory?.subjectSelector ?: HashSet(), dumpUid, alerts ) } } fun buildCustomConfig(proxy: ProxyEntity, port: Int): V2rayBuildResult { val bind = LOCALHOST val trafficSniffing = DataStore.trafficSniffing val bean = proxy.configBean!! val config = JSONObject(bean.content) val inbounds = config.getJSONArray("inbounds") ?.filterIsInstance() ?.map { gson.fromJson(it.toString(), InboundObject::class.java) } ?.toMutableList() ?: ArrayList() val dnsArr = config.getJSONObject("dns")?.getJSONArray("servers")?.map { if (it is String) DnsObject.StringOrServerObject().apply { valueX = it } else DnsObject.StringOrServerObject().apply { valueY = gson.fromJson(it.toString(), DnsObject.ServerObject::class.java) } } val ipv6Mode = DataStore.ipv6Mode var socksInbound = inbounds.find { it.tag == TAG_SOCKS }?.apply { if (protocol != "socks") error("Inbound $tag with type $protocol, excepted socks.") } if (socksInbound == null) { val socksInbounds = inbounds.filter { it.protocol == "socks" } if (socksInbounds.size == 1) { socksInbound = socksInbounds[0] } } if (socksInbound != null) { socksInbound.apply { listen = bind this.port = port } } else { inbounds.add(InboundObject().apply { tag = TAG_SOCKS listen = bind this.port = port protocol = "socks" settings = LazyInboundConfigurationObject(this, SocksInboundConfigurationObject().apply { auth = "noauth" udp = true }) if (trafficSniffing) { sniffing = InboundObject.SniffingObject().apply { enabled = true destOverride = listOf("http", "tls") metadataOnly = false } } }) } var requireWs = false var wsPort = 0 if (config.contains("browserForwarder")) { requireWs = true } val outbounds = try { config.getJSONArray("outbounds")?.filterIsInstance()?.map { gson.fromJson(it.toString().takeIf { it.isNotBlank() } ?: "{}", OutboundObject::class.java) }?.toMutableList() } catch (e: JsonSyntaxException) { null } var flushOutbounds = false val outboundTags = ArrayList() val firstOutbound = outbounds?.get(0) if (firstOutbound != null) { if (firstOutbound.tag == null) { firstOutbound.tag = TAG_AGENT outboundTags.add(TAG_AGENT) flushOutbounds = true } else { outboundTags.add(firstOutbound.tag) } } var directTag = "" val directOutbounds = outbounds?.filter { it.protocol == "freedom" } if (!directOutbounds.isNullOrEmpty()) { val directOutbound = if (directOutbounds.size == 1) { directOutbounds[0] } else { val directOutboundsWithTag = directOutbounds.filter { it.tag != null } if (directOutboundsWithTag.isNotEmpty()) { directOutboundsWithTag[0] } else { directOutbounds[0] } } if (directOutbound.tag.isNullOrBlank()) { directOutbound.tag = TAG_DIRECT flushOutbounds = true } directTag = directOutbound.tag } inbounds.forEach { it.init() } config["inbounds"] = JSONArray(inbounds.map { JSONObject(gson.toJson(it)) }) if (flushOutbounds) { outbounds!!.forEach { it.init() } config["outbounds"] = JSONArray(outbounds.map { JSONObject(gson.toJson(it)) }) } return V2rayBuildResult( config.toStringPretty(), emptyList(), requireWs, outboundTags, outboundTags, emptyMap(), directTag, emptySet(), false, emptyList() ) } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/fmt/KryoConverters.java ================================================ /****************************************************************************** * * * 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.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 cn.hutool.core.io.IoUtil; import cn.hutool.core.util.ArrayUtil; import io.nekohasekai.sagernet.database.SubscriptionBean; import io.nekohasekai.sagernet.fmt.brook.BrookBean; import io.nekohasekai.sagernet.fmt.http.HttpBean; import io.nekohasekai.sagernet.fmt.hysteria.HysteriaBean; import io.nekohasekai.sagernet.fmt.internal.BalancerBean; import io.nekohasekai.sagernet.fmt.internal.ChainBean; import io.nekohasekai.sagernet.fmt.internal.ConfigBean; import io.nekohasekai.sagernet.fmt.naive.NaiveBean; import io.nekohasekai.sagernet.fmt.pingtunnel.PingTunnelBean; import io.nekohasekai.sagernet.fmt.relaybaton.RelayBatonBean; import io.nekohasekai.sagernet.fmt.shadowsocks.ShadowsocksBean; import io.nekohasekai.sagernet.fmt.shadowsocksr.ShadowsocksRBean; import io.nekohasekai.sagernet.fmt.snell.SnellBean; 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.v2ray.VLESSBean; 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; 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); IoUtil.flush(buffer); IoUtil.close(buffer); return out.toByteArray(); } public static T deserialize(T bean, byte[] bytes) { 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 (ArrayUtil.isEmpty(bytes)) return null; return deserialize(new SOCKSBean(), bytes); } @TypeConverter public static HttpBean httpDeserialize(byte[] bytes) { if (ArrayUtil.isEmpty(bytes)) return null; return deserialize(new HttpBean(), bytes); } @TypeConverter public static ShadowsocksBean shadowsocksDeserialize(byte[] bytes) { if (ArrayUtil.isEmpty(bytes)) return null; return deserialize(new ShadowsocksBean(), bytes); } @TypeConverter public static ShadowsocksRBean shadowsocksRDeserialize(byte[] bytes) { if (ArrayUtil.isEmpty(bytes)) return null; return deserialize(new ShadowsocksRBean(), bytes); } @TypeConverter public static VMessBean vmessDeserialize(byte[] bytes) { if (ArrayUtil.isEmpty(bytes)) return null; return deserialize(new VMessBean(), bytes); } @TypeConverter public static VLESSBean vlessDeserialize(byte[] bytes) { if (ArrayUtil.isEmpty(bytes)) return null; return deserialize(new VLESSBean(), bytes); } @TypeConverter public static TrojanBean trojanDeserialize(byte[] bytes) { if (ArrayUtil.isEmpty(bytes)) return null; return deserialize(new TrojanBean(), bytes); } @TypeConverter public static TrojanGoBean trojanGoDeserialize(byte[] bytes) { if (ArrayUtil.isEmpty(bytes)) return null; return deserialize(new TrojanGoBean(), bytes); } @TypeConverter public static NaiveBean naiveDeserialize(byte[] bytes) { if (ArrayUtil.isEmpty(bytes)) return null; return deserialize(new NaiveBean(), bytes); } @TypeConverter public static PingTunnelBean pingTunnelDeserialize(byte[] bytes) { if (ArrayUtil.isEmpty(bytes)) return null; return deserialize(new PingTunnelBean(), bytes); } @TypeConverter public static RelayBatonBean relayBatonDeserialize(byte[] bytes) { if (ArrayUtil.isEmpty(bytes)) return null; return deserialize(new RelayBatonBean(), bytes); } @TypeConverter public static BrookBean brookDeserialize(byte[] bytes) { if (ArrayUtil.isEmpty(bytes)) return null; return deserialize(new BrookBean(), bytes); } @TypeConverter public static HysteriaBean hysteriaDeserialize(byte[] bytes) { if (ArrayUtil.isEmpty(bytes)) return null; return deserialize(new HysteriaBean(), bytes); } @TypeConverter public static SnellBean snellDeserialize(byte[] bytes) { if (ArrayUtil.isEmpty(bytes)) return null; return deserialize(new SnellBean(), bytes); } @TypeConverter public static SSHBean sshDeserialize(byte[] bytes) { if (ArrayUtil.isEmpty(bytes)) return null; return deserialize(new SSHBean(), bytes); } @TypeConverter public static WireGuardBean wireguardDeserialize(byte[] bytes) { if (ArrayUtil.isEmpty(bytes)) return null; return deserialize(new WireGuardBean(), bytes); } @TypeConverter public static ConfigBean configDeserialize(byte[] bytes) { if (ArrayUtil.isEmpty(bytes)) return null; return deserialize(new ConfigBean(), bytes); } @TypeConverter public static ChainBean chainDeserialize(byte[] bytes) { if (ArrayUtil.isEmpty(bytes)) return null; return deserialize(new ChainBean(), bytes); } @TypeConverter public static BalancerBean balancerBeanDeserialize(byte[] bytes) { if (ArrayUtil.isEmpty(bytes)) return null; return deserialize(new BalancerBean(), bytes); } @TypeConverter public static SubscriptionBean subscriptionDeserialize(byte[] bytes) { if (ArrayUtil.isEmpty(bytes)) return null; return deserialize(new SubscriptionBean(), bytes); } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/fmt/PluginEntry.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.fmt import androidx.annotation.StringRes import io.nekohasekai.sagernet.R enum class PluginEntry( val pluginId: String, @StringRes val nameId: Int, val packageName: String, val downloadSource: DownloadSource = DownloadSource() ) { // sagernet plugins TrojanGo("trojan-go-plugin", R.string.action_trojan_go, "io.nekohasekai.sagernet.plugin.trojan_go"), NaiveProxy("naive-plugin", R.string.action_naive, "io.nekohasekai.sagernet.plugin.naive"), PingTunnel("pingtunnel-plugin", R.string.action_ping_tunnel, "io.nekohasekai.sagernet.plugin.pingtunnel"), RelayBaton("relaybaton-plugin", R.string.action_relay_baton, "io.nekohasekai.sagernet.plugin.relaybaton"), Brook("brook-plugin", R.string.action_brook, "io.nekohasekai.sagernet.plugin.brook"), Hysteria("hysteria-plugin", R.string.action_hysteria, "io.nekohasekai.sagernet.plugin.hysteria", DownloadSource(fdroid = false)), WireGuard("wireguard-plugin", R.string.action_wireguard, "io.nekohasekai.sagernet.plugin.wireguard", DownloadSource(fdroid = false)), // shadowsocks plugins ObfsLocal("shadowsocks-obfs-local", R.string.shadowsocks_plugin_simple_obfs, "com.github.shadowsocks.plugin.obfs_local", DownloadSource( fdroid = false, downloadLink = "https://github.com/shadowsocks/simple-obfs-android/releases" )), V2RayPlugin("shadowsocks-v2ray-plugin", R.string.shadowsocks_plugin_v2ray, "com.github.shadowsocks.plugin.v2ray", DownloadSource( downloadLink = "https://github.com/shadowsocks/v2ray-plugin-android/releases" )); data class DownloadSource( val playStore: Boolean = true, val fdroid: Boolean = true, val downloadLink: String = "https://sagernet.org/download/" ) 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 ================================================ /****************************************************************************** * * * Copyright (C) 2021 by nekohasekai @neko.services> * * Copyright (C) 2021 by Max Lv @gmail.com> * * Copyright (C) 2021 by Mygod Studio @mygod.be> * * * * 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 //www.gnu.org/licenses/>. * * * */ 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) { val byteArray = KryoConverters.serialize(this) dest.writeInt(byteArray.size) dest.writeByteArray(byteArray) } abstract class CREATOR : Parcelable.Creator { abstract fun newInstance(): T override fun createFromParcel(source: Parcel): T { val byteArray = ByteArray(source.readInt()) source.readByteArray(byteArray) return KryoConverters.deserialize(newInstance(), byteArray) } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/fmt/TypeMap.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.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["ssr"] = ProxyEntity.TYPE_SSR this["vmess"] = ProxyEntity.TYPE_VMESS this["vless"] = ProxyEntity.TYPE_VLESS this["trojan"] = ProxyEntity.TYPE_TROJAN this["trojan-go"] = ProxyEntity.TYPE_TROJAN_GO this["naive"] = ProxyEntity.TYPE_NAIVE this["pt"] = ProxyEntity.TYPE_PING_TUNNEL this["rb"] = ProxyEntity.TYPE_RELAY_BATON this["brook"] = ProxyEntity.TYPE_BROOK this["config"] = ProxyEntity.TYPE_CONFIG this["hysteria"] = ProxyEntity.TYPE_HYSTERIA this["snell"] = ProxyEntity.TYPE_SNELL this["ssh"] = ProxyEntity.TYPE_SSH this["wg"] = ProxyEntity.TYPE_WG } val reversed = HashMap() init { TypeMap.forEach { (key, type) -> reversed[type] = key } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/fmt/UniversalFmt.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.fmt import cn.hutool.core.codec.Base64Decoder import cn.hutool.core.codec.Base64Encoder import cn.hutool.core.util.ZipUtil import io.nekohasekai.sagernet.database.ProxyEntity import io.nekohasekai.sagernet.database.ProxyGroup fun parseUniversal(link: String): AbstractBean { return if (link.contains("?")) { val type = link.substringAfter("ax://").substringBefore("?") ProxyEntity(type = TypeMap[type] ?: error("Type $type not found")).apply { putByteArray(ZipUtil.unZlib(Base64Decoder.decode(link.substringAfter("?")))) }.requireBean() } else { val type = link.substringAfter("ax://").substringBefore(":") ProxyEntity(type = TypeMap[type] ?: error("Type $type not found")).apply { putByteArray(Base64Decoder.decode(link.substringAfter(":").substringAfter(":"))) }.requireBean() } } fun AbstractBean.toUniversalLink(): String { var link = "ax://" link += TypeMap.reversed[ProxyEntity().putBean(this).type] link += "?" link += Base64Encoder.encodeUrlSafe(ZipUtil.zlib(KryoConverters.serialize(this), 9)) return link } fun ProxyGroup.toUniversalLink(): String { var link = "sn://subscription?" export = true link += Base64Encoder.encodeUrlSafe(ZipUtil.zlib(KryoConverters.serialize(this), 9)) export = false return link } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/fmt/brook/BrookBean.java ================================================ /****************************************************************************** * * * 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.fmt.brook; import androidx.annotation.NonNull; import com.esotericsoftware.kryo.io.ByteBufferInput; import com.esotericsoftware.kryo.io.ByteBufferOutput; import io.nekohasekai.sagernet.fmt.AbstractBean; import io.nekohasekai.sagernet.fmt.KryoConverters; public class BrookBean extends AbstractBean { public String protocol; public String password; public String wsPath; @Override public void initializeDefaultValues() { super.initializeDefaultValues(); if (protocol == null) protocol = ""; if (password == null) password = ""; if (wsPath == null) wsPath = ""; } @Override public void serialize(ByteBufferOutput output) { output.writeInt(1); super.serialize(output); output.writeString(protocol); output.writeString(password); switch (protocol) { case "ws": case "wss": { output.writeString(wsPath); break; } } } @Override public void deserialize(ByteBufferInput input) { int version = input.readInt(); super.deserialize(input); protocol = input.readString(); password = input.readString(); if (version > 0) switch (protocol) { case "ws": case "wss": { wsPath = input.readString(); break; } } } @NonNull @Override public BrookBean clone() { return KryoConverters.deserialize(new BrookBean(), KryoConverters.serialize(this)); } public static final Creator CREATOR = new CREATOR() { @NonNull @Override public BrookBean newInstance() { return new BrookBean(); } @Override public BrookBean[] newArray(int size) { return new BrookBean[size]; } }; } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/fmt/brook/BrookFmt.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.fmt.brook import io.nekohasekai.sagernet.fmt.AbstractBean import io.nekohasekai.sagernet.fmt.socks.SOCKSBean import io.nekohasekai.sagernet.ktx.* import okhttp3.HttpUrl.Companion.toHttpUrlOrNull val kinds = arrayOf("server", "wsserver", "wssserver", "socks5") fun parseBrook(text: String): AbstractBean { if (!(text.contains("([?@])".toRegex()))) { // https://txthinking.github.io/brook/#/brook-link // old brook scheme var server = text.substringAfter("brook://").unUrlSafe() if (server.startsWith("socks5://")) { server = server.substringAfter("://") val bean = SOCKSBean() bean.serverAddress = server.substringBefore(":") bean.serverPort = server.substringAfter(":").substringBefore(" ").toInt() server = server.substringAfter(":").substringAfter(" ") if (server.contains(" ")) { bean.username = server.substringBefore(" ") bean.password = server.substringAfter(" ") } return bean.applyDefaultValues() } val bean = BrookBean() when { server.startsWith("ws://") -> { bean.protocol = "ws" server = server.substringAfter("://") } server.startsWith("wss://") -> { bean.protocol = "wss" server = server.substringAfter("://") } else -> { bean.protocol = "" } } if (server.contains(" ")) { bean.password = server.substringAfter(" ") server = server.substringBefore(" ") } val url = "https://$server".toHttpUrlOrNull() ?: error("Invalid brook link: $text ($server)") bean.serverAddress = url.host bean.serverPort = url.port // bean.name = url.fragment if (server.contains("/")) { bean.wsPath = url.encodedPath.unUrlSafe() } return bean.applyDefaultValues() } else if (text.matches("^brook://(${kinds.joinToString("|")})\\?.+".toRegex())) { // https://github.com/txthinking/brook/issues/811 val link = ("https://" + text.substringAfter("://")).toHttpUrlOrNull() ?: error("Invalid brook url: $text") val bean = if (link.host == "socks5") SOCKSBean() else BrookBean() bean.name = link.queryParameter("remarks") when (link.host) { "server" -> { bean as BrookBean bean.protocol = "" val server = link.queryParameter("server") ?: error("Invalid brook server url (Missing server parameter): $text") bean.serverAddress = server.substringBefore(":") bean.serverPort = server.substringAfter(":").toInt() bean.password = link.queryParameter("password") ?: error("Invalid brook server url (Missing password parameter): $text") } "wsserver" -> { bean as BrookBean bean.protocol = "ws" var wsserver = (link.queryParameter("wsserver") ?: error("Invalid brook wsserver url (Missing wsserver parameter): $text")) .substringAfter("://") if (wsserver.contains("/")) { bean.wsPath = "/" + wsserver.substringAfter("/") wsserver = wsserver.substringBefore("/") } bean.serverAddress = wsserver.substringBefore(":") bean.serverPort = wsserver.substringAfter(":").toInt() bean.password = link.queryParameter("password") ?: error("Invalid brook wsserver url (Missing password parameter): $text") } "wssserver" -> { bean as BrookBean bean.protocol = "wss" var wsserver = (link.queryParameter("wssserver") ?: error("Invalid brook wssserver url (Missing wssserver parameter): $text")) .substringAfter("://") if (wsserver.contains("/")) { bean.wsPath = "/" + wsserver.substringAfter("/") wsserver = wsserver.substringBefore("/") } bean.serverAddress = wsserver.substringBefore(":") bean.serverPort = wsserver.substringAfter(":").toInt() bean.password = link.queryParameter("password") ?: error("Invalid brook wssserver url (Missing password parameter): $text") } "socks5" -> { bean as SOCKSBean val socks5 = (link.queryParameter("socks5") ?: error("Invalid brook socks5 url (Missing socks5 parameter): $text")) .substringAfter("://") bean.serverAddress = socks5.substringBefore(":") bean.serverPort = socks5.substringAfter(":").toInt() link.queryParameter("username")?.also { username -> bean.username = username link.queryParameter("password")?.also { password -> bean.password = password } } } } return bean } else { /** * brook://urlEncode(password)@host:port#urlEncode(remarks) * brook+ws(s)://urlEncode(password)@host:port?path=...#urlEncode(remarks) */ val proto = if (!text.startsWith("brook+")) "" else { text.substringAfter("+").substringBefore("://") } if (proto !in arrayOf("", "ws", "wss")) error("Invalid brook protocol $proto") val link = ("https://" + text.substringAfter("://")).toHttpUrlOrNull() ?: error("Invalid brook url: $text") return BrookBean().apply { protocol = proto serverAddress = link.host serverPort = link.port password = link.username link.queryParameter("path")?.also { wsPath = it } name = link.fragment } } } fun BrookBean.toUri(): String { /*var server = when (protocol) { "ws" -> "ws://$serverAddress:$serverPort" "wss" -> "wss://$serverAddress:$serverPort" else -> "$serverAddress:$serverPort" } if (protocol.startsWith("ws")) { if (wsPath.isNotBlank()) { if (!wsPath.startsWith("/")) { server += "/" } server += wsPath.pathSafe() } } //if (name.isNotBlank()) { // server += "#" + name.urlSafe() //} if (password.isNotBlank()) { server = "$server $password" } return "brook://" + server.urlSafe()*/ val builder = linkBuilder() .host(serverAddress) .port(serverPort) if (password.isNotBlank()) { builder.encodedUsername(password.urlSafe()) } if (name.isNotBlank()) { builder.encodedFragment(name.urlSafe()) } if (wsPath.isNotBlank()) { builder.addQueryParameter("path", wsPath) } return when (protocol) { "ws", "wss" -> builder.toLink("brook+$protocol", false) else -> builder.toLink("brook") } } fun BrookBean.internalUri(): String { var server = wrapUri() server = when (protocol) { "ws" -> "ws://" "wss" -> "wss://" else -> return server } + server if (wsPath.isNotBlank()) { if (!wsPath.startsWith("/")) { server += "/" } server += wsPath.pathSafe() } return server } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/fmt/gson/GsonConverters.java ================================================ /****************************************************************************** * * * 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.fmt.gson; import androidx.room.TypeConverter; import java.util.Collection; import java.util.List; import java.util.Set; import cn.hutool.core.util.StrUtil; import kotlin.collections.CollectionsKt; import kotlin.collections.SetsKt; public class GsonConverters { @TypeConverter public static String toJson(Object value) { if (value instanceof Collection) { if (((Collection) value).isEmpty()) return ""; } return GsonsKt.getGson().toJson(value); } @TypeConverter public static List toList(String value) { if (StrUtil.isBlank(value)) return CollectionsKt.listOf(); return GsonsKt.getGson().fromJson(value, List.class); } @TypeConverter public static Set toSet(String value) { if (StrUtil.isBlank(value)) return SetsKt.setOf(); return GsonsKt.getGson().fromJson(value, Set.class); } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/fmt/gson/Gsons.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.fmt.gson import com.google.gson.GsonBuilder val gson = GsonBuilder() .setPrettyPrinting() .setLenient() .registerTypeAdapterFactory(JsonOrAdapterFactory()) .registerTypeAdapterFactory(JsonLazyFactory()) .create() ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/fmt/gson/JsonLazyAdapter.java ================================================ /****************************************************************************** * * * 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.fmt.gson; import com.google.gson.Gson; import com.google.gson.JsonElement; import com.google.gson.TypeAdapter; import com.google.gson.stream.JsonReader; import com.google.gson.stream.JsonToken; import com.google.gson.stream.JsonWriter; import java.io.IOException; public class JsonLazyAdapter extends TypeAdapter> { private final Gson gson; private final Class> clazz; public JsonLazyAdapter(Gson gson, Class> clazz) { this.gson = gson; this.clazz = clazz; } @Override public void write(JsonWriter out, JsonLazyInterface value) throws IOException { if (value == null) { out.nullValue(); } else { gson.getAdapter(value.type.getValue()).write(out, value.getValue()); } } @Override public JsonLazyInterface read(JsonReader in) throws IOException { if (in.peek() == JsonToken.NULL) { in.nextNull(); return null; } try { JsonLazyInterface instance = clazz.newInstance(); instance.gson = gson; instance.content = gson.getAdapter(JsonElement.class).read(in); return instance; } catch (Exception e) { if (e instanceof IOException) { throw ((IOException) e); } else { throw new IOException(e); } } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/fmt/gson/JsonLazyFactory.java ================================================ /****************************************************************************** * * * 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.fmt.gson; import com.google.gson.Gson; import com.google.gson.TypeAdapter; import com.google.gson.TypeAdapterFactory; import com.google.gson.reflect.TypeToken; public class JsonLazyFactory implements TypeAdapterFactory { @SuppressWarnings({"unchecked", "rawtypes"}) @Override public TypeAdapter create(Gson gson, TypeToken type) { if (!JsonLazyInterface.class.isAssignableFrom(type.getRawType())) return null; return new JsonLazyAdapter(gson, type.getRawType()); } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/fmt/gson/JsonLazyInterface.java ================================================ /****************************************************************************** * * * 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.fmt.gson; import androidx.annotation.Nullable; import com.google.gson.Gson; import com.google.gson.JsonElement; import kotlin.Lazy; import kotlin.LazyKt; @SuppressWarnings("unchecked") public abstract class JsonLazyInterface implements Lazy { protected JsonElement content; protected Gson gson; private T value; private boolean fromValue; public JsonLazyInterface() { } public JsonLazyInterface(T value) { this.value = value; this.fromValue = true; } protected final Lazy> type = LazyKt.lazy(() -> (Class) getType()); private final Lazy _value = LazyKt.lazy(this::init); private T init() { if (type.getValue() == null) { return null; } return gson.fromJson(content, type.getValue()); } @Nullable protected abstract Class getType(); @Override public T getValue() { if (fromValue) return value; return _value.getValue(); } @Override public boolean isInitialized() { if (fromValue) return true; return _value.isInitialized(); } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/fmt/gson/JsonOr.java ================================================ /****************************************************************************** * * * 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.fmt.gson; import androidx.annotation.NonNull; import com.google.gson.stream.JsonToken; public class JsonOr { public JsonToken tokenX; public JsonToken tokenY; public X valueX; public Y valueY; public JsonOr(JsonToken tokenX, JsonToken tokenY) { this.tokenX = tokenX; this.tokenY = tokenY; } protected JsonOr(X valueX, Y valueY) { this.valueX = valueX; this.valueY = valueY; } @NonNull @Override public String toString() { return valueX != null ? valueX.toString() : valueY != null ? valueY.toString() : "null"; } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/fmt/gson/JsonOrAdapter.java ================================================ /****************************************************************************** * * * 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.fmt.gson; import com.google.gson.Gson; import com.google.gson.TypeAdapter; import com.google.gson.reflect.TypeToken; import com.google.gson.stream.JsonReader; import com.google.gson.stream.JsonToken; import com.google.gson.stream.JsonWriter; import java.io.IOException; @SuppressWarnings("unchecked") public class JsonOrAdapter> extends TypeAdapter { private final Gson gson; private final TypeToken typeX; private final TypeToken typeY; private final TypeToken typeT; private final JsonToken tokenX; @SuppressWarnings("FieldCanBeLocal") private final JsonToken tokenY; public JsonOrAdapter(Gson gson, TypeToken typeX, TypeToken typeY, TypeToken typeT, JsonToken tokenX, JsonToken tokenY) { this.gson = gson; this.typeX = typeX; this.typeY = typeY; this.typeT = typeT; this.tokenX = tokenX; this.tokenY = tokenY; } @Override public void write(JsonWriter out, T value) throws IOException { if (value.valueX != null) { gson.getAdapter(typeX).write(out, value.valueX); } else { gson.getAdapter(typeY).write(out, value.valueY); } } @Override public T read(JsonReader in) throws IOException { try { T jsonOr = (T) typeT.getRawType().newInstance(); if (in.peek() == tokenX) { jsonOr.valueX = gson.getAdapter(typeX).read(in); } else { jsonOr.valueY = gson.getAdapter(typeY).read(in); } return jsonOr; } catch (Exception e) { throw new IOException(e); } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/fmt/gson/JsonOrAdapterFactory.java ================================================ /****************************************************************************** * * * 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.fmt.gson; import com.google.gson.Gson; import com.google.gson.TypeAdapter; import com.google.gson.TypeAdapterFactory; import com.google.gson.reflect.TypeToken; import java.lang.reflect.ParameterizedType; import java.lang.reflect.Type; @SuppressWarnings({"ConstantConditions", "unchecked", "rawtypes"}) public class JsonOrAdapterFactory implements TypeAdapterFactory { @Override public TypeAdapter create(Gson gson, TypeToken type) { if (!JsonOr.class.isAssignableFrom(type.getRawType())) return null; Type superclass = type.getRawType().getGenericSuperclass(); if (superclass instanceof Class) { throw new RuntimeException("Missing type parameter."); } ParameterizedType parameterized = (ParameterizedType) superclass; Type[] args = parameterized.getActualTypeArguments(); try { JsonOr instance = (JsonOr) type.getRawType().newInstance(); return new JsonOrAdapter(gson, TypeToken.get(args[0]), TypeToken.get(args[1]), type, instance.tokenX, instance.tokenY); } catch (Exception e) { e.printStackTrace(); throw new RuntimeException(e); } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/fmt/http/HttpBean.java ================================================ /****************************************************************************** * * * 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.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.AbstractBean; import io.nekohasekai.sagernet.fmt.KryoConverters; public class HttpBean extends AbstractBean { public String username; public String password; public boolean tls; public String sni; @Override public void initializeDefaultValues() { super.initializeDefaultValues(); if (username == null) username = ""; if (password == null) password = ""; if (sni == null) sni = ""; } @Override public void serialize(ByteBufferOutput output) { output.writeInt(0); super.serialize(output); output.writeString(username); output.writeString(password); output.writeBoolean(tls); output.writeString(sni); } @Override public void deserialize(ByteBufferInput input) { int version = input.readInt(); super.deserialize(input); username = input.readString(); password = input.readString(); tls = input.readBoolean(); sni = 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 ================================================ /****************************************************************************** * * * 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.fmt.http 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 tls = httpUrl.scheme == "https" } } fun HttpBean.toUri(): String { val builder = HttpUrl.Builder() .scheme(if (tls) "https" else "http") .host(serverAddress) .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 ================================================ /****************************************************************************** * * * 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.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; public class HysteriaBean extends AbstractBean { 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 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; @Override public void initializeDefaultValues() { super.initializeDefaultValues(); if (authPayloadType == null) authPayloadType = TYPE_NONE; if (authPayload == null) authPayload = ""; if (obfuscation == null) obfuscation = ""; if (sni == null) sni = ""; if (caText == null) caText = ""; if (uploadMbps == null) uploadMbps = 10; if (downloadMbps == null) downloadMbps = 50; if (allowInsecure == null) allowInsecure = false; if (streamReceiveWindow == null) streamReceiveWindow = 0; if (connectionReceiveWindow == null) connectionReceiveWindow = 0; if (disableMtuDiscovery == null) disableMtuDiscovery = false; } @Override public void serialize(ByteBufferOutput output) { output.writeInt(1); super.serialize(output); output.writeInt(authPayloadType); output.writeString(authPayload); output.writeString(obfuscation); output.writeString(sni); output.writeInt(uploadMbps); output.writeInt(downloadMbps); output.writeBoolean(allowInsecure); output.writeString(caText); output.writeInt(streamReceiveWindow); output.writeInt(connectionReceiveWindow); output.writeBoolean(disableMtuDiscovery); } @Override public void deserialize(ByteBufferInput input) { int version = input.readInt(); super.deserialize(input); authPayloadType = input.readInt(); authPayload = input.readString(); obfuscation = input.readString(); sni = input.readString(); uploadMbps = input.readInt(); downloadMbps = input.readInt(); allowInsecure = input.readBoolean(); if (version >= 1) { caText = input.readString(); streamReceiveWindow = input.readInt(); connectionReceiveWindow = input.readInt(); disableMtuDiscovery = input.readBoolean(); } } @Override public void applyFeatureSettings(AbstractBean other) { if (!(other instanceof HysteriaBean)) return; HysteriaBean bean = ((HysteriaBean) other); bean.uploadMbps = uploadMbps; bean.downloadMbps = downloadMbps; bean.allowInsecure = allowInsecure; bean.disableMtuDiscovery = disableMtuDiscovery; } @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 ================================================ /****************************************************************************** * * * 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.fmt.hysteria import cn.hutool.core.util.NumberUtil import cn.hutool.json.JSONObject import io.nekohasekai.sagernet.fmt.LOCALHOST import io.nekohasekai.sagernet.ktx.wrapUri import java.io.File fun JSONObject.parseHysteria(): HysteriaBean { return HysteriaBean().apply { serverAddress = getStr("server").substringBeforeLast(":") serverPort = getStr("server").substringAfterLast(":") .takeIf { NumberUtil.isInteger(it) } ?.toInt() ?: 443 uploadMbps = getInt("up_mbps") downloadMbps = getInt("down_mbps") obfuscation = getStr("obfs") getStr("auth")?.also { authPayloadType = HysteriaBean.TYPE_BASE64 authPayload = it } getStr("auth_str")?.also { authPayloadType = HysteriaBean.TYPE_STRING authPayload = it } sni = getStr("server_name") allowInsecure = getBool("insecure") streamReceiveWindow = getInt("recv_window_conn") connectionReceiveWindow = getInt("recv_window") disableMtuDiscovery = getBool("disable_mtu_discovery") } } fun HysteriaBean.buildHysteriaConfig(port: Int, cacheFile: (() -> File)?): String { return JSONObject().also { it["server"] = wrapUri() it["up_mbps"] = uploadMbps it["down_mbps"] = downloadMbps it["socks5"] = JSONObject(mapOf("listen" to "$LOCALHOST:$port")) it["obfs"] = obfuscation when (authPayloadType) { HysteriaBean.TYPE_BASE64 -> it["auth"] = authPayload HysteriaBean.TYPE_STRING -> it["auth_str"] = authPayload } if (sni.isNotBlank()) it["server_name"] = sni if (caText.isNotBlank() && cacheFile != null) { val caFile = cacheFile() caFile.writeText(caText) it["ca"] = caFile.absolutePath } if (allowInsecure) it["insecure"] = true if (streamReceiveWindow > 0) it["recv_window_conn"] = streamReceiveWindow if (connectionReceiveWindow > 0) it["recv_window"] = connectionReceiveWindow if (disableMtuDiscovery) it["disable_mtu_discovery"] = true }.toStringPretty() } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/fmt/internal/BalancerBean.java ================================================ /****************************************************************************** * * * 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.fmt.internal; 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 cn.hutool.core.util.StrUtil; import io.nekohasekai.sagernet.fmt.KryoConverters; public class BalancerBean extends InternalBean { public static final int TYPE_LIST = 0; public static final int TYPE_GROUP = 1; public Integer type; public String strategy; public List proxies; public Long groupId; @Override public void initializeDefaultValues() { super.initializeDefaultValues(); if (name == null) name = ""; if (strategy == null) strategy = ""; if (type == null) type = TYPE_LIST; if (proxies == null) proxies = new ArrayList<>(); if (groupId == null) groupId = 0L; } @Override public String displayName() { if (StrUtil.isNotBlank(name)) { return name; } else { return "Balancer " + Math.abs(hashCode()); } } @Override public void serialize(ByteBufferOutput output) { output.writeInt(0); output.writeInt(type); output.writeString(strategy); switch (type) { case TYPE_LIST: { int length = proxies.size(); output.writeInt(length); for (Long proxy : proxies) { output.writeLong(proxy); } break; } case TYPE_GROUP: { output.writeLong(groupId); break; } } } @Override public void deserialize(ByteBufferInput input) { int version = input.readInt(); type = input.readInt(); strategy = input.readString(); switch (type) { case TYPE_LIST: { int length = input.readInt(); proxies = new ArrayList<>(); for (int i = 0; i < length; i++) { proxies.add(input.readLong()); } break; } case TYPE_GROUP: { groupId = input.readLong(); break; } } } @NonNull @Override public BalancerBean clone() { return KryoConverters.deserialize(new BalancerBean(), KryoConverters.serialize(this)); } public static final Creator CREATOR = new CREATOR() { @NonNull @Override public BalancerBean newInstance() { return new BalancerBean(); } @Override public BalancerBean[] newArray(int size) { return new BalancerBean[size]; } }; } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/fmt/internal/ChainBean.java ================================================ /****************************************************************************** * * * 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.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 cn.hutool.core.util.StrUtil; import io.nekohasekai.sagernet.fmt.KryoConverters; public class ChainBean extends InternalBean { public List proxies; @Override public String displayName() { if (StrUtil.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/ConfigBean.java ================================================ /****************************************************************************** * * * 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.fmt.internal; import androidx.annotation.NonNull; import com.esotericsoftware.kryo.io.ByteBufferInput; import com.esotericsoftware.kryo.io.ByteBufferOutput; import cn.hutool.core.util.StrUtil; import io.nekohasekai.sagernet.fmt.KryoConverters; public class ConfigBean extends InternalBean { public String type; public String content; @Override public String displayName() { if (StrUtil.isNotBlank(name)) { return name; } else { return "Config " + Math.abs(hashCode()); } } @Override public void initializeDefaultValues() { super.initializeDefaultValues(); if (name == null) name = ""; if (type == null) type = "v2ray"; if (content == null) content = "{}"; } @Override public void serialize(ByteBufferOutput output) { output.writeInt(0); output.writeString(type); output.writeString(content); } @Override public void deserialize(ByteBufferInput input) { int version = input.readInt(); type = input.readString(); content = input.readString(); } @NonNull @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/io/nekohasekai/sagernet/fmt/internal/InternalBean.java ================================================ /****************************************************************************** * * * 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.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/naive/NaiveBean.java ================================================ /****************************************************************************** * * * 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.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; @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 = ""; } @Override public void serialize(ByteBufferOutput output) { output.writeInt(0); super.serialize(output); output.writeString(proto); output.writeString(username); output.writeString(password); output.writeString(extraHeaders); } @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(); } @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 ================================================ /****************************************************************************** * * * 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.fmt.naive import cn.hutool.json.JSONObject import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.fmt.LOCALHOST import io.nekohasekai.sagernet.ktx.* import okhttp3.HttpUrl.Companion.toHttpUrlOrNull 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 extraHeaders = url.queryParameter("extra-headers")?.let { it.unUrlSafe().replace("\r\n", "\n") } 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 (extraHeaders.isNotBlank()) { builder.addQueryParameter("extra-headers", extraHeaders) } if (name.isNotBlank()) { builder.encodedFragment(name.urlSafe()) } } return builder.toLink(if (proxyOnly) proto else "naive+$proto", false) } fun NaiveBean.buildNaiveConfig(port: Int, mux: Boolean): String { return JSONObject().also { it["listen"] = "socks://$LOCALHOST:$port" it["proxy"] = toUri(true) if (extraHeaders.isNotBlank()) { it["extra-headers"] = extraHeaders.split("\n").joinToString("\r\n") } if (!serverAddress.isIpAddress() && finalAddress == LOCALHOST) { it["host-resolver-rules"] = "MAP $serverAddress $LOCALHOST" } if (DataStore.enableLog) { it["log"] = "" } if (mux) { it["concurrency"] = DataStore.muxConcurrency } }.toStringPretty() } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/fmt/pingtunnel/PingTunnelBean.java ================================================ /****************************************************************************** * * * 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.fmt.pingtunnel; import androidx.annotation.NonNull; import com.esotericsoftware.kryo.io.ByteBufferInput; import com.esotericsoftware.kryo.io.ByteBufferOutput; import org.jetbrains.annotations.NotNull; import cn.hutool.core.util.StrUtil; import io.nekohasekai.sagernet.fmt.AbstractBean; import io.nekohasekai.sagernet.fmt.KryoConverters; public class PingTunnelBean extends AbstractBean { public String key; @Override public String displayName() { if (StrUtil.isNotBlank(name)) { return name; } else { return serverAddress; } } @Override public boolean canTCPing() { return false; } @Override public boolean canMapping() { return false; } @Override public void initializeDefaultValues() { super.initializeDefaultValues(); if (key == null) key = ""; } @Override public void serialize(ByteBufferOutput output) { output.writeInt(0); output.writeString(serverAddress); output.writeString(key); } @Override public void deserialize(ByteBufferInput input) { int version = input.readInt(); serverAddress = input.readString(); key = input.readString(); } @NotNull @Override public PingTunnelBean clone() { return KryoConverters.deserialize(new PingTunnelBean(), KryoConverters.serialize(this)); } public static final Creator CREATOR = new CREATOR() { @NonNull @Override public PingTunnelBean newInstance() { return new PingTunnelBean(); } @Override public PingTunnelBean[] newArray(int size) { return new PingTunnelBean[size]; } }; } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/fmt/pingtunnel/PingTunnelFmt.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.fmt.pingtunnel import io.nekohasekai.sagernet.ktx.linkBuilder import io.nekohasekai.sagernet.ktx.toLink import io.nekohasekai.sagernet.ktx.urlSafe import okhttp3.HttpUrl.Companion.toHttpUrlOrNull /** * Unofficial * * ping-tunnel://[urlEncode(key)@]host[#urlEncode(remarks)] */ fun parsePingTunnel(server: String): PingTunnelBean { val link = server.replace("ping-tunnel://", "https://").toHttpUrlOrNull() ?: error("invalid PingTunnel link $server") return PingTunnelBean().apply { serverAddress = link.host key = link.username link.fragment.takeIf { !it.isNullOrBlank() }?.let { name = it } initializeDefaultValues() } } fun PingTunnelBean.toUri(): String { val builder = linkBuilder().host(serverAddress) if (key.isNotBlank() && key != "1") { builder.encodedUsername(key.urlSafe()) } if (name.isNotBlank()) { builder.encodedFragment(name.urlSafe()) } return builder.toLink("ping-tunnel", false) } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/fmt/relaybaton/RelayBatonBean.java ================================================ /****************************************************************************** * * * 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.fmt.relaybaton; 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 RelayBatonBean extends AbstractBean { public String username; public String password; @Override public void initializeDefaultValues() { if (serverPort == null) serverPort = 443; super.initializeDefaultValues(); if (username == null) username = ""; if (password == null) password = ""; } @Override public boolean canMapping() { return false; } @Override public void serialize(ByteBufferOutput output) { output.writeInt(0); output.writeString(serverAddress); output.writeString(username); output.writeString(password); } @Override public void deserialize(ByteBufferInput input) { int version = input.readInt(); serverAddress = input.readString(); username = input.readString(); password = input.readString(); } @NotNull @Override public RelayBatonBean clone() { return KryoConverters.deserialize(new RelayBatonBean(), KryoConverters.serialize(this)); } public static final Creator CREATOR = new CREATOR() { @NonNull @Override public RelayBatonBean newInstance() { return new RelayBatonBean(); } @Override public RelayBatonBean[] newArray(int size) { return new RelayBatonBean[size]; } }; } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/fmt/relaybaton/RelayBatonFmt.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.fmt.relaybaton 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 okhttp3.HttpUrl.Companion.toHttpUrlOrNull fun parseRelayBaton(link: String): RelayBatonBean { val url = (link.replace("relaybaton://", "https://")).toHttpUrlOrNull() ?: error("Invalid relaybaton link: $link") return RelayBatonBean().apply { serverAddress = url.host username = url.username password = url.password name = url.fragment initializeDefaultValues() } } fun RelayBatonBean.toUri(): String { val builder = linkBuilder().host(serverAddress).username(username).password(password) if (name.isNotBlank()) { builder.encodedFragment(name.urlSafe()) } return builder.toLink("relaybaton", false) } fun RelayBatonBean.buildRelayBatonConfig(port: Int): String { return """ [client] port = $port http_port = 0 redir_port = 0 server = "$finalAddress" username = "$username" password = "$password" proxy_all = true [dns] type = "default" [log] file = "stdout" level = "${if (DataStore.enableLog) "trace" else "error"}" """.trimIndent() } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/fmt/shadowsocks/ShadowsocksBean.java ================================================ /****************************************************************************** * * * 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.fmt.shadowsocks; import androidx.annotation.NonNull; import com.esotericsoftware.kryo.io.ByteBufferInput; import com.esotericsoftware.kryo.io.ByteBufferOutput; import org.jetbrains.annotations.NotNull; import cn.hutool.core.util.StrUtil; import io.nekohasekai.sagernet.fmt.AbstractBean; import io.nekohasekai.sagernet.fmt.KryoConverters; public class ShadowsocksBean extends AbstractBean { public String method; public String password; public String plugin; @Override public void initializeDefaultValues() { super.initializeDefaultValues(); if (StrUtil.isBlank(method)) method = "aes-256-gcm"; if (method == null) method = ""; if (password == null) password = ""; if (plugin == null) plugin = ""; } @Override public void serialize(ByteBufferOutput output) { output.writeInt(0); super.serialize(output); output.writeString(method); output.writeString(password); output.writeString(plugin); } @Override public void deserialize(ByteBufferInput input) { int version = input.readInt(); super.deserialize(input); method = input.readString(); password = input.readString(); plugin = input.readString(); } @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 ================================================ /****************************************************************************** * * * 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.fmt.shadowsocks import cn.hutool.core.codec.Base64 import cn.hutool.json.JSONObject import com.github.shadowsocks.plugin.PluginConfiguration import com.github.shadowsocks.plugin.PluginManager import com.github.shadowsocks.plugin.PluginOptions import io.nekohasekai.sagernet.IPv6Mode import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.fmt.LOCALHOST import io.nekohasekai.sagernet.ktx.* import okhttp3.HttpUrl.Companion.toHttpUrlOrNull val methodsXray = arrayOf( "none", "aes-128-gcm", "aes-256-gcm", "chacha20-ietf-poly1305", "aes-256-cfb", "aes-128-cfb", "chacha20", "chacha20-ietf" ) val methodsClash = arrayOf( "none", "aes-128-gcm", "aes-192-gcm", "aes-256-gcm", "chacha20-ietf-poly1305", "xchacha20-ietf-poly1305", "rc4", "rc4-md5", "aes-128-ctr", "aes-192-ctr", "aes-256-ctr", "aes-128-cfb", "aes-192-cfb", "aes-256-cfb", "aes-128-cfb8", "aes-192-cfb8", "aes-256-cfb8", "aes-128-ofb", "aes-192-ofb", "aes-256-ofb", "bf-cfb", "cast5-cfb", "des-cfb", "idea-cfb", "rc2-cfb", "seed-cfb", "camellia-128-cfb", "camellia-192-cfb", "camellia-256-cfb", "camellia-128-cfb8", "camellia-192-cfb8", "camellia-256-cfb8", "salsa20", "chacha20", "chacha20-ietf", "xchacha20", ) val methodsSsRust = arrayOf( "none", "rc4-md5", "aes-128-cfb", "aes-192-cfb", "aes-256-cfb", "aes-128-ctr", "aes-192-ctr", "aes-256-ctr", "bf-cfb", "camellia-128-cfb", "camellia-192-cfb", "camellia-256-cfb", "chacha20-ietf", "aes-128-gcm", "aes-256-gcm", "chacha20-ietf-poly1305", "xchacha20-ietf-poly1305", ) val methodsSsLibev = arrayOf( "rc4-md5", "aes-128-gcm", "aes-192-gcm", "aes-256-gcm", "aes-128-cfb", "aes-192-cfb", "aes-256-cfb", "aes-128-ctr", "aes-192-ctr", "aes-256-ctr", "camellia-128-cfb", "camellia-192-cfb", "camellia-256-cfb", "bf-cfb", "chacha20-ietf-poly1305", "xchacha20-ietf-poly1305", "salsa20", "chacha20", "chacha20-ietf" ) fun PluginConfiguration.fixInvalidParams() { if (selected.contains("v2ray") && selected != "v2ray-plugin") { pluginsOptions["v2ray-plugin"] = getOptions().apply { id = "v2ray-plugin" } pluginsOptions.remove(selected) selected = "v2ray-plugin" // resolve v2ray plugin } if (selected.contains("obfs") && selected != "obfs-local") { pluginsOptions["obfs-local"] = getOptions().apply { id = "obfs-local" } pluginsOptions.remove(selected) selected = "obfs-local" // resolve clash obfs } if (selected == "obfs-local") { val options = pluginsOptions["obfs-local"] if (options != null) { if (options.containsKey("mode")) { options["obfs"] = options["mode"] options.remove("mode") } if (options.containsKey("host")) { options["obfs-host"] = options["host"] options.remove("host") } } } } fun ShadowsocksBean.fixInvalidParams() { if (method == "plain") method = "none" plugin = PluginConfiguration(plugin).apply { fixInvalidParams() }.toString() } fun parseShadowsocks(url: String): ShadowsocksBean { if (url.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 ?: "" fixInvalidParams() } } 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 ?: "" fixInvalidParams() } } 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 = "" if (url.contains("#")) { name = url.substringAfter("#").unUrlSafe() } fixInvalidParams() } } } fun ShadowsocksBean.toUri(): String { val builder = linkBuilder().username(Base64.encodeUrlSafe("$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 { var pluginStr = "" val pId = getStr("plugin") if (!pId.isNullOrBlank()) { val plugin = PluginOptions(pId, getStr("plugin_opts")) pluginStr = plugin.toString(false) } serverAddress = getStr("server") serverPort = getInt("server_port") password = getStr("password") method = getStr("method") plugin = pluginStr name = getStr("remarks", "") fixInvalidParams() } } fun ShadowsocksBean.buildShadowsocksConfig(port: Int): String { val proxyConfig = JSONObject().also { it["server"] = finalAddress it["server_port"] = finalPort it["method"] = method it["password"] = password it["local_address"] = LOCALHOST it["local_port"] = port it["local_udp_address"] = LOCALHOST it["local_udp_port"] = port it["mode"] = "tcp_and_udp" it["ipv6_first"] = DataStore.ipv6Mode >= IPv6Mode.PREFER it["keep_alive"] = DataStore.tcpKeepAliveInterval } if (plugin.isNotBlank()) { val pluginConfiguration = PluginConfiguration(plugin ?: "") PluginManager.init(pluginConfiguration)?.let { (path, opts, _) -> proxyConfig["plugin"] = path proxyConfig["plugin_opts"] = opts.toString() } } return proxyConfig.toStringPretty() } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/fmt/shadowsocksr/ShadowsocksRBean.java ================================================ /****************************************************************************** * * * 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.fmt.shadowsocksr; import androidx.annotation.NonNull; import com.esotericsoftware.kryo.io.ByteBufferInput; import com.esotericsoftware.kryo.io.ByteBufferOutput; import org.jetbrains.annotations.NotNull; import cn.hutool.core.util.StrUtil; import io.nekohasekai.sagernet.fmt.AbstractBean; import io.nekohasekai.sagernet.fmt.KryoConverters; public class ShadowsocksRBean extends AbstractBean { public String password; public String method; public String protocol; public String protocolParam; public String obfs; public String obfsParam; @Override public void initializeDefaultValues() { super.initializeDefaultValues(); if (password == null) password = ""; if (StrUtil.isBlank(method)) method = "aes-256-cfb"; if (StrUtil.isBlank(protocol)) protocol = "origin"; if (protocolParam == null) protocolParam = ""; if (StrUtil.isBlank(obfs)) obfs = "plain"; if (obfsParam == null) obfsParam = ""; } @Override public void serialize(ByteBufferOutput output) { output.writeInt(0); super.serialize(output); output.writeString(password); output.writeString(method); output.writeString(protocol); output.writeString(protocolParam); output.writeString(obfs); output.writeString(obfsParam); } @Override public void deserialize(ByteBufferInput input) { int version = input.readInt(); super.deserialize(input); password = input.readString(); method = input.readString(); protocol = input.readString(); protocolParam = input.readString(); obfs = input.readString(); obfsParam = input.readString(); } @NotNull @Override public ShadowsocksRBean clone() { return KryoConverters.deserialize(new ShadowsocksRBean(), KryoConverters.serialize(this)); } public static final Creator CREATOR = new CREATOR() { @NonNull @Override public ShadowsocksRBean newInstance() { return new ShadowsocksRBean(); } @Override public ShadowsocksRBean[] newArray(int size) { return new ShadowsocksRBean[size]; } }; } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/fmt/shadowsocksr/ShadowsocksRFmt.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.fmt.shadowsocksr import cn.hutool.core.codec.Base64 import cn.hutool.json.JSONObject import io.nekohasekai.sagernet.IPv6Mode import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.ktx.applyDefaultValues import io.nekohasekai.sagernet.ktx.decodeBase64UrlSafe import okhttp3.HttpUrl.Companion.toHttpUrl import java.util.* fun parseShadowsocksR(url: String): ShadowsocksRBean { val params = url.substringAfter("ssr://").decodeBase64UrlSafe().split(":") val bean = ShadowsocksRBean().apply { serverAddress = params[0] serverPort = params[1].toInt() protocol = params[2] method = params[3] obfs = params[4] password = params[5].substringBefore("/").decodeBase64UrlSafe() } val httpUrl = ("https://localhost" + params[5].substringAfter("/")).toHttpUrl() runCatching { bean.obfsParam = httpUrl.queryParameter("obfsparam")!!.decodeBase64UrlSafe() } runCatching { bean.protocolParam = httpUrl.queryParameter("protoparam")!!.decodeBase64UrlSafe() } val remarks = httpUrl.queryParameter("remarks") if (!remarks.isNullOrBlank()) { bean.name = remarks.decodeBase64UrlSafe() } return bean } fun ShadowsocksRBean.toUri(): String { return "ssr://" + Base64.encodeUrlSafe( "%s:%d:%s:%s:%s:%s/?obfsparam=%s&protoparam=%s&remarks=%s".format( Locale.ENGLISH, serverAddress, serverPort, protocol, method, obfs, Base64.encodeUrlSafe("%s".format(Locale.ENGLISH, password)), Base64.encodeUrlSafe("%s".format(Locale.ENGLISH, obfsParam)), Base64.encodeUrlSafe("%s".format(Locale.ENGLISH, protocolParam)), Base64.encodeUrlSafe( "%s".format( Locale.ENGLISH, name ?: "" ) ) ) ) } fun ShadowsocksRBean.buildShadowsocksRConfig(): String { return JSONObject().also { it["server"] = finalAddress it["server_port"] = finalPort it["method"] = method it["password"] = password it["protocol"] = protocol it["protocol_param"] = protocolParam it["obfs"] = obfs it["obfs_param"] = obfsParam it["ipv6"] = DataStore.ipv6Mode >= IPv6Mode.ENABLE }.toStringPretty() } fun JSONObject.parseShadowsocksR(): ShadowsocksRBean { return ShadowsocksRBean().applyDefaultValues().apply { serverAddress = getStr("server", serverAddress) serverPort = getInt("server_port", serverPort) method = getStr("method", method) password = getStr("password", password) protocol = getStr("protocol", protocol) protocolParam = getStr("protocol_param", protocolParam) obfs = getStr("obfs", obfs) obfsParam = getStr("obfs_param", obfsParam) name = getStr("remarks", name) } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/fmt/snell/SnellBean.java ================================================ /****************************************************************************** * * * 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.fmt.snell; 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 SnellBean extends AbstractBean { public Integer version; public String psk; public String obfsMode; public String obfsHost; @Override public void initializeDefaultValues() { super.initializeDefaultValues(); if (version == null) version = 2; if (psk == null) psk = ""; if (obfsMode == null) obfsMode = "http"; if (obfsHost == null) obfsHost = "bing.com"; } @Override public void serialize(ByteBufferOutput output) { output.writeInt(0); super.serialize(output); output.writeInt(version); output.writeString(psk); output.writeString(obfsMode); output.writeString(obfsHost); } @Override public void deserialize(ByteBufferInput input) { int serVer = input.readInt(); super.deserialize(input); version = input.readInt(); psk = input.readString(); obfsMode = input.readString(); obfsHost = input.readString(); } @NotNull @Override public SnellBean clone() { return KryoConverters.deserialize(new SnellBean(), KryoConverters.serialize(this)); } public static final Creator CREATOR = new CREATOR() { @NonNull @Override public SnellBean newInstance() { return new SnellBean(); } @Override public SnellBean[] newArray(int size) { return new SnellBean[size]; } }; } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/fmt/socks/SOCKSBean.java ================================================ /****************************************************************************** * * * 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.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 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 boolean tls; public String sni; 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 (sni == null) sni = ""; } @Override public void serialize(ByteBufferOutput output) { output.writeInt(1); super.serialize(output); output.writeInt(protocol); output.writeString(username); output.writeString(password); output.writeBoolean(tls); output.writeString(sni); } @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(); tls = input.readBoolean(); sni = input.readString(); } @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 ================================================ /****************************************************************************** * * * 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.fmt.socks import cn.hutool.core.codec.Base64 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 okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrlOrNull fun parseSOCKS(link: String): SOCKSBean { if (!link.substringAfter("://").contains(":")) { // v2rayN shit format var url = link.substringAfter("://") if (url.contains("#")) { url = url.substringBeforeLast("#") } url = url.decodeBase64UrlSafe() val httpUrl = "http://$url".toHttpUrlOrNull() ?: error("Invalid v2rayN link content: $url") return SOCKSBean().apply { serverAddress = httpUrl.host serverPort = httpUrl.port username = httpUrl.username.takeIf { it != "null" } ?: "" password = httpUrl.password.takeIf { it != "null" } ?: "" if (link.contains("#")) { name = link.substringAfter("#").unUrlSafe() } } } else { 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 } serverAddress = url.host serverPort = url.port username = url.username password = url.password name = url.fragment tls = url.queryParameter("tls") == "true" sni = url.queryParameter("sni") } } } 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 (tls) { builder.addQueryParameter("tls", "true") if (sni.isNotBlank()) { builder.addQueryParameter("sni", sni) } } 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://" + Base64.encode(link) if (name.isNotBlank()) { link += "#" + name.urlSafe() } return link } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/fmt/ssh/SSHBean.java ================================================ /****************************************************************************** * 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.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/trojan/TrojanBean.java ================================================ /****************************************************************************** * * * 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.fmt.trojan; import androidx.annotation.NonNull; import com.esotericsoftware.kryo.io.ByteBufferInput; import com.esotericsoftware.kryo.io.ByteBufferOutput; import org.jetbrains.annotations.NotNull; import cn.hutool.core.util.StrUtil; import io.nekohasekai.sagernet.fmt.AbstractBean; import io.nekohasekai.sagernet.fmt.KryoConverters; public class TrojanBean extends AbstractBean { public String password; public String security; public String sni; public String alpn; public String flow; // --------------------------------------- // public Boolean allowInsecure; @Override public void initializeDefaultValues() { super.initializeDefaultValues(); if (password == null) password = ""; if (StrUtil.isBlank(security)) security = "tls"; if (sni == null) sni = ""; if (alpn == null) alpn = ""; if (flow == null) flow = ""; if (allowInsecure == null) allowInsecure = false; } @Override public void serialize(ByteBufferOutput output) { output.writeInt(1); super.serialize(output); output.writeString(password); output.writeString(security); output.writeString(sni); output.writeString(alpn); if ("xtls".equals(security)) { output.writeString(flow); } output.writeBoolean(allowInsecure); } @Override public void deserialize(ByteBufferInput input) { int version = input.readInt(); super.deserialize(input); password = input.readString(); security = input.readString(); sni = input.readString(); alpn = input.readString(); if ("xtls".equals(security)) { flow = input.readString(); } if (version >= 1) { allowInsecure = input.readBoolean(); } } @Override public void applyFeatureSettings(AbstractBean other) { if (!(other instanceof TrojanBean)) return; TrojanBean bean = ((TrojanBean) other); bean.allowInsecure = allowInsecure; } @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 ================================================ /****************************************************************************** * * * 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.fmt.trojan import cn.hutool.json.JSONArray import cn.hutool.json.JSONObject import io.nekohasekai.sagernet.IPv6Mode import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.fmt.LOCALHOST import io.nekohasekai.sagernet.ktx.isIpAddress import io.nekohasekai.sagernet.ktx.linkBuilder import io.nekohasekai.sagernet.ktx.toLink import io.nekohasekai.sagernet.ktx.urlSafe import okhttp3.HttpUrl.Companion.toHttpUrlOrNull // WTF // https://github.com/trojan-gfw/igniter/issues/318 fun parseTrojan(server: String): TrojanBean { val link = server.replace("trojan://", "https://").toHttpUrlOrNull() ?: error("invalid trojan link $server") return TrojanBean().apply { serverAddress = link.host serverPort = link.port password = link.username if (link.password.isNotBlank()) { password += ":" + link.password } security = link.queryParameter("security") ?: "tls" sni = link.queryParameter("sni") ?: "" alpn = link.queryParameter("alpn") ?: "" flow = link.queryParameter("flow") ?: "" name = link.fragment ?: "" } } fun TrojanBean.toUri(): String { val builder = linkBuilder().username(password).host(serverAddress).port(serverPort) if (sni.isNotBlank()) { builder.addQueryParameter("sni", sni) } if (alpn.isNotBlank()) { builder.addQueryParameter("alpn", alpn) } when (security) { "tls" -> { } "xtls" -> { builder.addQueryParameter("security", security) builder.addQueryParameter("flow", flow) } } if (name.isNotBlank()) { builder.encodedFragment(name.urlSafe()) } return builder.toLink("trojan") } fun TrojanBean.buildTrojanConfig(port: Int): String { return JSONObject().also { conf -> conf["run_type"] = "client" conf["local_addr"] = LOCALHOST conf["local_port"] = port conf["remote_addr"] = finalAddress conf["remote_port"] = finalPort conf["password"] = JSONArray().apply { add(password) } conf["log_level"] = if (DataStore.enableLog) 0 else 2 conf["ssl"] = JSONObject().also { if (allowInsecure) it["verify"] = false if (sni.isBlank() && finalAddress == LOCALHOST && !serverAddress.isIpAddress()) { sni = serverAddress } if (sni.isNotBlank()) it["sni"] = sni if (alpn.isNotBlank()) it["alpn"] = JSONArray(alpn.split("\n")) } }.toStringPretty() } fun TrojanBean.buildTrojanGoConfig(port: Int, mux: Boolean): String { return JSONObject().also { conf -> conf["run_type"] = "client" conf["local_addr"] = LOCALHOST conf["local_port"] = port conf["remote_addr"] = finalAddress conf["remote_port"] = finalPort conf["password"] = JSONArray().apply { add(password) } conf["log_level"] = if (DataStore.enableLog) 0 else 2 if (mux && DataStore.enableMuxForAll) conf["mux"] = JSONObject().also { it["enabled"] = true it["concurrency"] = DataStore.muxConcurrency } conf["tcp"] = JSONObject().also { it["prefer_ipv4"] = DataStore.ipv6Mode <= IPv6Mode.ENABLE } conf["ssl"] = JSONObject().also { if (allowInsecure) it["verify"] = false if (sni.isBlank() && finalAddress == LOCALHOST && !serverAddress.isIpAddress()) { sni = serverAddress } if (sni.isNotBlank()) it["sni"] = sni if (alpn.isNotBlank()) it["alpn"] = JSONArray(alpn.split("\n")) } }.toStringPretty() } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/fmt/trojan_go/TrojanGoBean.java ================================================ /****************************************************************************** * * * 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.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 cn.hutool.core.util.StrUtil; import io.nekohasekai.sagernet.fmt.AbstractBean; import io.nekohasekai.sagernet.fmt.KryoConverters; 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; /** * 额外的插件选项。本字段保留。 * 可省略,但不可以为空字符串。 */ public String plugin; @Override public void initializeDefaultValues() { super.initializeDefaultValues(); if (password == null) password = ""; if (sni == null) sni = ""; if (StrUtil.isBlank(type)) type = "original"; if (host == null) host = ""; if (path == null) path = ""; if (StrUtil.isBlank(encryption)) encryption = "none"; if (plugin == null) plugin = ""; } @Override public void serialize(ByteBufferOutput output) { output.writeInt(0); 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); } @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(); } @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 ================================================ /****************************************************************************** * * * 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.fmt.trojan_go import cn.hutool.json.JSONArray import cn.hutool.json.JSONObject import com.github.shadowsocks.plugin.PluginConfiguration import com.github.shadowsocks.plugin.PluginManager import com.github.shadowsocks.plugin.PluginOptions import io.nekohasekai.sagernet.IPv6Mode import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.fmt.LOCALHOST import io.nekohasekai.sagernet.fmt.shadowsocks.fixInvalidParams import io.nekohasekai.sagernet.ktx.* import okhttp3.HttpUrl.Companion.toHttpUrlOrNull 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, mux: Boolean): String { return JSONObject().also { conf -> conf["run_type"] = "client" conf["local_addr"] = LOCALHOST conf["local_port"] = port conf["remote_addr"] = finalAddress conf["remote_port"] = finalPort conf["password"] = JSONArray().apply { add(password) } conf["log_level"] = if (DataStore.enableLog) 0 else 2 if (mux) conf["mux"] = JSONObject().also { it["enabled"] = true it["concurrency"] = DataStore.muxConcurrency } conf["tcp"] = JSONObject().also { it["prefer_ipv4"] = DataStore.ipv6Mode <= IPv6Mode.ENABLE } when (type) { "original" -> { } "ws" -> conf["websocket"] = JSONObject().also { it["enabled"] = true it["host"] = host it["path"] = path } } if (sni.isBlank() && finalAddress == LOCALHOST && !serverAddress.isIpAddress()) { sni = serverAddress } conf["ssl"] = JSONObject().also { if (sni.isNotBlank()) it["sni"] = sni } when { encryption == "none" -> { } encryption.startsWith("ss;") -> conf["shadowsocks"] = JSONObject().also { it["enabled"] = true it["method"] = encryption.substringAfter(";").substringBefore(":") it["password"] = encryption.substringAfter(":") } } if (plugin.isNotBlank()) { val pluginConfiguration = PluginConfiguration(plugin ?: "") PluginManager.init(pluginConfiguration)?.let { (path, opts, isV2) -> conf["transport_plugin"] = JSONObject().also { it["enabled"] = true it["type"] = "shadowsocks" it["command"] = path it["option"] = opts.toString() } } } }.toStringPretty() } fun buildCustomTrojanConfig(config: String, port: Int): String { val conf = JSONObject(config) conf["local_port"] = port return conf.toStringPretty() } fun JSONObject.parseTrojanGo(): TrojanGoBean { return TrojanGoBean().applyDefaultValues().apply { serverAddress = getStr("remote_addr", serverAddress) serverPort = getInt("remote_port", serverPort) when (val pass = get("password")) { is String -> { password = pass } is List<*> -> { password = pass[0] as String } } getJSONObject("ssl")?.apply { sni = getStr("sni", sni) } getJSONObject("websocket")?.apply { if (getBool("enabled", false)) { type = "ws" host = getStr("host", host) path = getStr("path", path) } } getJSONObject("shadowsocks")?.apply { if (getBool("enabled", false)) { encryption = "ss;${getStr("method", "")}:${getStr("password", "")}" } } getJSONObject("transport_plugin")?.apply { if (getBool("enabled", false)) { when (type) { "shadowsocks" -> { val pl = PluginConfiguration() pl.selected = getStr("command") getJSONArray("arg")?.also { pl.pluginsOptions[pl.selected] = PluginOptions().also { opts -> var key = "" it.forEachIndexed { index, param -> if (index % 2 != 0) { key = param.toString() } else { opts[key] = param.toString() } } } } getStr("option")?.also { pl.pluginsOptions[pl.selected] = PluginOptions(it) } pl.fixInvalidParams() plugin = pl.toString() } } } } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/fmt/v2ray/StandardV2RayBean.java ================================================ /****************************************************************************** * * * 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.fmt.v2ray; import com.esotericsoftware.kryo.io.ByteBufferInput; import com.esotericsoftware.kryo.io.ByteBufferOutput; import cn.hutool.core.lang.UUID; import cn.hutool.core.util.StrUtil; import io.nekohasekai.sagernet.fmt.AbstractBean; import io.nekohasekai.sagernet.ktx.UUIDsKt; /** * https://github.com/XTLS/Xray-core/issues/91 */ public abstract class StandardV2RayBean extends AbstractBean { /** * UUID。对应配置文件该项出站中 settings.vnext[0].users[0].id 的值。 *

* 不可省略,不能为空字符串。 */ public String uuid; /** * 当协议为 VMess 时,对应配置文件出站中 settings.security,可选值为 auto / aes-128-gcm / chacha20-poly1305 / none。 *

* 省略时默认为 auto,但不可以为空字符串。除非指定为 none,否则建议省略。 *

* 当协议为 VLESS 时,对应配置文件出站中 settings.encryption,当前可选值只有 none。 *

* 省略时默认为 none,但不可以为空字符串。 *

* 特殊说明:之所以不使用 security 而使用 encryption,是因为后面还有一个底层传输安全类型 security 与这个冲突。 * 由 @huyz 提议,将此字段重命名为 encryption,这样不仅能避免命名冲突,还与 VLESS 保持了一致。 */ public String encryption; /** * 协议的传输方式。对应配置文件出站中 settings.vnext[0].streamSettings.network 的值。 *

* 当前的取值必须为 tcp、kcp、ws、http、quic 其中之一,分别对应 TCP、mKCP、WebSocket、HTTP/2、QUIC 传输方式。 */ public String type; /** * 客户端进行 HTTP/2 通信时所发送的 Host 头部。 *

* 省略时复用 remote-host,但不可以为空字符串。 *

* 若有多个域名,可使用英文逗号隔开,但中间及前后不可有空格。 *

* 必须使用 encodeURIComponent 转义。 * ----------------------------------- * WebSocket 请求时 Host 头的内容。不推荐省略,不推荐设为空字符串。 *

* 必须使用 encodeURIComponent 转义。 */ public String host; /** * HTTP/2 的路径。省略时默认为 /,但不可以为空字符串。不推荐省略。 *

* 必须使用 encodeURIComponent 转义。 * ----------------------------------- * WebSocket 的路径。省略时默认为 /,但不可以为空字符串。不推荐省略。 *

* 必须使用 encodeURIComponent 转义。 */ public String path; /** * mKCP 的伪装头部类型。当前可选值有 none / srtp / utp / wechat-video / dtls / wireguard。 *

* 省略时默认值为 none,即不使用伪装头部,但不可以为空字符串。 * ----------------------------------- * QUIC 的伪装头部类型。其他同 mKCP headerType 字段定义。 */ public String headerType; /** * mKCP 种子。省略时不使用种子,但不可以为空字符串。建议 mKCP 用户使用 seed。 *

* 必须使用 encodeURIComponent 转义。 */ public String mKcpSeed; /** * QUIC 的加密方式。当前可选值有 none / aes-128-gcm / chacha20-poly1305。 *

* 省略时默认值为 none。 */ public String quicSecurity; /** * 当 QUIC 的加密方式不为 none 时的加密密钥。 *

* 当 QUIC 的加密方式为 none 时,此项不得出现;否则,此项必须出现,且不可为空字符串。 *

* 若出现此项,则必须使用 encodeURIComponent 转义。 */ public String quicKey; /** * 底层传输安全 security *

* 设定底层传输所使用的 TLS 类型。当前可选值有 none,tls 和 xtls。 *

* 省略时默认为 none,但不可以为空字符串。 */ public String security; /** * TLS SNI,对应配置文件中的 serverName 项目。 *

* 省略时复用 remote-host,但不可以为空字符串。 */ public String sni; /** * TLS ALPN,对应配置文件中的 alpn 项目。 *

* 多个 ALPN 之间用英文逗号隔开,中间无空格。 *

* 省略时由内核决定具体行为,但不可以为空字符串。 *

* 必须使用 encodeURIComponent 转义。 */ public String alpn; /** * XTLS 的流控方式。可选值为 xtls-rprx-direct、xtls-rprx-splice 等。 *

* 若使用 XTLS,此项不可省略,否则无此项。此项不可为空字符串。 */ public String flow; // --------------------------------------- // public String grpcServiceName; public Boolean grpcMultiMode; public String earlyDataHeaderName; public String certificates; // --------------------------------------- // public Boolean wsUseBrowserForwarder; public Boolean allowInsecure; // --------------------------------------- // @Override public void initializeDefaultValues() { super.initializeDefaultValues(); if (StrUtil.isBlank(uuid)) uuid = ""; if (StrUtil.isBlank(type)) type = "tcp"; else if ("h2".equals(type)) type = "http"; if (StrUtil.isBlank(host)) host = ""; if (StrUtil.isBlank(path)) path = ""; if (StrUtil.isBlank(headerType)) headerType = ""; if (StrUtil.isBlank(mKcpSeed)) mKcpSeed = ""; if (StrUtil.isBlank(quicSecurity)) quicSecurity = ""; if (StrUtil.isBlank(quicKey)) quicKey = ""; if (StrUtil.isBlank(security)) security = ""; if (StrUtil.isBlank(sni)) sni = ""; if (StrUtil.isBlank(alpn)) alpn = ""; if (StrUtil.isBlank(grpcServiceName)) grpcServiceName = ""; if (grpcMultiMode == null) grpcMultiMode = false; if (wsUseBrowserForwarder == null) wsUseBrowserForwarder = false; if (certificates == null) certificates = ""; if (StrUtil.isBlank(flow)) flow = ""; if (allowInsecure == null) allowInsecure = false; } @Override public void serialize(ByteBufferOutput output) { output.writeInt(5); super.serialize(output); output.writeString(uuid); output.writeString(encryption); output.writeString(type); switch (type) { case "tcp": { output.writeString(headerType); output.writeString(host); output.writeString(path); break; } case "kcp": { output.writeString(headerType); output.writeString(mKcpSeed); break; } case "ws": { output.writeString(host); output.writeString(path); output.writeBoolean(wsUseBrowserForwarder); break; } case "http": { output.writeString(host); output.writeString(path); break; } case "quic": { output.writeString(headerType); output.writeString(quicSecurity); output.writeString(quicKey); } case "grpc": { output.writeString(grpcServiceName); output.writeBoolean(grpcMultiMode); } } output.writeString(security); switch (security) { case "tls": { output.writeString(sni); output.writeString(alpn); output.writeString(certificates); output.writeBoolean(allowInsecure); break; } case "xtls": { output.writeString(sni); output.writeString(alpn); output.writeString(flow); output.writeString(certificates); output.writeBoolean(allowInsecure); break; } } if (this instanceof VMessBean) { output.writeInt(((VMessBean) this).alterId); output.writeBoolean(((VMessBean) this).experimentalAuthenticatedLength); output.writeBoolean(((VMessBean) this).experimentalNoTerminationSignal); } } @Override public void deserialize(ByteBufferInput input) { int version = input.readInt(); super.deserialize(input); uuid = input.readString(); encryption = input.readString(); type = input.readString(); switch (type) { case "tcp": { headerType = input.readString(); host = input.readString(); path = input.readString(); break; } case "kcp": { headerType = input.readString(); mKcpSeed = input.readString(); break; } case "ws": { host = input.readString(); path = input.readString(); wsUseBrowserForwarder = input.readBoolean(); break; } case "http": { host = input.readString(); path = input.readString(); break; } case "quic": { headerType = input.readString(); quicSecurity = input.readString(); quicKey = input.readString(); } case "grpc": { grpcServiceName = input.readString(); grpcMultiMode = input.readBoolean(); } } security = input.readString(); switch (security) { case "tls": { sni = input.readString(); alpn = input.readString(); if (version >= 1) certificates = input.readString(); if (version >= 3) allowInsecure = input.readBoolean(); break; } case "xtls": { sni = input.readString(); alpn = input.readString(); flow = input.readString(); if (version >= 4) certificates = input.readString(); if (version >= 3) allowInsecure = input.readBoolean(); } } if (this instanceof VMessBean && version != 4) { ((VMessBean) this).alterId = input.readInt(); } if (this instanceof VMessBean && version >= 4) { ((VMessBean) this).experimentalAuthenticatedLength = input.readBoolean(); ((VMessBean) this).experimentalNoTerminationSignal = input.readBoolean(); } } @Override public void applyFeatureSettings(AbstractBean other) { if (!(other instanceof StandardV2RayBean)) return; StandardV2RayBean bean = ((StandardV2RayBean) other); bean.wsUseBrowserForwarder = wsUseBrowserForwarder; bean.allowInsecure = allowInsecure; } public String uuidOrGenerate() { try { return UUID.fromString(uuid).toString(false); } catch (Exception ignored) { return UUIDsKt.uuid5(uuid); } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/fmt/v2ray/V2RayConfig.java ================================================ /****************************************************************************** * * * 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.fmt.v2ray; import androidx.annotation.Nullable; import com.google.gson.annotations.SerializedName; import com.google.gson.stream.JsonToken; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; import io.nekohasekai.sagernet.fmt.gson.JsonLazyInterface; import io.nekohasekai.sagernet.fmt.gson.JsonOr; @SuppressWarnings({"SpellCheckingInspection", "unused", "RedundantSuppression"}) public class V2RayConfig { public LogObject log; public static class LogObject { public String access; public String error; public String loglevel; } public ApiObject api; public static class ApiObject { public String tag; public List services; } public DnsObject dns; public static class DnsObject { public Map hosts; public List servers; public static class ServerObject { public String address; public Integer port; public String clientIp; public Boolean skipFallback; public List domains; public List expectIPs; public Boolean concurrent; } public static class StringOrServerObject extends JsonOr { public StringOrServerObject() { super(JsonToken.STRING, JsonToken.BEGIN_OBJECT); } } public String clientIp; public Boolean disableCache; public String tag; public List domains; public List expectIPs; public String queryStrategy; public Boolean disableFallback; public Boolean disableFallbackIfMatch; } public RoutingObject routing; public static class RoutingObject { public String domainStrategy; public String domainMatcher; public List rules; public static class RuleObject { public String type; public List domain; public List ip; public String port; public String sourcePort; public String network; public List source; public List user; public List inboundTag; public List protocol; public String attrs; public String outboundTag; public String balancerTag; // SagerNet private public List uidList; public List appStatus; } public List balancers; public static class BalancerObject { public String tag; public List selector; public StrategyObject strategy; public static class StrategyObject { public String type; } } } public PolicyObject policy; public static class PolicyObject { public Map levels; public static class LevelPolicyObject { public Integer handshake; public Integer connIdle; public Integer uplinkOnly; public Integer downlinkOnly; public Boolean statsUserUplink; public Boolean statsUserDownlink; public Integer bufferSize; } public SystemPolicyObject system; public static class SystemPolicyObject { public Boolean statsInboundUplink; public Boolean statsInboundDownlink; public Boolean statsOutboundUplink; public Boolean statsOutboundDownlink; } } public List inbounds; public static class InboundObject { public String listen; public Integer port; public String protocol; public LazyInboundConfigurationObject settings; public StreamSettingsObject streamSettings; public String tag; public SniffingObject sniffing; public AllocateObject allocate; public void init() { if (settings != null) { settings.init(this); } } public static class SniffingObject { public Boolean enabled; public List destOverride; public Boolean metadataOnly; public Boolean routeOnly; } public static class AllocateObject { public String strategy; public Integer refresh; public Integer concurrency; } } public static class LazyInboundConfigurationObject extends JsonLazyInterface { public LazyInboundConfigurationObject() { } public LazyInboundConfigurationObject(InboundObject ctx, InboundConfigurationObject value) { super(value); init(ctx); } public InboundObject ctx; public void init(InboundObject ctx) { this.ctx = ctx; } @Nullable @Override protected Class getType() { switch (ctx.protocol.toLowerCase(Locale.ROOT)) { case "dokodemo-door": return DokodemoDoorInboundConfigurationObject.class; case "http": return HTTPInboundConfigurationObject.class; case "socks": return SocksInboundConfigurationObject.class; case "vmess": return VMessInboundConfigurationObject.class; case "vless": return VLESSInboundConfigurationObject.class; case "shadowsocks": return ShadowsocksInboundConfigurationObject.class; case "trojan": return TrojanInboundConfigurationObject.class; } return null; } } public interface InboundConfigurationObject { } public static class DokodemoDoorInboundConfigurationObject implements InboundConfigurationObject { public String address; public Integer port; public String network; public Integer timeout; public Boolean followRedirect; public Integer userLevel; } public static class HTTPInboundConfigurationObject implements InboundConfigurationObject { public Integer timeout; public List accounts; public Boolean allowTransparent; public Integer userLevel; public static class AccountObject { public String user; public String pass; } } public static class SocksInboundConfigurationObject implements InboundConfigurationObject { public String auth; public List accounts; public Boolean udp; public String ip; public Integer userLevel; public static class AccountObject { public String user; public String pass; } } public static class VMessInboundConfigurationObject implements InboundConfigurationObject { public List clients; @SerializedName("default") public DefaultObject defaultObject; public DetourObject detour; public Boolean disableInsecureEncryption; public static class ClientObject { public String id; public Integer level; public Integer alterId; public String email; } public static class DefaultObject { public Integer level; public Integer alterId; } public static class DetourObject { public String to; } } public static class VLESSInboundConfigurationObject implements InboundConfigurationObject { public List clients; public String decryption; public List fallbacks; public static class ClientObject { public String id; public Integer level; public String email; } public static class FallbackObject { public String alpn; public String path; public Integer dest; public Integer xver; } } public static class ShadowsocksInboundConfigurationObject implements InboundConfigurationObject { public String email; public String method; public String password; public Integer level; public String network; } public static class TrojanInboundConfigurationObject implements InboundConfigurationObject { public List clients; public List fallbacks; public static class ClientObject { public String password; public String email; public Integer level; } public static class FallbackObject { public String alpn; public String path; public Integer dest; public Integer xver; } } public List outbounds; public static class OutboundObject { public String sendThrough; public String protocol; public LazyOutboundConfigurationObject settings; public String tag; public StreamSettingsObject streamSettings; public ProxySettingsObject proxySettings; public MuxObject mux; public String domainStrategy; public Long fallbackDelayMs; public void init() { if (settings != null) { settings.init(this); } } public static class ProxySettingsObject { public String tag; public Boolean transportLayer; } public static class MuxObject { public Boolean enabled; public Integer concurrency; } } public static class LazyOutboundConfigurationObject extends JsonLazyInterface { public LazyOutboundConfigurationObject() { } public LazyOutboundConfigurationObject(OutboundObject ctx, OutboundConfigurationObject value) { super(value); init(ctx); } private OutboundObject ctx; public void init(OutboundObject ctx) { this.ctx = ctx; } @Nullable @Override protected Class getType() { switch (ctx.protocol.toLowerCase(Locale.ROOT)) { case "blackhole": return BlackholeOutboundConfigurationObject.class; case "dns": return DNSOutboundConfigurationObject.class; case "freedom": return FreedomOutboundConfigurationObject.class; case "http": return HTTPOutboundConfigurationObject.class; case "socks": return SocksOutboundConfigurationObject.class; case "vmess": return VMessOutboundConfigurationObject.class; case "vless": return VLESSOutboundConfigurationObject.class; case "shadowsocks": return ShadowsocksOutboundConfigurationObject.class; case "trojan": return TrojanOutboundConfigurationObject.class; case "loopback": return LoopbackOutboundConfigurationObject.class; } return null; } } public interface OutboundConfigurationObject { } public static class BlackholeOutboundConfigurationObject implements OutboundConfigurationObject { public ResponseObject response; public Boolean keepConnection; public Integer userLevel; public static class ResponseObject { public String type; } } public static class DNSOutboundConfigurationObject implements OutboundConfigurationObject { public String network; public String address; public Integer port; // SagerNet private public Integer userLevel; } public static class FreedomOutboundConfigurationObject implements OutboundConfigurationObject { public String domainStrategy; public String redirect; public Integer userLevel; } public static class HTTPOutboundConfigurationObject implements OutboundConfigurationObject { public List servers; public static class ServerObject { public String address; public Integer port; public List users; } } public static class SocksOutboundConfigurationObject implements OutboundConfigurationObject { public List servers; public String version; public static class ServerObject { public String address; public Integer port; public List users; public static class UserObject { public String user; public String pass; public Integer level; } } } public static class VMessOutboundConfigurationObject implements OutboundConfigurationObject { public List vnext; public static class ServerObject { public String address; public Integer port; public List users; public static class UserObject { public String id; public Integer alterId; public String security; public Integer level; public String experimental; } } } public static class ShadowsocksOutboundConfigurationObject implements OutboundConfigurationObject { public List servers; public static class ServerObject { public String address; public Integer port; public String method; public String password; public Integer level; public String email; } } public static class VLESSOutboundConfigurationObject implements OutboundConfigurationObject { public List vnext; public static class ServerObject { public String address; public Integer port; public List users; public static class UserObject { public String id; public String encryption; public Integer level; public String flow; } } } public static class TrojanOutboundConfigurationObject implements OutboundConfigurationObject { public List servers; public static class ServerObject { public String address; public Integer port; public String password; public String email; public Integer level; public String flow; } } public static class LoopbackOutboundConfigurationObject implements OutboundConfigurationObject { public String inboundTag; } public TransportObject transport; public static class TransportObject { public TLSObject tlsSettings; public TcpObject tcpSettings; public KcpObject kcpSettings; public WebSocketObject wsSettings; public HttpObject httpSettings; public QuicObject quicSettings; public DomainSocketObject dsSettings; public GrpcObject grpcSettings; } public static class StreamSettingsObject { public String network; public String security; public TLSObject tlsSettings; public TLSObject xtlsSettings; public TcpObject tcpSettings; public KcpObject kcpSettings; public WebSocketObject wsSettings; public HttpObject httpSettings; public QuicObject quicSettings; public DomainSocketObject dsSettings; public GrpcObject grpcSettings; public SockoptObject sockopt; public static class SockoptObject { public Integer mark; public Boolean tcpFastOpen; public String tproxy; } } public static class TLSObject { public String serverName; public Boolean allowInsecure; public List alpn; public List certificates; public Boolean disableSystemRoot; public String fingerprint; public static class CertificateObject { public String usage; public String certificateFile; public String keyFile; public List certificate; public List key; } } public static class TcpObject { public Boolean acceptProxyProtocol; public HeaderObject header; public static class HeaderObject { public String type; public HTTPRequestObject request; public HTTPResponseObject response; public static class HTTPRequestObject { public String version; public String method; public List path; public Map headers; } public static class HTTPResponseObject { public String version; public String status; public String reason; public Map headers; } public static class StringOrListObject extends JsonOr> { public StringOrListObject() { super(JsonToken.STRING, JsonToken.BEGIN_ARRAY); } } } } public static class KcpObject { public Integer mtu; public Integer tti; public Integer uplinkCapacity; public Integer downlinkCapacity; public Boolean congestion; public Integer readBufferSize; public Integer writeBufferSize; public HeaderObject header; public String seed; public static class HeaderObject { public String type; } } public static class WebSocketObject { public Boolean acceptProxyProtocol; public String path; public Map headers; } public static class HttpObject { public List host; public String path; } public static class QuicObject { public String security; public String key; public HeaderObject header; public static class HeaderObject { public String type; } } public static class DomainSocketObject { public String path; @SerializedName("abstract") public Boolean isAbstract; public Boolean padding; } public static class GrpcObject { public String serviceName; public Boolean multiMode; } public Map stats; public FakeDnsObject fakedns; public static class FakeDnsObject { public String ipPool; public Integer poolSize; } public ReverseObject reverse; public static class ReverseObject { public List bridges; public List portals; public static class BridgeObject { public String tag; public String domain; } public static class PortalObject { public String tag; public String domain; } } public ObservatoryObject observatory; public static class ObservatoryObject { public Set subjectSelector; public String probeUrl; public String probeInterval; public Boolean enableConcurrency; } public void init() { if (inbounds != null) { for (InboundObject inbound : inbounds) inbound.init(); } if (outbounds != null) { for (OutboundObject outbound : outbounds) outbound.init(); } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/fmt/v2ray/V2RayFmt.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.fmt.v2ray import cn.hutool.core.codec.Base64 import cn.hutool.json.JSONObject import io.nekohasekai.sagernet.ktx.* import okhttp3.HttpUrl.Companion.toHttpUrl fun parseV2Ray(link: String): StandardV2RayBean { if (!link.contains("@")) { return parseV2RayN(link) } val bean = if (!link.startsWith("vless://")) { VMessBean() } else { VLESSBean() } val url = link.replace("vmess://", "https://").replace("vless://", "https://").toHttpUrl() bean.serverAddress = url.host bean.serverPort = url.port bean.name = url.fragment if (url.password.isNotBlank()) { // https://github.com/v2fly/v2fly-github-io/issues/26 (bean as VMessBean?) ?: error("Invalid vless url: $link") 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 } } "kcp" -> { url.queryParameter("type")?.let { bean.headerType = it } url.queryParameter("seed")?.let { bean.mKcpSeed = it } } "quic" -> { url.queryParameter("security")?.let { bean.quicSecurity = it } url.queryParameter("key")?.let { bean.quicKey = it } url.queryParameter("type")?.let { bean.headerType = it } } } } else { // https://github.com/XTLS/Xray-core/issues/91 bean.uuid = url.username if (url.pathSegments.size > 1 || url.pathSegments[0].isNotBlank()) { bean.path = url.pathSegments.joinToString("/") } val protocol = url.queryParameter("type") ?: "tcp" bean.type = protocol when (url.queryParameter("security")) { "tls" -> { bean.security = "tls" url.queryParameter("sni")?.let { bean.sni = it } url.queryParameter("alpn")?.let { bean.alpn = it } url.queryParameter("cert")?.let { bean.certificates = it } } "xtls" -> { bean.security = "xtls" url.queryParameter("sni")?.let { bean.sni = it } url.queryParameter("alpn")?.let { bean.alpn = it } url.queryParameter("flow")?.let { bean.flow = it } url.queryParameter("cert")?.let { bean.certificates = it } } } when (protocol) { "tcp" -> { url.queryParameter("headerType")?.let { headerType -> if (headerType == "http") { bean.headerType = headerType url.queryParameter("host")?.let { bean.host = it } url.queryParameter("path")?.let { bean.path = it } } } } "kcp" -> { url.queryParameter("headerType")?.let { bean.headerType = it } url.queryParameter("seed")?.let { bean.mKcpSeed = it } } "http" -> { url.queryParameter("host")?.let { bean.host = it } url.queryParameter("path")?.let { bean.path = it } } "ws" -> { url.queryParameter("host")?.let { bean.host = it } url.queryParameter("path")?.let { bean.path = it } } "quic" -> { url.queryParameter("headerType")?.let { bean.headerType = it } url.queryParameter("quicSecurity")?.let { quicSecurity -> bean.quicSecurity = quicSecurity url.queryParameter("key")?.let { bean.quicKey = it } } } "grpc" -> { url.queryParameter("serviceName")?.let { bean.grpcServiceName = it } url.queryParameter("mode")?.takeIf { it == "multi" }?.also { bean.grpcMultiMode = true } } } } Logs.d(formatObject(bean)) return bean } fun parseV2RayN(link: String): VMessBean { val result = link.substringAfter("vmess://").decodeBase64UrlSafe() if (result.contains("= vmess")) { return parseCsvVMess(result) } val bean = VMessBean() val json = JSONObject(result) bean.serverAddress = json.getStr("add") ?: "" bean.serverPort = json.getInt("port") ?: 1080 bean.encryption = json.getStr("scy") ?: "" bean.uuid = json.getStr("id") ?: "" bean.alterId = json.getInt("aid") ?: 0 bean.type = json.getStr("net") ?: "" bean.headerType = json.getStr("type") ?: "" bean.host = json.getStr("host") ?: "" bean.path = json.getStr("path") ?: "" when (bean.headerType) { "quic" -> { bean.quicSecurity = bean.host bean.quicKey = bean.path } "kcp" -> { bean.mKcpSeed = bean.path } "grpc" -> { bean.grpcServiceName = bean.path } } bean.name = json.getStr("ps") ?: "" bean.sni = json.getStr("sni") ?: bean.host bean.security = json.getStr("tls") if (json.getInt("v", 2) < 2) { when (bean.type) { "ws" -> { var path = "" var host = "" val lstParameter = bean.host.split(";") if (lstParameter.isNotEmpty()) { path = lstParameter[0].trim() } if (lstParameter.size > 1) { path = lstParameter[0].trim() host = lstParameter[1].trim() } bean.path = path bean.host = host } "h2" -> { var path = "" var host = "" val lstParameter = bean.host.split(";") if (lstParameter.isNotEmpty()) { path = lstParameter[0].trim() } if (lstParameter.size > 1) { path = lstParameter[0].trim() host = lstParameter[1].trim() } bean.path = path bean.host = host } } } 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 { return "vmess://" + JSONObject().also { it["v"] = 2 it["ps"] = name it["add"] = serverAddress it["port"] = serverPort it["id"] = uuid it["aid"] = alterId it["net"] = type it["host"] = host it["path"] = path it["type"] = headerType when (headerType) { "quic" -> { it["host"] = quicSecurity it["path"] = quicKey } "kcp" -> { it["path"] = mKcpSeed } "grpc" -> { it["path"] = grpcServiceName } } it["tls"] = if (security == "tls") "tls" else "" it["sni"] = sni it["scy"] = encryption }.toString().let { Base64.encodeUrlSafe(it) } } fun StandardV2RayBean.toUri(standard: Boolean = true): String { if (this is VMessBean && alterId > 0) return toV2rayN() val builder = linkBuilder().username(uuid).host(serverAddress).port(serverPort) .addQueryParameter("type", type).addQueryParameter("encryption", encryption) when (type) { "tcp" -> { if (headerType == "http") { builder.addQueryParameter("headerType", headerType) if (host.isNotBlank()) { builder.addQueryParameter("host", host) } if (path.isNotBlank()) { if (standard) { builder.addQueryParameter("path", path) } else { builder.encodedPath(path.pathSafe()) } } } } "kcp" -> { if (headerType.isNotBlank() && headerType != "none") { builder.addQueryParameter("headerType", headerType) } if (mKcpSeed.isNotBlank()) { builder.addQueryParameter("seed", mKcpSeed) } } "ws", "http" -> { if (host.isNotBlank()) { builder.addQueryParameter("host", host) } if (path.isNotBlank()) { if (standard) { builder.addQueryParameter("path", path) } else { builder.encodedPath(path.pathSafe()) } } } "quic" -> { if (headerType.isNotBlank() && headerType != "none") { builder.addQueryParameter("headerType", headerType) } if (quicSecurity.isNotBlank() && quicSecurity != "none") { builder.addQueryParameter("quicSecurity", quicSecurity) builder.addQueryParameter("key", quicKey) } } "grpc" -> { if (grpcServiceName.isNotBlank()) { builder.addQueryParameter("serviceName", grpcServiceName) } if (grpcMultiMode == true) { builder.addQueryParameter("mode", "multi") } } } 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) } if (certificates.isNotBlank()) { builder.addQueryParameter("cert", certificates) } } "xtls" -> { if (sni.isNotBlank()) { builder.addQueryParameter("sni", sni) } if (alpn.isNotBlank()) { builder.addQueryParameter("alpn", alpn) } if (flow.isNotBlank()) { builder.addQueryParameter("flow", flow) } if (certificates.isNotBlank()) { builder.addQueryParameter("cert", certificates) } } } } if (name.isNotBlank()) { builder.encodedFragment(name.urlSafe()) } return builder.toLink(if (this is VMessBean) "vmess" else "vless") } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/fmt/v2ray/VLESSBean.java ================================================ /****************************************************************************** * * * 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.fmt.v2ray; import androidx.annotation.NonNull; import org.jetbrains.annotations.NotNull; import cn.hutool.core.util.StrUtil; import io.nekohasekai.sagernet.fmt.KryoConverters; public class VLESSBean extends StandardV2RayBean { @Override public void initializeDefaultValues() { super.initializeDefaultValues(); if (StrUtil.isBlank(encryption)) { encryption = "none"; } } @NotNull @Override public VLESSBean clone() { return KryoConverters.deserialize(new VLESSBean(), KryoConverters.serialize(this)); } public static final Creator CREATOR = new CREATOR() { @NonNull @Override public VLESSBean newInstance() { return new VLESSBean(); } @Override public VLESSBean[] newArray(int size) { return new VLESSBean[size]; } }; } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/fmt/v2ray/VMessBean.java ================================================ /****************************************************************************** * * * 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.fmt.v2ray; import androidx.annotation.NonNull; import org.jetbrains.annotations.NotNull; import cn.hutool.core.util.StrUtil; import io.nekohasekai.sagernet.fmt.AbstractBean; import io.nekohasekai.sagernet.fmt.KryoConverters; public class VMessBean extends StandardV2RayBean { public Integer alterId; public Boolean experimentalAuthenticatedLength; public Boolean experimentalNoTerminationSignal; @Override public void initializeDefaultValues() { super.initializeDefaultValues(); alterId = alterId != null ? alterId : 0; encryption = StrUtil.isNotBlank(encryption) ? encryption : "auto"; experimentalAuthenticatedLength = experimentalAuthenticatedLength != null ? experimentalAuthenticatedLength : false; experimentalNoTerminationSignal = experimentalNoTerminationSignal != null ? experimentalNoTerminationSignal : false; } @Override public void applyFeatureSettings(AbstractBean other) { if (!(other instanceof VMessBean)) return; VMessBean bean = ((VMessBean) other); bean.experimentalAuthenticatedLength = experimentalAuthenticatedLength; bean.experimentalNoTerminationSignal = experimentalNoTerminationSignal; } @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 ================================================ /****************************************************************************** * 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.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; @Override public void initializeDefaultValues() { super.initializeDefaultValues(); if (localAddress == null) localAddress = ""; if (privateKey == null) privateKey = ""; if (peerPublicKey == null) peerPublicKey = ""; if (peerPreSharedKey == null) peerPreSharedKey = ""; } @Override public void serialize(ByteBufferOutput output) { output.writeInt(0); super.serialize(output); output.writeString(localAddress); output.writeString(privateKey); output.writeString(peerPublicKey); output.writeString(peerPreSharedKey); } @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(); } @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 ================================================ /****************************************************************************** * 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.fmt.wireguard import cn.hutool.core.codec.Base64 import cn.hutool.core.util.HexUtil import com.wireguard.crypto.Key import io.nekohasekai.sagernet.ktx.wrapUri fun WireGuardBean.buildWireGuardUapiConf(): String { var conf = "private_key=" conf += Key.fromBase64(privateKey).toHex() conf += "\npublic_key=" conf += Key.fromBase64(peerPublicKey).toHex() if (peerPreSharedKey.isNotBlank()) { conf += "\npreshared_key=" conf += HexUtil.encodeHexStr(Base64.decode(peerPreSharedKey)) } conf += "\nendpoint=${wrapUri()}" conf += "\nallowed_ip=0.0.0.0/0" conf += "\nallowed_ip=::/0" return conf } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/group/GroupInterfaceAdapter.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.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 ================================================ /****************************************************************************** * * * 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.group import io.nekohasekai.sagernet.IPv6Mode import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.SubscriptionType 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.brook.BrookBean 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.relaybaton.RelayBatonBean import io.nekohasekai.sagernet.fmt.socks.SOCKSBean 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.ktx.* import kotlinx.coroutines.* import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.OkHttpClient import okhttp3.dnsoverhttps.DnsOverHttps 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?, httpClient: OkHttpClient, byUser: Boolean ) data class Progress( var max: Int ) { var progress by AtomicInteger() } protected suspend fun forceResolve( okHttpClient: OkHttpClient, profiles: List, groupId: Long? ) { val connected = DataStore.startedProfile > 0 var dohUrl: String? = null if (connected) { val remoteDns = DataStore.remoteDns when { remoteDns.startsWith("https+local://") -> dohUrl = remoteDns.replace( "https+local://", "https://" ) remoteDns.startsWith("https://") -> dohUrl = remoteDns } } else { val directDns = DataStore.directDns when { directDns.startsWith("https+local://") -> dohUrl = directDns.replace( "https+local://", "https://" ) directDns.startsWith("https://") -> dohUrl = directDns } } val dohHttpUrl = dohUrl?.toHttpUrlOrNull() ?: (if (connected) { "https://1.0.0.1/dns-query" } else { "https://223.5.5.5/dns-query" }).toHttpUrl() Logs.d("Using doh url $dohHttpUrl") val ipv6Mode = DataStore.ipv6Mode val dohClient = DnsOverHttps.Builder().client(okHttpClient).url(dohHttpUrl).apply { if (ipv6Mode == IPv6Mode.DISABLE) includeIPv6(false) }.build() 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 BrookBean -> if (profile.protocol == "wss") continue is NaiveBean, is RelayBatonBean -> continue } if (profile.serverAddress.isIpAddress()) continue lookupJobs.add(GlobalScope.launch(lookupPool) { try { val results = dohClient.lookup(profile.serverAddress) if (results.isEmpty()) error("empty response") rewriteAddress(profile, results, ipv6First) } catch (e: Exception) { Logs.d("Lookup ${profile.serverAddress} failed: ${e.readableMessage}") } 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 SOCKSBean -> { if (tls && sni.isBlank()) sni = bean.serverAddress } is HttpBean -> { if (tls && 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.startedProfile > 0 val httpClient = createProxyClient() val userInterface = GroupManager.userInterface if (userInterface != null) { if ((subscription.link?.startsWith("http://") == true || subscription.updateWhenConnectedOnly) && !connected) { if (!userInterface.confirm(app.getString(R.string.update_subscription_warning))) { finishUpdate(proxyGroup) cancel() } } } try { when (subscription.type) { SubscriptionType.RAW -> RawUpdater SubscriptionType.OOCv1 -> OpenOnlineConfigUpdater SubscriptionType.SIP008 -> SIP008Updater else -> error("wtf") }.doUpdate(proxyGroup, subscription, userInterface, httpClient, 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/OpenOnlineConfigUpdater.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.group import android.annotation.SuppressLint import cn.hutool.core.net.DefaultTrustManager import cn.hutool.core.util.CharUtil import cn.hutool.crypto.digest.DigestUtil import cn.hutool.json.JSONObject import com.github.shadowsocks.plugin.PluginConfiguration import com.github.shadowsocks.plugin.PluginOptions import io.nekohasekai.sagernet.ExtraType import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.database.* import io.nekohasekai.sagernet.fmt.AbstractBean import io.nekohasekai.sagernet.fmt.shadowsocks.ShadowsocksBean import io.nekohasekai.sagernet.fmt.shadowsocks.fixInvalidParams import io.nekohasekai.sagernet.ktx.Logs import io.nekohasekai.sagernet.ktx.USER_AGENT_ORIGIN import io.nekohasekai.sagernet.ktx.app import io.nekohasekai.sagernet.ktx.applyDefaultValues import okhttp3.* import okhttp3.HttpUrl.Companion.toHttpUrl import java.security.cert.CertificateException import java.security.cert.X509Certificate import javax.net.ssl.SSLSocketFactory object OpenOnlineConfigUpdater : GroupUpdater() { val oocConnSpec = ConnectionSpec.Builder(ConnectionSpec.RESTRICTED_TLS) .tlsVersions(TlsVersion.TLS_1_3) .build() override suspend fun doUpdate( proxyGroup: ProxyGroup, subscription: SubscriptionBean, userInterface: GroupManager.Interface?, httpClient: OkHttpClient, byUser: Boolean ) { val apiToken: JSONObject var baseLink: HttpUrl val certSha256: String? try { apiToken = JSONObject(subscription.token) val version = apiToken.getInt("version") if (version != 1) { if (version != null) { error("Unsupported OOC version $version") } else { error("Missing field: version") } } val baseUrl = apiToken.getStr("baseUrl") when { baseUrl.isNullOrBlank() -> { error("Missing field: baseUrl") } baseUrl.endsWith("/") -> { error("baseUrl must not contain a trailing slash") } !baseUrl.startsWith("https://") -> { error("Protocol scheme must be https") } else -> baseLink = baseUrl.toHttpUrl() } val secret = apiToken.getStr("secret") if (secret.isNullOrBlank()) error("Missing field: secret") baseLink = baseLink.newBuilder() .addPathSegments(secret) .addPathSegments("ooc/v1") .build() val userId = apiToken.getStr("userId") if (userId.isNullOrBlank()) error("Missing field: userId") baseLink = baseLink.newBuilder().addPathSegment(userId).build() certSha256 = apiToken.getStr("certSha256") if (!certSha256.isNullOrBlank()) { when { certSha256.length != 64 -> { error("certSha256 must be a SHA-256 hexadecimal string") } !certSha256.all { CharUtil.isLetterLower(it) || CharUtil.isNumber(it) } -> { error("certSha256 must be a hexadecimal string with lowercase letters") } } } } catch (e: Exception) { Logs.v("ooc token check failed, token = ${subscription.token}", e) error(app.getString(R.string.ooc_subscription_token_invalid)) } val oocHttpClient = if (certSha256.isNullOrBlank()) httpClient else httpClient.newBuilder() .connectionSpecs(listOf(oocConnSpec)) .sslSocketFactory( SSLSocketFactory.getDefault() as SSLSocketFactory, PinnedTrustManager(certSha256) ) .build() val response = oocHttpClient.newCall(Request.Builder().url(baseLink).header("User-Agent", subscription.customUserAgent.takeIf { it.isNotBlank() } ?: USER_AGENT_ORIGIN).build()) .execute() .apply { if (!isSuccessful) error("ERROR: HTTP $code\n\n${body?.string() ?: ""}") if (body == null) error("ERROR: Empty response") } Logs.d(response.toString()) val oocResponse = JSONObject(response.body!!.string()) subscription.username = oocResponse.getStr("username") subscription.bytesUsed = oocResponse.getLong("bytesUsed", -1) subscription.bytesRemaining = oocResponse.getLong("bytesRemaining", -1) subscription.expiryDate = oocResponse.getInt("expiryDate", -1) subscription.protocols = oocResponse.getJSONArray("protocols").filterIsInstance() subscription.applyDefaultValues() for (protocol in subscription.protocols) { if (protocol !in supportedProtocols) { userInterface?.alert(app.getString(R.string.ooc_missing_protocol, protocol)) } } var profiles = mutableListOf() for (protocol in subscription.protocols) { val profilesInProtocol = oocResponse.getJSONArray(protocol) .filterIsInstance() if (protocol == "shadowsocks") for (profile in profilesInProtocol) { val bean = ShadowsocksBean() bean.name = profile.getStr("name") bean.serverAddress = profile.getStr("address") bean.serverPort = profile.getInt("port") bean.method = profile.getStr("method") bean.password = profile.getStr("password") val pluginName = profile.getStr("pluginName") if (!pluginName.isNullOrBlank()) { // TODO: check plugin exists // TODO: check pluginVersion // TODO: support pluginArguments val pl = PluginConfiguration() pl.selected = pluginName pl.pluginsOptions[pl.selected] = PluginOptions(profile.getStr("pluginOptions")) pl.fixInvalidParams() bean.plugin = pl.toString() } appendExtraInfo(profile, bean) profiles.add(bean.applyDefaultValues()) } } if (subscription.forceResolve) forceResolve(httpClient, profiles, proxyGroup.id) val exists = SagerDatabase.proxyDao.getByGroup(proxyGroup.id) val duplicate = ArrayList() if (subscription.deduplication) { Logs.d("Before deduplication: ${profiles.size}") val uniqueProfiles = LinkedHashSet() val uniqueNames = HashMap() for (proxy in profiles) { if (!uniqueProfiles.add(proxy)) { val index = uniqueProfiles.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() } } uniqueProfiles.retainAll(uniqueNames.keys) profiles = uniqueProfiles.toMutableList() } Logs.d("New profiles: ${profiles.size}") val profileMap = profiles.associateBy { it.profileId } val toDelete = ArrayList() val toReplace = exists.mapNotNull { entity -> val profileId = entity.requireBean().profileId if (profileMap.contains(profileId)) profileId 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 ((profileId, bean) in profileMap.entries) { val name = bean.displayName() if (toReplace.contains(profileId)) { val entity = toReplace[profileId]!! val existsBean = entity.requireBean() existsBean.applyFeatureSettings(bean) when { existsBean != bean -> { changed++ entity.putBean(bean) toUpdate.add(entity) updated[entity.displayName()] = name Logs.d("Updated profile: [$profileId] $name") } entity.userOrder != userOrder -> { entity.putBean(bean) toUpdate.add(entity) entity.userOrder = userOrder Logs.d("Reordered profile: [$profileId] $name") } else -> { Logs.d("Ignored profile: [$profileId] $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 != profileMap.size) { Logs.e("Exist profiles: $existCount, new profiles: ${profileMap.size}") } subscription.lastUpdated = (System.currentTimeMillis() / 1000).toInt() SagerDatabase.groupDao.updateGroup(proxyGroup) finishUpdate(proxyGroup) userInterface?.onUpdateSuccess( proxyGroup, changed, added, updated, deleted, duplicate, byUser ) } fun appendExtraInfo(profile: JSONObject, bean: AbstractBean) { bean.extraType = ExtraType.OOCv1 bean.profileId = profile.getStr("id") bean.group = profile.getStr("group") bean.owner = profile.getStr("owner") bean.tags = profile.getJSONArray("tags")?.filterIsInstance() } val supportedProtocols = arrayOf("shadowsocks") @SuppressLint("CustomX509TrustManager") class PinnedTrustManager(val certSha256: String) : DefaultTrustManager() { override fun checkClientTrusted(chain: Array, authType: String) { val serverPK = DigestUtil.sha256Hex(chain[0].publicKey.encoded) if (serverPK != certSha256) throw CertificateException("Excepted certSha256 $certSha256, but was $serverPK") } override fun checkServerTrusted(chain: Array, authType: String) { val serverPK = DigestUtil.sha256Hex(chain[0].publicKey.encoded) if (serverPK != certSha256) throw CertificateException("Excepted certSha256 $certSha256, but was $serverPK") } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/group/RawUpdater.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.group import android.net.Uri import cn.hutool.json.* import com.github.shadowsocks.plugin.PluginOptions import io.nekohasekai.sagernet.BuildConfig import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.database.* import io.nekohasekai.sagernet.fmt.AbstractBean import io.nekohasekai.sagernet.fmt.gson.gson import io.nekohasekai.sagernet.fmt.http.HttpBean import io.nekohasekai.sagernet.fmt.hysteria.parseHysteria import io.nekohasekai.sagernet.fmt.shadowsocks.ShadowsocksBean import io.nekohasekai.sagernet.fmt.shadowsocks.fixInvalidParams import io.nekohasekai.sagernet.fmt.shadowsocks.parseShadowsocks import io.nekohasekai.sagernet.fmt.shadowsocksr.ShadowsocksRBean import io.nekohasekai.sagernet.fmt.shadowsocksr.parseShadowsocksR 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.v2ray.V2RayConfig import io.nekohasekai.sagernet.fmt.v2ray.VLESSBean import io.nekohasekai.sagernet.fmt.v2ray.VMessBean import io.nekohasekai.sagernet.fmt.wireguard.WireGuardBean import io.nekohasekai.sagernet.ktx.* import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.OkHttpClient import okhttp3.Request import org.ini4j.Ini import org.yaml.snakeyaml.TypeDescription import org.yaml.snakeyaml.Yaml import org.yaml.snakeyaml.error.YAMLException import java.io.StringReader @Suppress("EXPERIMENTAL_API_USAGE") object RawUpdater : GroupUpdater() { override suspend fun doUpdate( proxyGroup: ProxyGroup, subscription: SubscriptionBean, userInterface: GroupManager.Interface?, httpClient: OkHttpClient, byUser: Boolean ) { val link = subscription.link var proxies: List if (link.startsWith("content://")) { val contentText = app.contentResolver.openInputStream(Uri.parse(link)) ?.bufferedReader() ?.readText() proxies = contentText?.let { parseRaw(contentText) } ?: error(app.getString(R.string.no_proxies_found_in_subscription)) } else { val response = httpClient.newCall(Request.Builder() .url(subscription.link.toHttpUrl()) .header("User-Agent", subscription.customUserAgent.takeIf { it.isNotBlank() } ?: "SagerNet/${BuildConfig.VERSION_NAME}") .build()).execute().apply { if (!isSuccessful) error("ERROR: HTTP $code\n\n${body?.string() ?: ""}") if (body == null) error("ERROR: Empty response") } Logs.d(response.toString()) proxies = parseRaw(response.body!!.string()) ?: error(app.getString(R.string.no_proxies_found)) } 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(httpClient, proxies, proxyGroup.id) if (subscription.forceVMessAEAD) { proxies.filterIsInstance().forEach { it.alterId = 0 } } 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) { 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() } 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() existsBean.applyFeatureSettings(bean) 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") fun parseRaw(text: String): List? { val proxies = mutableListOf() if (text.contains("proxies:")) { try { // clash for (proxy in (Yaml().apply { addTypeDescription(TypeDescription(String::class.java, "str")) }.loadAs(text, Map::class.java)["proxies"] as? (List>) ?: error( app.getString(R.string.no_proxies_found_in_file) ))) { when (proxy["type"] as String) { "socks5" -> { proxies.add(SOCKSBean().apply { serverAddress = proxy["server"] as String serverPort = proxy["port"].toString().toInt() username = proxy["username"] as String? password = proxy["password"] as String? tls = proxy["tls"]?.toString() == "true" sni = proxy["sni"] as String? name = proxy["name"] as String? }) } "http" -> { proxies.add(HttpBean().apply { serverAddress = proxy["server"] as String serverPort = proxy["port"].toString().toInt() username = proxy["username"] as String? password = proxy["password"] as String? tls = proxy["tls"]?.toString() == "true" sni = proxy["sni"] as String? name = proxy["name"] as String? }) } "ss" -> { var pluginStr = "" if (proxy.contains("plugin")) { val opts = PluginOptions() opts.id = proxy["plugin"] as String opts.putAll(proxy["plugin-opts"] as Map) pluginStr = opts.toString(false) } proxies.add(ShadowsocksBean().apply { serverAddress = proxy["server"] as String serverPort = proxy["port"].toString().toInt() password = proxy["password"] as String method = proxy["cipher"] as String plugin = pluginStr name = proxy["name"] as String? fixInvalidParams() }) } "vmess" -> { val bean = VMessBean() for (opt in proxy) { when (opt.key) { "name" -> bean.name = opt.value as String "server" -> bean.serverAddress = opt.value as String "port" -> bean.serverPort = opt.value.toString().toInt() "uuid" -> bean.uuid = opt.value as String "alterId" -> bean.alterId = opt.value.toString().toInt() "cipher" -> bean.encryption = opt.value as String "network" -> bean.type = opt.value as String "tls" -> bean.security = if (opt.value?.toString() == "true") "tls" else "" "skip-cert-verify" -> bean.allowInsecure = opt.value == "true" "ws-path" -> bean.path = opt.value as String "ws-headers" -> for (wsHeader in (opt.value as Map)) { when (wsHeader.key.lowercase()) { "host" -> bean.host = wsHeader.value as String } } "ws-opts" -> for (wsOpt in (opt.value as Map)) { when (wsOpt.key.lowercase()) { "max-early-data" -> { bean.path = bean.path ?: "" bean.path += "?ed=" + wsOpt.value } "early-data-header-name" -> { bean.earlyDataHeaderName = wsOpt.value as String } } } "servername" -> bean.host = opt.value as String "h2-opts" -> for (h2Opt in (opt.value as Map)) { when (h2Opt.key.lowercase()) { "host" -> bean.host = (h2Opt.value as List).first() "path" -> bean.path = h2Opt.value as String } } "http-opts" -> for (httpOpt in (opt.value as Map)) { when (httpOpt.key.lowercase()) { "path" -> bean.path = (httpOpt.value as List).first() } } "grpc-opts" -> for (grpcOpt in (opt.value as Map)) { when (grpcOpt.key.lowercase()) { "grpc-service-name" -> bean.path = grpcOpt.value as String } } } } proxies.add(bean) } "trojan" -> { val bean = TrojanBean() for (opt in proxy) { when (opt.key) { "name" -> bean.name = opt.value as String? "server" -> bean.serverAddress = opt.value as String "port" -> bean.serverPort = opt.value.toString().toInt() "password" -> bean.password = opt.value as String "sni" -> bean.sni = opt.value as String? "skip-cert-verify" -> bean.allowInsecure = opt.value == "true" } } proxies.add(bean) } "ssr" -> { val entity = ShadowsocksRBean() for (opt in proxy) { when (opt.key) { "name" -> entity.name = opt.value as String "server" -> entity.serverAddress = opt.value as String "port" -> entity.serverPort = opt.value.toString().toInt() "cipher" -> entity.method = opt.value as String "password" -> entity.password = opt.value as String "obfs" -> entity.obfs = opt.value as String "protocol" -> entity.protocol = opt.value as String "obfs-param" -> entity.obfsParam = opt.value as String "protocol-param" -> entity.protocolParam = opt.value as String } } proxies.add(entity) } } } proxies.forEach { it.initializeDefaultValues() } return proxies } catch (e: YAMLException) { Logs.w(e) } } else if (text.contains("[Interface]")) { // wireguard try { proxies.addAll(parseWireGuard(text)) return proxies } catch (e: Exception) { Logs.w(e) } } try { val json = JSONUtil.parse(text) return parseJSON(json) } catch (ignored: JSONException) { } 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 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(",") }.let { address -> address.joinToString("\n") { it.substringBefore("/") } } bean.privateKey = iface["PrivateKey"] 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: JSON): List { val proxies = ArrayList() if (json is JSONObject) { when { json.containsKey("protocol_param") -> { return listOf(json.parseShadowsocksR()) } json.containsKey("method") -> { return listOf(json.parseShadowsocks()) } json.containsKey("protocol") -> { val v2rayConfig = gson.fromJson( json.toString(), V2RayConfig.OutboundObject::class.java ).apply { init() } return parseOutbound(v2rayConfig) } json.containsKey("outbound") -> { val v2rayConfig = gson.fromJson( json.getJSONObject("outbound").toString(), V2RayConfig.OutboundObject::class.java ).apply { init() } return parseOutbound(v2rayConfig) } json.containsKey("outbounds") -> {/* val fakedns = json["fakedns"] if (fakedns is JSONObject) { json["fakedns"] = JSONArray().apply { add(fakedns) } } val routing = json["routing"] if (routing is JSONObject) { val rules = routing["rules"] if (rules is JSONArray) { rules.filterIsInstance().forEach { val inboundTag = it["inboundTag"] if (inboundTag is String) { it["inboundTag"] = JSONArray().apply { add(inboundTag) } } } } } try { gson.fromJson( json.toString(), V2RayConfig::class.java ).apply { init() } } catch (e: Exception) { Logs.w(e)*/ json.getJSONArray("outbounds").filterIsInstance().forEach { val v2rayConfig = gson.fromJson( it.toString(), V2RayConfig.OutboundObject::class.java ).apply { init() } proxies.addAll(parseOutbound(v2rayConfig)) }/* null }?.outbounds?.forEach { proxies.addAll(parseOutbound(it)) }*/ } json.containsKey("remote_addr") -> { return listOf(json.parseTrojanGo()) } json.containsKey("up_mbps") -> { return listOf(json.parseHysteria()) } else -> json.forEach { _, it -> if (it is JSON) { proxies.addAll(parseJSON(it)) } } } } else { json as JSONArray json.forEach { if (it is JSON) { proxies.addAll(parseJSON(it)) } } } proxies.forEach { it.initializeDefaultValues() } return proxies } fun parseOutbound(outboundObject: V2RayConfig.OutboundObject): List { val proxies = ArrayList() with(outboundObject) { when (protocol) { "http" -> { val httpBean = HttpBean().applyDefaultValues() streamSettings?.apply { when (security) { "tls" -> { httpBean.tls = true tlsSettings?.serverName?.also { httpBean.sni = it } } } } (settings.value as? V2RayConfig.HTTPOutboundConfigurationObject)?.servers?.forEach { val httpBeanNext = httpBean.clone().apply { serverAddress = it.address serverPort = it.port } if (it.users.isNullOrEmpty()) { proxies.add(httpBeanNext) } else for (user in it.users) proxies.add(httpBeanNext.clone().apply { username = user.user password = user.pass name = tag ?: displayName() + " - $username" }) } } "socks" -> { val socksBean = SOCKSBean().applyDefaultValues() streamSettings?.apply { when (security) { "tls" -> { socksBean.tls = true tlsSettings?.serverName?.also { socksBean.sni = it } } } } (settings.value as? V2RayConfig.SocksOutboundConfigurationObject)?.servers?.forEach { val socksBeanNext = socksBean.clone().apply { serverAddress = it.address serverPort = it.port } if (it.users.isNullOrEmpty()) { proxies.add(socksBeanNext) } else for (user in it.users) proxies.add(socksBeanNext.clone().apply { username = user.user password = user.pass name = tag ?: displayName() + " - $username" }) } } "vmess", "vless" -> { val v2rayBean = (if (protocol == "vmess") VMessBean() else VLESSBean()).applyDefaultValues() streamSettings?.apply { v2rayBean.security = security ?: v2rayBean.security when (security) { "tls" -> { tlsSettings?.apply { serverName?.also { v2rayBean.sni = it } alpn?.also { v2rayBean.alpn = it.joinToString(",") } allowInsecure?.also { v2rayBean.allowInsecure = it } } } } v2rayBean.type = network ?: v2rayBean.type when (network) { "tcp" -> { tcpSettings?.header?.apply { when (type) { "http" -> { v2rayBean.headerType = "http" request?.apply { path?.also { v2rayBean.path = it.joinToString(",") } headers?.forEach { (key, value) -> when (key.lowercase()) { "host" -> { when { value.valueX != null -> { v2rayBean.host = value.valueX } value.valueY != null -> { v2rayBean.host = value.valueY.joinToString( "," ) } } } } } } } } } } "kcp" -> { kcpSettings?.apply { header?.type?.also { v2rayBean.headerType = it } seed?.also { v2rayBean.mKcpSeed = it } } } "ws" -> { wsSettings?.apply { headers?.forEach { (key, value) -> when (key.lowercase()) { "host" -> { v2rayBean.host = value } } } path?.also { v2rayBean.path = it } /*maxEarlyData?.also { v2rayBean.wsMaxEarlyData = it }*/ } } "http", "h2" -> { v2rayBean.type = "http" httpSettings?.apply { host?.also { v2rayBean.host = it.joinToString(",") } path?.also { v2rayBean.path = it } } } "quic" -> { quicSettings?.apply { security?.also { v2rayBean.quicSecurity = it } key?.also { v2rayBean.quicKey = it } header?.type?.also { v2rayBean.headerType = it } } } "grpc" -> { grpcSettings?.serviceName?.also { v2rayBean.grpcServiceName = it } } } } if (protocol == "vmess") { v2rayBean as VMessBean (settings.value as? V2RayConfig.VMessOutboundConfigurationObject)?.vnext?.forEach { val vmessBean = v2rayBean.clone().apply { serverAddress = it.address serverPort = it.port } for (user in it.users) { proxies.add(vmessBean.clone().apply { uuid = user.id encryption = user.security alterId = user.alterId name = tag ?: displayName() + " - ${user.security} - ${user.id}" }) } } } else { v2rayBean as VLESSBean (settings.value as? V2RayConfig.VLESSOutboundConfigurationObject)?.vnext?.forEach { val vlessBean = v2rayBean.clone().apply { serverAddress = it.address serverPort = it.port } for (user in it.users) { proxies.add(vlessBean.clone().apply { uuid = user.id encryption = user.encryption name = tag ?: displayName() + " - ${user.id}" }) } } } } "shadowsocks" -> (settings.value as? V2RayConfig.ShadowsocksOutboundConfigurationObject)?.servers?.forEach { proxies.add(ShadowsocksBean().applyDefaultValues().apply { name = tag serverAddress = it.address serverPort = it.port method = it.method password = it.password plugin = "" }) } "trojan" -> { val trojanBean = TrojanBean().applyDefaultValues() streamSettings?.apply { trojanBean.security = security ?: trojanBean.security when (security) { "tls" -> { tlsSettings?.apply { serverName?.also { trojanBean.sni = it } alpn?.also { trojanBean.alpn = it.joinToString(",") } allowInsecure?.also { trojanBean.allowInsecure = it } } } } (settings.value as? V2RayConfig.TrojanOutboundConfigurationObject)?.servers?.forEach { proxies.add(trojanBean.clone().apply { name = tag serverAddress = it.address serverPort = it.port password = it.password }) } } } } Unit } return proxies } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/group/SIP008Updater.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.group import android.net.Uri import cn.hutool.json.JSONObject import io.nekohasekai.sagernet.ExtraType import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.database.* import io.nekohasekai.sagernet.fmt.AbstractBean import io.nekohasekai.sagernet.fmt.shadowsocks.parseShadowsocks import io.nekohasekai.sagernet.ktx.Logs import io.nekohasekai.sagernet.ktx.USER_AGENT import io.nekohasekai.sagernet.ktx.app import io.nekohasekai.sagernet.ktx.applyDefaultValues import okhttp3.OkHttpClient import okhttp3.Request object SIP008Updater : GroupUpdater() { override suspend fun doUpdate( proxyGroup: ProxyGroup, subscription: SubscriptionBean, userInterface: GroupManager.Interface?, httpClient: OkHttpClient, byUser: Boolean ) { val link = subscription.link val sip008Response: JSONObject if (link.startsWith("content://")) { val contentText = app.contentResolver.openInputStream(Uri.parse(link)) ?.bufferedReader() ?.readText() sip008Response = contentText?.let { JSONObject(contentText) } ?: error(app.getString(R.string.no_proxies_found_in_subscription)) } else { val response = httpClient.newCall(Request.Builder() .url(subscription.link) .header("User-Agent", subscription.customUserAgent.takeIf { it.isNotBlank() } ?: USER_AGENT) .build()).execute().apply { if (!isSuccessful) error("ERROR: HTTP $code\n\n${body?.string() ?: ""}") if (body == null) error("ERROR: Empty response") } Logs.d(response.toString()) sip008Response = JSONObject(response.body!!.string()) } subscription.bytesUsed = sip008Response.getLong("bytesUsed", -1) subscription.bytesRemaining = sip008Response.getLong("bytesRemaining", -1) subscription.applyDefaultValues() val servers = sip008Response.getJSONArray("servers").filterIsInstance() var profiles = mutableListOf() for (profile in servers) { val bean = profile.parseShadowsocks() appendExtraInfo(profile, bean) profiles.add(bean) } if (subscription.forceResolve) forceResolve(httpClient, profiles, proxyGroup.id) val exists = SagerDatabase.proxyDao.getByGroup(proxyGroup.id) val duplicate = ArrayList() if (subscription.deduplication) { Logs.d("Before deduplication: ${profiles.size}") val uniqueProfiles = LinkedHashSet() val uniqueNames = HashMap() for (proxy in profiles) { if (!uniqueProfiles.add(proxy)) { val index = uniqueProfiles.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() } } uniqueProfiles.retainAll(uniqueNames.keys) profiles = uniqueProfiles.toMutableList() } Logs.d("New profiles: ${profiles.size}") val profileMap = profiles.associateBy { it.profileId } val toDelete = ArrayList() val toReplace = exists.mapNotNull { entity -> val profileId = entity.requireBean().profileId if (profileMap.contains(profileId)) profileId 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 ((profileId, bean) in profileMap.entries) { val name = bean.displayName() if (toReplace.contains(profileId)) { val entity = toReplace[profileId]!! val existsBean = entity.requireBean() existsBean.applyFeatureSettings(bean) when { existsBean != bean -> { changed++ entity.putBean(bean) toUpdate.add(entity) updated[entity.displayName()] = name Logs.d("Updated profile: [$profileId] $name") } entity.userOrder != userOrder -> { entity.putBean(bean) toUpdate.add(entity) entity.userOrder = userOrder Logs.d("Reordered profile: [$profileId] $name") } else -> { Logs.d("Ignored profile: [$profileId] $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 != profileMap.size) { Logs.e("Exist profiles: $existCount, new profiles: ${profileMap.size}") } subscription.lastUpdated = (System.currentTimeMillis() / 1000).toInt() SagerDatabase.groupDao.updateGroup(proxyGroup) finishUpdate(proxyGroup) userInterface?.onUpdateSuccess( proxyGroup, changed, added, updated, deleted, duplicate, byUser ) } fun appendExtraInfo(profile: JSONObject, bean: AbstractBean) { bean.extraType = ExtraType.SIP008 bean.profileId = profile.getStr("id") } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ktx/Asyncs.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 . * * * ******************************************************************************/ @file:Suppress("EXPERIMENTAL_API_USAGE") package io.nekohasekai.sagernet.ktx 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) 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) ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ktx/Browsers.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.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().launchUrl(this, Uri.parse(link)) } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ktx/Dialogs.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.ktx 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) ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ktx/Dimens.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.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 ================================================ /****************************************************************************** * * * 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.ktx import cn.hutool.core.codec.Base64 import cn.hutool.json.JSONObject import io.nekohasekai.sagernet.fmt.AbstractBean import io.nekohasekai.sagernet.fmt.Serializable import io.nekohasekai.sagernet.fmt.brook.parseBrook import io.nekohasekai.sagernet.fmt.gson.gson import io.nekohasekai.sagernet.fmt.http.parseHttp import io.nekohasekai.sagernet.fmt.naive.parseNaive import io.nekohasekai.sagernet.fmt.parseUniversal import io.nekohasekai.sagernet.fmt.pingtunnel.parsePingTunnel import io.nekohasekai.sagernet.fmt.relaybaton.parseRelayBaton import io.nekohasekai.sagernet.fmt.shadowsocks.parseShadowsocks import io.nekohasekai.sagernet.fmt.shadowsocksr.parseShadowsocksR import io.nekohasekai.sagernet.fmt.socks.parseSOCKS import io.nekohasekai.sagernet.fmt.trojan.parseTrojan import io.nekohasekai.sagernet.fmt.trojan_go.parseTrojanGo import io.nekohasekai.sagernet.fmt.v2ray.parseV2Ray fun formatObject(obj: Any): String { return gson.toJson(obj).let { JSONObject(it).toStringPretty() } } fun String.decodeBase64UrlSafe(): String { return Base64.decodeStr( replace(' ', '-').replace('/', '_').replace('+', '-').replace("=", "") ) } class SubscriptionFoundException(val link: String) : RuntimeException() 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("ax://")) { 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) } } else if (startsWith("vmess://") || startsWith("vless://")) { Logs.d("Try parse v2ray 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("ssr://")) { Logs.d("Try parse shadowsocksr link: $this") runCatching { entities.add(parseShadowsocksR(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("ping-tunnel://")) { Logs.d("Try parse pt link: $this") runCatching { entities.add(parsePingTunnel(this)) }.onFailure { Logs.w(it) } } else if (startsWith("relaybaton://")) { Logs.d("Try parse rb link: $this") runCatching { entities.add(parseRelayBaton(this)) }.onFailure { Logs.w(it) } } else if (startsWith("brook://")) { Logs.d("Try parse brook link: $this") runCatching { entities.add(parseBrook(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 ================================================ /****************************************************************************** * * * 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.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 ================================================ /****************************************************************************** * * * 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.ktx import android.graphics.Rect import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView 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 { 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 } } class FixedGridLayoutManager(val recyclerView: RecyclerView, spanCount: Int) : GridLayoutManager(recyclerView.context, spanCount) { 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 { 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 ================================================ /****************************************************************************** * * * 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.ktx import android.util.Log import cn.hutool.core.util.StrUtil import java.io.InputStream import java.io.OutputStream object Logs { private fun mkTag(): String { val stackTrace = Thread.currentThread().stackTrace return StrUtil.subAfter(stackTrace[4].className, ".", true) } fun v(message: String) { // if (BuildConfig.DEBUG) { Log.v(mkTag(), message) // } } fun v(message: String, exception: Throwable) { // if (BuildConfig.DEBUG) { Log.v(mkTag(), message, exception) // } } fun d(message: String) { // if (BuildConfig.DEBUG) { Log.d(mkTag(), message) // } } fun d(message: String, exception: Throwable) { // if (BuildConfig.DEBUG) { Log.d(mkTag(), message, exception) // } } fun i(message: String) { Log.i(mkTag(), message) } fun i(message: String, exception: Throwable) { Log.i(mkTag(), message, exception) } fun w(message: String) { Log.w(mkTag(), message) } fun w(message: String, exception: Throwable) { Log.w(mkTag(), message, exception) } fun w(exception: Throwable) { Log.w(mkTag(), exception) } fun e(message: String) { Log.e(mkTag(), message) } fun e(message: String, exception: Throwable) { Log.e(mkTag(), message, exception) } } fun InputStream.use(out: OutputStream) { use { input -> out.use { output -> input.copyTo(output) } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ktx/Nets.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 . * * * ******************************************************************************/ @file:Suppress("SpellCheckingInspection") package io.nekohasekai.sagernet.ktx import android.os.Build import cn.hutool.core.lang.Validator import io.nekohasekai.sagernet.BuildConfig import io.nekohasekai.sagernet.SagerNet import io.nekohasekai.sagernet.bg.VpnService import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.fmt.AbstractBean import io.nekohasekai.sagernet.fmt.LOCALHOST import okhttp3.ConnectionSpec import okhttp3.HttpUrl import okhttp3.OkHttpClient import java.net.InetAddress import java.net.InetSocketAddress import java.net.Proxy import java.net.Socket val okHttpClient = OkHttpClient.Builder() .followRedirects(true) .followSslRedirects(true) .connectionSpecs(listOf(ConnectionSpec.CLEARTEXT, ConnectionSpec.RESTRICTED_TLS)) .build() private lateinit var proxyClient: OkHttpClient fun createProxyClient(): OkHttpClient { if (!SagerNet.started) return okHttpClient if (!::proxyClient.isInitialized) { proxyClient = okHttpClient.newBuilder().proxy(requireProxy()).build() } return proxyClient } fun requireProxy(): Proxy { return if (Build.VERSION.SDK_INT > Build.VERSION_CODES.M) { Proxy(Proxy.Type.SOCKS, InetSocketAddress(LOCALHOST, DataStore.socksPort)) } else { Proxy(Proxy.Type.HTTP, InetSocketAddress(LOCALHOST, DataStore.httpPort)) } } 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 Validator.isIpv4(this) || Validator.isIpv6(this) } fun String.unwrapHost(): String { if (startsWith("[") && endsWith("]")) { return substring(1, length - 1).unwrapHost() } return this } fun AbstractBean.wrapUri(): String { return if (Validator.isIpv6(finalAddress)) { "[$finalAddress]:$finalPort" } else { "$finalAddress:$finalPort" } } fun parseAddress(addressArray: ByteArray) = InetAddress.getByAddress(addressArray) val INET_TUN = InetAddress.getByName(VpnService.PRIVATE_VLAN4_CLIENT) val INET6_TUN = InetAddress.getByName(VpnService.PRIVATE_VLAN6_CLIENT) fun mkPort(): Int { val socket = Socket() socket.reuseAddress = true socket.bind(InetSocketAddress(0)) val port = socket.localPort socket.close() return port } const val IPPROTO_ICMP = 1 const val IPPROTO_ICMPv6 = 58 const val IPPROTO_TCP = 6 const val IPPROTO_UDP = 17 const val USER_AGENT = "curl/7.74.0" const val USER_AGENT_ORIGIN = "SagerNet/${BuildConfig.VERSION_NAME}" ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ktx/Preferences.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.ktx import androidx.preference.PreferenceDataStore import cn.hutool.core.util.NumberUtil 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.stringToInt( name: String, defaultValue: () -> Int = { 0 }, ) = PreferenceProxy(name, defaultValue, { key, default -> getString(key, "$default")?.takeIf { NumberUtil.isInteger(it) }?.toInt() ?: default }, { key, value -> putString(key, "$value") }) fun PreferenceDataStore.stringToIntIfExists( name: String, defaultValue: () -> Int = { 0 }, ) = PreferenceProxy(name, defaultValue, { key, default -> getString(key, "$default")?.takeIf { NumberUtil.isInteger(it) }?.toInt() ?: 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")?.takeIf { NumberUtil.isLong(it) }?.toLong() ?: 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/Signatures.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 . * * * ******************************************************************************/ /*** * If you modify and release but do not release the source code, you violate the GPL, so this is made. * * @author nekohasekai */ package io.nekohasekai.sagernet.ktx import android.content.Context import android.content.pm.PackageManager.GET_SIGNATURES import android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES import android.content.pm.Signature import android.os.Build import android.os.Process import cn.hutool.crypto.digest.DigestUtil val devKeys = arrayOf( "32250A4B5F3A6733DF57A3B9EC16C38D2C7FC5F2F693A9636F8F7B3BE3549641" ) fun Context.getSignature(): Signature { val appInfo = packageManager.getPackageInfo( packageName, if (Build.VERSION.SDK_INT >= 28) GET_SIGNING_CERTIFICATES else GET_SIGNATURES ) return if (Build.VERSION.SDK_INT >= 28) { appInfo.signingInfo.apkContentsSigners[0] } else { appInfo.signatures[0] } } fun Context.getSha256Signature(): String { return DigestUtil.sha256Hex(getSignature().toByteArray()).uppercase() } fun Context.isVerified(): Boolean { when (val s = getSha256Signature()) { in devKeys, -> return true else -> { Logs.w("Unknown signature: $s") } } return false } fun Context.checkMT() { val fuckMT = block { Thread.setDefaultUncaughtExceptionHandler(null) Thread.currentThread().uncaughtExceptionHandler = null try { Process.killProcess(Process.myPid()) } catch (e: Exception) { } Runtime.getRuntime().exit(0) } try { Class.forName("bin.mt.apksignaturekillerplus.HookApplication") runOnMainDispatcher(fuckMT) return } catch (ignored: ClassNotFoundException) { } if (isVerified()) return val manifestMF = javaClass.getResourceAsStream("/META-INF/MANIFEST.MF") if (manifestMF == null) { Logs.w("/META-INF/MANIFEST.MF not found") return } val input = manifestMF.bufferedReader() val headers = input.use { (0 until 5).map { readLine() } }.joinToString("\n") // WTF version? if (headers.contains("Android Gradle 3.5.0")) { runOnMainDispatcher(fuckMT) } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ktx/UUIDs.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.ktx import cn.hutool.core.lang.UUID import cn.hutool.core.util.ArrayUtil import cn.hutool.crypto.digest.DigestUtil import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream import kotlin.experimental.and import kotlin.experimental.or fun uuid5(text: String): String { val data = ByteArrayOutputStream() data.write(ByteArray(16)) data.write(text.toByteArray()) val hash = DigestUtil.sha1(ByteArrayInputStream(data.toByteArray())) val result = ArrayUtil.sub(hash, 0, 16) result[6] = result[6] and 0x0F.toByte() result[6] = result[6] or 0x50.toByte() result[8] = result[8] and 0x3F.toByte() result[8] = result[8] or 0x80.toByte() var msb = 0L for (i in 0..7) { msb = msb shl 8 or (result[i].toLong() and 0xff) } var lsb = 0L for (i in 8..15) { lsb = lsb shl 8 or (result[i].toLong() and 0xff) } return UUID(msb, lsb).toString(false) } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ktx/Utils.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 . * * * ******************************************************************************/ @file:SuppressLint("SoonBlockedPrivateApi") package io.nekohasekai.sagernet.ktx import android.animation.Animator import android.animation.AnimatorListenerAdapter import android.annotation.SuppressLint import android.app.Service import android.content.* import android.content.pm.PackageInfo import android.content.res.Resources import android.net.NetworkUtils import android.os.Build import android.os.SystemClock 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.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 cn.hutool.core.net.URLDecoder import cn.hutool.core.net.URLEncoder import cn.hutool.core.util.CharsetUtil import io.nekohasekai.sagernet.BuildConfig import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.SagerNet import io.nekohasekai.sagernet.ui.MainActivity import io.nekohasekai.sagernet.ui.ThemedActivity import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine import sun.misc.Unsafe import java.io.FileDescriptor import java.net.HttpURLConnection import java.net.InetAddress import java.net.Socket 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.KClass import kotlin.reflect.KMutableProperty0 import kotlin.reflect.KProperty import kotlin.reflect.KProperty0 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") }) } val PackageInfo.signaturesCompat get() = if (Build.VERSION.SDK_INT >= 28) signingInfo.apkContentsSigners else @Suppress("DEPRECATION") signatures /** * 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) } private val encoder = URLEncoder().apply { addSafeCharacter('*') addSafeCharacter('-') addSafeCharacter('.') addSafeCharacter('_') addSafeCharacter('&') addSafeCharacter('/') } fun String.pathSafe(): String { return encoder.encode(this, CharsetUtil.CHARSET_UTF_8) } fun String.urlSafe(): String { return URLEncoder.ALL.encode(this, CharsetUtil.CHARSET_UTF_8) } fun String.unUrlSafe(): String { return URLDecoder.decode(this, CharsetUtil.CHARSET_UTF_8) } 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 (visibility == View.VISIBLE && other.visibility == View.GONE) 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 (SagerNet.started) { snackbar(getString(R.string.restart)).setAction(R.string.apply) { SagerNet.reloadService() }.show() } } @Suppress("DEPRECATION") fun KClass.isRunning(): Boolean { val name = qualifiedName var myServices = SagerNet.activity.getRunningServices(5) ?: return false if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { val myUid = Os.getuid() myServices = myServices.filter { it.uid == myUid } } for (myService in myServices) if (myService.service.className == name) { return true } return false } 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) } const val isDefaultFlavor = BuildConfig.FLAVOR == "oss" const val isExpert = BuildConfig.FLAVOR == "expert" val LAUNCH_DELAY = System.currentTimeMillis() - SystemClock.elapsedRealtime() private val protectDirectAvailable by lazy { try { NetworkUtils::class.java.getDeclaredMethod("protectFromVpn", Int::class.java) true } catch (e: Exception) { false } } fun Fragment.protectFromVpn(fd: Int) { if (protectDirectAvailable) { NetworkUtils.protectFromVpn(fd) } else { (requireActivity() as? MainActivity)?.connection?.service?.protect(fd) } } 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) } } @SuppressLint("DiscouragedPrivateApi") val UNSAFE = try { Unsafe::class.java.getDeclaredMethod("getUnsafe").invoke(null) as Unsafe? } catch (e: Throwable) { null } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ktx/Validators.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.ktx import androidx.annotation.RawRes import cn.hutool.core.lang.Validator import cn.hutool.core.net.NetUtil.isInnerIP import cn.hutool.json.JSONObject import com.github.shadowsocks.plugin.PluginConfiguration import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.fmt.AbstractBean import io.nekohasekai.sagernet.fmt.http.HttpBean import io.nekohasekai.sagernet.fmt.internal.ConfigBean import io.nekohasekai.sagernet.fmt.shadowsocks.ShadowsocksBean import io.nekohasekai.sagernet.fmt.shadowsocksr.ShadowsocksRBean import io.nekohasekai.sagernet.fmt.socks.SOCKSBean import io.nekohasekai.sagernet.fmt.v2ray.VLESSBean import io.nekohasekai.sagernet.fmt.v2ray.VMessBean import io.nekohasekai.sagernet.group.RawUpdater interface ValidateResult object ResultSecure : ValidateResult object ResultLocal : ValidateResult class ResultDeprecated(@RawRes val textRes: Int) : ValidateResult class ResultInsecure(@RawRes val textRes: Int) : ValidateResult val ssSecureList = "(gcm|poly1305)".toRegex() fun AbstractBean.isInsecure(): ValidateResult { if (Validator.isIpv4(serverAddress) && isInnerIP(serverAddress) || serverAddress in arrayOf( "localhost", "::" ) ) { return ResultLocal } if (this is ShadowsocksBean) { if (plugin.isBlank() || PluginConfiguration(plugin).selected == "obfs-local") { if (!method.contains(ssSecureList)) { return ResultInsecure(R.raw.shadowsocks_stream_cipher) } } } else if (this is ShadowsocksRBean) { return ResultDeprecated(R.raw.shadowsocksr) } else if (this is HttpBean) { if (!tls) return ResultInsecure(R.raw.not_encrypted) } else if (this is SOCKSBean) { if (!tls) return ResultInsecure(R.raw.not_encrypted) } else if (this is VMessBean) { if (security in arrayOf("", "none")) { if (encryption in arrayOf("none", "zero")) { return ResultInsecure(R.raw.not_encrypted) } } if (type == "kcp" && mKcpSeed.isBlank()) { return ResultInsecure(R.raw.mkcp_no_seed) } if (allowInsecure) return ResultInsecure(R.raw.insecure) if (alterId > 0) return ResultDeprecated(R.raw.vmess_md5_auth) } else if (this is VLESSBean) { if (security in arrayOf("", "none")) { return ResultInsecure(R.raw.not_encrypted) } if (type == "kcp" && mKcpSeed.isBlank()) { return ResultInsecure(R.raw.mkcp_no_seed) } if (allowInsecure) return ResultInsecure(R.raw.insecure) } else if (this is ConfigBean) { try { val profiles = RawUpdater.parseJSON(JSONObject(content)) val results = profiles.map { it.isInsecure() } (results.find { it is ResultInsecure } ?: results.find { it is ResultDeprecated } ?: results.find { it is ResultLocal })?.also { return it } } catch (ignored: Exception) { } } return ResultSecure } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/plugin/NativePlugin.kt ================================================ /****************************************************************************** * * * Copyright (C) 2021 by nekohasekai * * Copyright (C) 2021 by Max Lv * * Copyright (C) 2021 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.plugin import android.content.pm.ResolveInfo class NativePlugin(resolveInfo: ResolveInfo) : ResolvedPlugin(resolveInfo) { init { check(resolveInfo.providerInfo != null) } override val componentInfo get() = resolveInfo.providerInfo!! } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/plugin/Plugin.kt ================================================ /****************************************************************************** * * * Copyright (C) 2021 by nekohasekai * * Copyright (C) 2021 by Max Lv * * Copyright (C) 2021 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.plugin import android.graphics.drawable.Drawable abstract class Plugin { abstract val id: String abstract val label: CharSequence abstract val version: Int abstract val versionName: String open val icon: Drawable? get() = null open val defaultConfig: String? get() = null open val packageName: String get() = "" open val directBootAware: Boolean get() = true override fun equals(other: Any?): Boolean { if (this === other) return true if (javaClass != other?.javaClass) return false return id == (other as Plugin).id } override fun hashCode() = id.hashCode() } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/plugin/PluginList.kt ================================================ /****************************************************************************** * * * Copyright (C) 2021 by nekohasekai * * Copyright (C) 2021 by Max Lv * * Copyright (C) 2021 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.plugin import android.content.Intent import android.content.pm.PackageManager import io.nekohasekai.sagernet.SagerNet class PluginList : ArrayList() { init { addAll(SagerNet.application.packageManager.queryIntentContentProviders( Intent(PluginContract.ACTION_NATIVE_PLUGIN), PackageManager.GET_META_DATA) .filter { it.providerInfo.exported }.map { NativePlugin(it) }) } val lookup = mutableMapOf().apply { for (plugin in this@PluginList.toList()) { fun check(old: Plugin?) { if (old != null && old != plugin) { this@PluginList.remove(old) } /* if (old != null && old !== plugin) { val packages = this@PluginList.filter { it.id == plugin.id } .joinToString { it.packageName } val message = "Conflicting plugins found from: $packages" Toast.makeText(SagerNet.application, message, Toast.LENGTH_LONG).show() throw IllegalStateException(message) }*/ } check(put(plugin.id, plugin)) } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/plugin/PluginManager.kt ================================================ /****************************************************************************** * * * Copyright (C) 2021 by nekohasekai * * Copyright (C) 2021 by Max Lv * * Copyright (C) 2021 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.plugin import android.annotation.SuppressLint import android.content.BroadcastReceiver import android.content.ContentResolver import android.content.Intent import android.content.pm.ComponentInfo import android.content.pm.PackageManager import android.content.pm.ProviderInfo import android.database.Cursor import android.net.Uri import android.os.Build import android.system.Os import android.widget.Toast import androidx.core.os.bundleOf import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.SagerNet import io.nekohasekai.sagernet.bg.BaseService import io.nekohasekai.sagernet.ktx.Logs import io.nekohasekai.sagernet.ktx.listenForPackageChanges 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) } private var receiver: BroadcastReceiver? = null private var cachedPlugins: PluginList? = null fun fetchPlugins() = synchronized(this) { if (receiver == null) receiver = SagerNet.application.listenForPackageChanges { synchronized(this) { receiver = null cachedPlugins = null } } if (cachedPlugins == null) cachedPlugins = PluginList() cachedPlugins!! } private fun buildUri(id: String) = Uri.Builder() .scheme(PluginContract.SCHEME) .authority(PluginContract.AUTHORITY) .path("/$id") .build() data class InitResult( val path: String, ) @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) { if (throwable == null) throwable = t else Logs.w(t) } throw throwable ?: PluginNotFoundException(pluginId) } private fun initNative(pluginId: String): InitResult? { 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 providers = SagerNet.application.packageManager.queryIntentContentProviders( Intent(PluginContract.ACTION_NATIVE_PLUGIN, buildUri(pluginId)), flags) .filter { it.providerInfo.exported } if (providers.isEmpty()) return null if (providers.size > 1) { val message = "Conflicting plugins found from: ${providers.joinToString { it.providerInfo.packageName }}" Toast.makeText(SagerNet.application, message, Toast.LENGTH_LONG).show() throw IllegalStateException(message) } val provider = providers.single().providerInfo var failure: Throwable? = null try { initNativeFaster(provider)?.also { return InitResult(it) } } catch (t: Throwable) { Logs.w("Initializing native plugin faster mode failed") failure = t } val uri = Uri.Builder().apply { scheme(ContentResolver.SCHEME_CONTENT) authority(provider.authority) }.build() try { return initNativeFast(SagerNet.application.contentResolver, pluginId, uri)?.let { InitResult(it) } } catch (t: Throwable) { Logs.w("Initializing native plugin fast mode failed") failure?.also { t.addSuppressed(it) } failure = t } try { return initNativeSlow(SagerNet.application.contentResolver, pluginId, uri)?.let { InitResult(it) } } catch (t: Throwable) { failure?.also { t.addSuppressed(it) } throw t } } private fun initNativeFaster(provider: ProviderInfo): String? { return provider.loadString(PluginContract.METADATA_KEY_EXECUTABLE_PATH) ?.let { relativePath -> File(provider.applicationInfo.nativeLibraryDir).resolve(relativePath).apply { check(canExecute()) }.absolutePath } } private fun initNativeFast(cr: ContentResolver, pluginId: String, uri: Uri): String? { return cr.call(uri, PluginContract.METHOD_GET_EXECUTABLE, null, bundleOf()) ?.getString(PluginContract.EXTRA_ENTRY)?.also { check(File(it).canExecute()) } } @SuppressLint("Recycle") private fun initNativeSlow(cr: ContentResolver, pluginId: String, uri: Uri): String? { var initialized = false fun entryNotFound(): Nothing = throw IndexOutOfBoundsException("Plugin entry binary not found") val pluginDir = File(SagerNet.deviceStorage.noBackupFilesDir, "plugin") (cr.query(uri, arrayOf(PluginContract.COLUMN_PATH, PluginContract.COLUMN_MODE), null, null, null) ?: return null).use { cursor -> if (!cursor.moveToFirst()) entryNotFound() pluginDir.deleteRecursively() if (!pluginDir.mkdirs()) throw FileNotFoundException("Unable to create plugin directory") val pluginDirPath = pluginDir.absolutePath + '/' do { val path = cursor.getString(0) val file = File(pluginDir, path) check(file.absolutePath.startsWith(pluginDirPath)) cr.openInputStream(uri.buildUpon().path(path).build())!!.use { inStream -> file.outputStream().use { outStream -> inStream.copyTo(outStream) } } Os.chmod(file.absolutePath, when (cursor.getType(1)) { Cursor.FIELD_TYPE_INTEGER -> cursor.getInt(1) Cursor.FIELD_TYPE_STRING -> cursor.getString(1).toInt(8) else -> throw IllegalArgumentException("File mode should be of type int") }) if (path == pluginId) initialized = true } while (cursor.moveToNext()) } if (!initialized) entryNotFound() return File(pluginDir, pluginId).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/plugin/ResolvedPlugin.kt ================================================ /****************************************************************************** * * * Copyright (C) 2021 by nekohasekai * * Copyright (C) 2021 by Max Lv * * Copyright (C) 2021 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.plugin import android.content.pm.ComponentInfo import android.content.pm.ResolveInfo import android.graphics.drawable.Drawable import android.os.Build import io.nekohasekai.sagernet.SagerNet import io.nekohasekai.sagernet.plugin.PluginManager.loadString abstract class ResolvedPlugin(protected val resolveInfo: ResolveInfo) : Plugin() { protected abstract val componentInfo: ComponentInfo override val id by lazy { componentInfo.loadString(PluginContract.METADATA_KEY_ID)!! } override val version by lazy { SagerNet.application.getPackageInfo(componentInfo.packageName).versionCode } override val versionName by lazy { SagerNet.application.getPackageInfo(componentInfo.packageName).versionName } override val label: CharSequence get() = resolveInfo.loadLabel(SagerNet.application.packageManager) override val icon: Drawable get() = resolveInfo.loadIcon(SagerNet.application.packageManager) override val packageName: String get() = componentInfo.packageName override val directBootAware get() = Build.VERSION.SDK_INT < 24 || componentInfo.directBootAware } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ui/AboutFragment.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 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.os.SystemClock import android.provider.Settings import android.text.util.Linkify import android.view.View 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.fmt.PluginEntry import io.nekohasekai.sagernet.ktx.* import io.nekohasekai.sagernet.plugin.PluginManager import io.nekohasekai.sagernet.widget.ListHolderListener import libcore.Libcore 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, ListHolderListener) toolbar.setTitle(R.string.menu_about) var eTime = 0L var eCount = 0 binding.titleCard.setOnClickListener { val time = SystemClock.elapsedRealtime() if (time - eTime >= 1000L) eCount = 1 else if (++eCount >= 3) { requireContext().launchCustomTab("https://github.com/XTLS") } eTime = time } 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 { var versionName = BuildConfig.VERSION_NAME if (isExpert) { versionName += "-${BuildConfig.FLAVOR}" } return MaterialAboutList.Builder() .addCard(MaterialAboutCard.Builder() .outline(false) .addItem(MaterialAboutActionItem.Builder() .icon(R.drawable.ic_baseline_update_24) .text(R.string.app_version) .subText(versionName) .setOnClickAction { requireContext().launchCustomTab( "https://github.com/XTLS/AnXray/releases" ) } .build()) .addItem(MaterialAboutActionItem.Builder() .icon(R.drawable.ic_baseline_airplanemode_active_24) .text(getString(R.string.version_x, "Xray-core")) .subText("v" + Libcore.getV2RayVersion()) .setOnClickAction { requireContext().launchCustomTab( "https://github.com/XTLS/Xray-core/releases" ) } .build()) .apply { val m = enumValues().associateBy { it.pluginId } for (plugin in PluginManager.fetchPlugins()) { if (!m.containsKey(plugin.id)) continue try { addItem(MaterialAboutActionItem.Builder() .icon(R.drawable.ic_baseline_nfc_24) .text(getString(R.string.version_x, plugin.id)) .subText("v" + plugin.versionName) .setOnClickAction { startActivity(Intent().apply { action = Settings.ACTION_APPLICATION_DETAILS_SETTINGS data = Uri.fromParts( "package", plugin.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, Uri.parse("package:${app.packageName}") ) ) } .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://liberapay.com/nekohasekai" ) } .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/XTLS/AnXray" ) } .build()) .addItem(MaterialAboutActionItem.Builder() .icon(R.drawable.baseline_translate_24) .text(R.string.translate_platform) .setOnClickAction { requireContext().launchCustomTab( "https://hosted.weblate.org/engage/sagernet/" ) } .build()) .addItem(MaterialAboutActionItem.Builder() .icon(R.drawable.ic_qu_shadowsocks_foreground) .text(R.string.telegram) .setOnClickAction { requireContext().launchCustomTab( "https://t.me/AnXray" ) } .build()) .addItem(MaterialAboutActionItem.Builder() .icon(R.drawable.ic_action_copyright) .text(R.string.oss_licenses) .setOnClickAction { startActivity(Intent(context, LicenseActivity::class.java)) } .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 } } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ui/ActiveFragment.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 import android.os.Bundle import android.text.format.Formatter import android.view.View import android.view.ViewGroup import androidx.appcompat.widget.PopupMenu import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.recyclerview.widget.RecyclerView import io.nekohasekai.sagernet.Key import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.SagerNet import io.nekohasekai.sagernet.aidl.AppStats import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.databinding.LayoutTrafficItemBinding import io.nekohasekai.sagernet.databinding.LayoutTrafficListBinding import io.nekohasekai.sagernet.ktx.* import io.nekohasekai.sagernet.utils.PackageCache class ActiveFragment : Fragment(R.layout.layout_traffic_list) { lateinit var binding: LayoutTrafficListBinding lateinit var adapter: ActiveAdapter override fun onViewCreated(view: View, savedInstanceState: Bundle?) { binding = LayoutTrafficListBinding.bind(view) adapter = ActiveAdapter() binding.trafficList.layoutManager = FixedLinearLayoutManager(binding.trafficList) binding.trafficList.adapter = adapter (parentFragment as TrafficFragment).listeners.add(::emitStats) emitStats(emptyList()) } fun emitStats(statsList: List) { if (statsList.isEmpty()) { runOnMainDispatcher { binding.holder.isVisible = true binding.trafficList.isVisible = false if (!SagerNet.started || DataStore.serviceMode != Key.MODE_VPN) { binding.holder.text = getString(R.string.traffic_holder) } else if ((activity as MainActivity).connection.service?.trafficStatsEnabled != true) { binding.holder.text = getString(R.string.app_statistics_disabled) } else { binding.holder.text = getString(R.string.no_statistics) } } binding.trafficList.post { adapter.data = emptyList() adapter.notifyDataSetChanged() } } else { runOnMainDispatcher { binding.holder.isVisible = false binding.trafficList.isVisible = true } val now = System.currentTimeMillis() / 1000 val list = statsList.filter { it.deactivateAt == 0 || now - it.deactivateAt < 5 } .toSortedSet { a, b -> val dataA = a.uplink + a.downlink val dataB = b.uplink + b.downlink if (dataA != dataB) { dataB.compareTo(dataA) } else { val connA = a.tcpConnections + a.udpConnections val connB = b.tcpConnections + b.udpConnections if (connA != connB) { connB.compareTo(connA) } else { b.packageName.compareTo(a.packageName) } } } .toList() binding.trafficList.post { adapter.data = list adapter.notifyDataSetChanged() } } } inner class ActiveAdapter : RecyclerView.Adapter() { init { setHasStableIds(true) } lateinit var data: List override fun getItemId(position: Int): Long { return data[position].uid.toLong() } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ActiveViewHolder { return ActiveViewHolder( LayoutTrafficItemBinding.inflate(layoutInflater, parent, false) ) } override fun onBindViewHolder(holder: ActiveViewHolder, position: Int) { holder.bind(data[position]) } override fun getItemCount(): Int { if (!::data.isInitialized) return 0 return data.size } } inner class ActiveViewHolder(val binding: LayoutTrafficItemBinding) : RecyclerView.ViewHolder( binding.root ) { fun bind(stats: AppStats) { PackageCache.awaitLoadSync() val packageName = if (stats.uid > 1000) { PackageCache.uidMap[stats.uid]?.iterator()?.next() ?: "android" } else { "android" } binding.menu.setOnClickListener { val popup = PopupMenu(requireContext(), it) popup.menuInflater.inflate(R.menu.traffic_item_menu, popup.menu) popup.setOnMenuItemClickListener( (requireParentFragment() as TrafficFragment).ItemMenuListener( stats ) ) popup.show() } binding.label.text = PackageCache.loadLabel(packageName) binding.desc.text = "$packageName (${stats.uid})" binding.tcpConnections.text = getString(R.string.tcp_connections, stats.tcpConnections) binding.udpConnections.text = getString(R.string.udp_connections, stats.udpConnections) binding.trafficUplink.text = getString( R.string.traffic_uplink, Formatter.formatFileSize(requireContext(), stats.uplinkTotal), Formatter.formatFileSize(requireContext(), stats.uplink) ) binding.trafficDownlink.text = getString( R.string.traffic_downlink, Formatter.formatFileSize(requireContext(), stats.downlinkTotal), Formatter.formatFileSize(requireContext(), stats.downlink) ) val info = PackageCache.installedApps[packageName] if (info != null) runOnDefaultDispatcher { try { val icon = info.loadIcon(app.packageManager) onMainDispatcher { binding.icon.setImageDrawable(icon) } } catch (ignored: Exception) { } } } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ui/AppListActivity.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 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.* 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.ListHolderListener 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})" 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.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() { apps = cachedApps.map { (packageName, packageInfo) -> coroutineContext[Job]!!.ensureActive() ProxiedApp(packageManager, packageInfo.applicationInfo, 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()) proxiedUids[(apps[line] ?: continue).applicationInfo.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() ?: "") binding.list.crossFadeFrom(loading) } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = LayoutAppListBinding.inflate(layoutInflater) setContentView(binding.root) ListHolderListener.setup(this) 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 ================================================ /****************************************************************************** * * * 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 import android.annotation.SuppressLint import android.content.Intent import android.content.pm.ApplicationInfo import android.content.pm.PackageInfo import android.content.pm.PackageManager import android.graphics.drawable.Drawable import android.os.Bundle import android.util.SparseBooleanArray import android.view.* import android.widget.Filter import android.widget.Filterable import android.widget.TextView 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.databinding.LayoutLoadingBinding import io.nekohasekai.sagernet.ktx.Logs 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.ListHolderListener import io.nekohasekai.sagernet.widget.ListListener import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.ensureActive import kotlinx.coroutines.withContext import okhttp3.internal.closeQuietly import org.jf.dexlib2.dexbacked.DexBackedDexFile import org.jf.dexlib2.iface.DexFile import java.io.File import java.util.zip.ZipException import java.util.zip.ZipFile 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() { apps = cachedApps.map { (packageName, packageInfo) -> coroutineContext[Job]!!.ensureActive() ProxiedApp(packageManager, packageInfo.applicationInfo, 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()) proxiedUids[(apps[line] ?: continue).applicationInfo.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() ?: "") binding.list.crossFadeFrom(loading) } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = LayoutAppsBinding.inflate(layoutInflater) setContentView(binding.root) ListHolderListener.setup(this) 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 } } 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_scan_china_apps -> { scanChinaApps() return true } 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.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) } @SuppressLint("SetTextI18n") private fun scanChinaApps() { val text: TextView val dialog = MaterialAlertDialogBuilder(this).setView( LayoutLoadingBinding.inflate(layoutInflater).apply { text = loadingText }.root ).setCancelable(false).show() val txt = text.text.toString() runOnDefaultDispatcher { val chinaApps = ArrayList>() val chinaRegex = ("(" + arrayOf( "com.tencent", "com.alibaba", "com.umeng", "com.qihoo", "com.ali", "com.alipay", "com.amap", "com.sina", "com.weibo", "com.vivo", "com.xiaomi", "com.huawei", "com.taobao", "com.secneo", "s.h.e.l.l", "com.stub", "com.kiwisec", "com.secshell", "com.wrapper", "cn.securitystack", "com.mogosec", "com.secoen", "com.netease", "com.mx", "com.qq.e", "com.baidu", "com.bytedance", "com.bugly", "com.miui", "com.oppo", "com.coloros", "com.iqoo", "com.meizu", "com.gionee", "cn.nubia" ).joinToString("|") { "${it.replace(".", "\\.")}\\." } + ").*").toRegex() val bypass = DataStore.bypass val cachedApps = cachedApps apps = cachedApps.map { (packageName, packageInfo) -> kotlin.coroutines.coroutineContext[Job]!!.ensureActive() ProxiedApp(packageManager, packageInfo.applicationInfo, packageName) }.sortedWith(compareBy({ !isProxiedApp(it) }, { it.name.toString() })) scan@ for ((pkg, app) in cachedApps.entries) { /*if (!sysApps && app.applicationInfo.flags and ApplicationInfo.FLAG_SYSTEM != 0) { continue }*/ val index = appsAdapter.filteredApps.indexOfFirst { it.uid == app.applicationInfo.uid } var changed = false onMainDispatcher { text.text = (txt + " " + app.packageName + "\n\n" + chinaApps.map { it.second } .reversed() .joinToString("\n", postfix = "\n")).trim() } try { val dex = File(app.applicationInfo.publicSourceDir) val zipFile = ZipFile(dex) var dexFile: DexFile for (entry in zipFile.entries()) { if (entry.name.startsWith("classes") && entry.name.endsWith(".dex")) { val input = zipFile.getInputStream(entry).readBytes() dexFile = try { DexBackedDexFile.fromInputStream(null, input.inputStream()) } catch (e: Exception) { Logs.w(e) break } for (clazz in dexFile.classes) { val clazzName = clazz.type.substring(1, clazz.type.length - 1) .replace("/", ".") .replace("$", ".") if (clazzName.matches(chinaRegex)) { chinaApps.add( app to app.applicationInfo.loadLabel(packageManager) .toString() ) zipFile.closeQuietly() if (bypass) { changed = !proxiedUids[app.applicationInfo.uid] proxiedUids[app.applicationInfo.uid] = true } else { proxiedUids.delete(app.applicationInfo.uid) } continue@scan } } } } zipFile.closeQuietly() if (bypass) { proxiedUids.delete(app.applicationInfo.uid) } else { changed = !proxiedUids[index] proxiedUids[app.applicationInfo.uid] = true } } catch (e: ZipException) { Logs.w("Error in pkg ${app.packageName}:${app.versionName}", e) continue } } 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() ?: "") dialog.dismiss() } } } 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 ================================================ /****************************************************************************** * * * 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 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 cn.hutool.json.JSONObject 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 okhttp3.Request import java.io.File import java.io.FileNotFoundException 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 internalFiles = arrayOf("geoip.dat", "geosite.dat") 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(".dat")) { 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() } 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(".dat") && it.name !in internalFiles } assets.clear() assets.add(File(filesDir, "geoip.dat")) assets.add( File( filesDir, "geosite.dat" ) ) 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) { versionFile.readText().trim() } else { DateFormat.getDateFormat(app).format(Date(file.lastModified())) } } else { try { assets.open("v2ray/" + versionFile.name).bufferedReader().readText().trim() } catch (e: FileNotFoundException) { versionFile.readText() Logs.w(e) "" } } binding.assetStatus.text = getString(R.string.route_asset_status, localVersion) binding.rulesUpdate.isInvisible = file.name !in internalFiles 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).show() } } onMainDispatcher { binding.rulesUpdate.isInvisible = false binding.subscriptionUpdateProgress.isInvisible = true if (updating.decrementAndGet() == 0) { layout.refreshLayout.isEnabled = true } } } } } } suspend fun updateAsset(file: File, versionFile: File, localVersion: String) { val okHttpClient = createProxyClient() val repo: String var fileName = file.name if (DataStore.rulesProvider == 0) { if (file.name == internalFiles[0]) { repo = "SagerNet/geoip" } else { repo = "v2fly/domain-list-community" fileName = "dlc.dat" } fileName = "$fileName.xz" } else { repo = "Loyalsoldier/v2ray-rules-dat" } var response = okHttpClient.newCall( Request.Builder().url("https://api.github.com/repos/$repo/releases/latest").build() ).execute() if (!response.isSuccessful) { error("Error when fetching latest release of $repo : HTTP ${response.code}\n\n${response.body?.string()}") } val release = JSONObject(response.body!!.string()) val tagName = release.getStr("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 = okHttpClient.newCall( Request.Builder().url(browserDownloadUrl).build() ).execute() if (!response.isSuccessful) { error("Error when downloading $browserDownloadUrl : HTTP ${response.code}") } val cacheFile = File(file.parentFile, file.name + ".tmp") response.body!!.use { body -> body.byteStream().use(cacheFile.outputStream()) } 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() } } override fun onSupportNavigateUp(): Boolean { finish() return true } override fun onBackPressed() { finish() } override fun onResume() { super.onResume() if (::adapter.isInitialized) { adapter.reloadAssets() } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ui/CloudflareFragment.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 import android.os.Bundle import android.view.View import androidx.appcompat.app.AlertDialog import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.database.ProfileManager import io.nekohasekai.sagernet.databinding.LayoutCloudflareBinding import io.nekohasekai.sagernet.databinding.LayoutProgressBinding import io.nekohasekai.sagernet.ktx.onMainDispatcher import io.nekohasekai.sagernet.ktx.readableMessage import io.nekohasekai.sagernet.ktx.runOnDefaultDispatcher import io.nekohasekai.sagernet.utils.Cloudflare import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.runBlocking class CloudflareFragment : NamedFragment(R.layout.layout_cloudflare) { override fun name() = "CloudFlare" override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) val binding = LayoutCloudflareBinding.bind(view) binding.warpGenerate.setOnClickListener { runBlocking { generateWarpConfiguration() } } } suspend fun generateWarpConfiguration() { val activity = requireActivity() as MainActivity val binding = LayoutProgressBinding.inflate(layoutInflater) var job: Job? = null val dialog = AlertDialog.Builder(requireContext()) .setView(binding.root) .setCancelable(false) .setNegativeButton(android.R.string.cancel) { _, _ -> job?.cancel() } .show() job = runOnDefaultDispatcher { try { val bean = Cloudflare.makeWireGuardConfiguration() if (isActive) { val groupId = DataStore.selectedGroupForImport() if (DataStore.selectedGroup != groupId) { DataStore.selectedGroup = groupId } onMainDispatcher { activity.displayFragmentWithId(R.id.nav_configuration) } delay(1000L) onMainDispatcher { dialog.dismiss() } ProfileManager.createProfile(groupId, bean) } } catch (e: Exception) { onMainDispatcher { if (isActive) { dialog.dismiss() activity.snackbar(e.readableMessage).show() } } } } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.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 import android.annotation.SuppressLint import android.content.ActivityNotFoundException import android.content.DialogInterface import android.content.Intent import android.graphics.Color import android.net.Uri import android.os.Bundle import android.os.SystemClock import android.provider.OpenableColumns import android.text.format.Formatter import android.text.method.LinkMovementMethod import android.text.util.Linkify import android.util.TypedValue import android.view.* 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.Toolbar import androidx.core.graphics.TypefaceCompat import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.core.view.size import androidx.fragment.app.Fragment import androidx.recyclerview.widget.DefaultItemAnimator 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.* import io.nekohasekai.sagernet.aidl.TrafficStats import io.nekohasekai.sagernet.bg.BaseService import io.nekohasekai.sagernet.bg.test.LocalDnsInstance import io.nekohasekai.sagernet.bg.test.UrlTest import io.nekohasekai.sagernet.database.* import io.nekohasekai.sagernet.databinding.LayoutProfileBinding 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.fmt.v2ray.toV2rayN import io.nekohasekai.sagernet.group.RawUpdater import io.nekohasekai.sagernet.ktx.* import io.nekohasekai.sagernet.plugin.PluginManager import io.nekohasekai.sagernet.ui.profile.* import io.nekohasekai.sagernet.widget.QRCodeDialog import io.nekohasekai.sagernet.widget.UndoSnackbarManager import kotlinx.coroutines.* import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import libcore.Libcore import okhttp3.internal.closeQuietly import java.net.InetAddress import java.net.InetSocketAddress import java.net.Socket import java.net.UnknownHostException import java.util.concurrent.ConcurrentLinkedQueue import java.util.zip.ZipInputStream class ConfigurationFragment @JvmOverloads constructor( val select: Boolean = false, val selectedItem: ProxyEntity? = null, ) : ToolbarFragment(R.layout.layout_group_list), PopupMenu.OnMenuItemClickListener, Toolbar.OnMenuItemClickListener { lateinit var adapter: GroupPagerAdapter lateinit var tabLayout: TabLayout lateinit var groupPager: ViewPager2 val selectedGroup get() = if (tabLayout.isGone) adapter.groupList[0] else adapter.groupList[tabLayout.selectedTabPosition] val alwaysShowAddress by lazy { DataStore.alwaysShowAddress } val securityAdvisory by lazy { DataStore.securityAdvisory } 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 onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) if (!select) { toolbar.inflateMenu(R.menu.add_profile_menu) toolbar.setOnMenuItemClickListener(this) runCatching { val mTitleTextView = Toolbar::class.java.getDeclaredField("mTitleTextView") .apply { isAccessible = true }.get(toolbar) as TextView mTitleTextView.typeface = TypefaceCompat.createFromResourcesFontFile( view.context, resources, R.font.bgothm, "res/font/bgothm.ttf", mTitleTextView.typeface.style ) mTitleTextView.setTextSize(TypedValue.COMPLEX_UNIT_PX, (mTitleTextView.textSize * 1.35).toFloat() ) }.onFailure { Logs.w(it) } } else { toolbar.setTitle(R.string.select_profile) toolbar.setNavigationIcon(R.drawable.ic_navigation_close) toolbar.setNavigationOnClickListener { requireActivity().finish() } } 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 = (childFragmentManager.findFragmentByTag("f" + selectedGroup.id) as GroupFragment?) 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) } } } override fun onDestroy() { if (::adapter.isInitialized) { GroupManager.removeListener(adapter) ProfileManager.removeListener(adapter) } super.onDestroy() } override fun onKeyDown(ketCode: Int, event: KeyEvent): Boolean { val fragment = (childFragmentManager.findFragmentByTag("f" + selectedGroup.id) as GroupFragment?) fragment?.configurationListView?.apply { if (!hasFocus()) requestFocus() } return super.onKeyDown(ketCode, event) } 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)?.let { pl -> proxies.addAll(pl) } zip.closeEntry() } zip.closeQuietly() } else { val fileText = requireContext().contentResolver.openInputStream(file)!!.use { it.bufferedReader().readText() } RawUpdater.parseRaw(fileText)?.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(Uri.parse(e.link)) } catch (e: Exception) { Logs.w(e) onMainDispatcher { snackbar(e.readableMessage).show() } } } } suspend fun import(proxies: List) { val targetId = DataStore.selectedGroupForImport() val targetIndex = adapter.groupList.indexOfFirst { it.id == targetId } for (proxy in proxies) { ProfileManager.createProfile(targetId, proxy) } onMainDispatcher { if (adapter.groupList.isEmpty() || selectedGroup.id != targetId) { if (targetIndex != -1) { tabLayout.getTabAt(targetIndex)?.select() } else { DataStore.selectedGroup = targetId adapter.reload() } } 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(Uri.parse(e.link)) } 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_ssr -> { startActivity(Intent(requireActivity(), ShadowsocksRSettingsActivity::class.java)) } R.id.action_new_vmess -> { startActivity(Intent(requireActivity(), VMessSettingsActivity::class.java)) } R.id.action_new_vless -> { startActivity(Intent(requireActivity(), VLESSSettingsActivity::class.java)) } 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_naive -> { startActivity(Intent(requireActivity(), NaiveSettingsActivity::class.java)) } R.id.action_new_ping_tunnel -> { startActivity(Intent(requireActivity(), PingTunnelSettingsActivity::class.java)) } R.id.action_new_relay_baton -> { startActivity(Intent(requireActivity(), RelayBatonSettingsActivity::class.java)) } R.id.action_new_brook -> { startActivity(Intent(requireActivity(), BrookSettingsActivity::class.java)) } R.id.action_new_hysteria -> { startActivity(Intent(requireActivity(), HysteriaSettingsActivity::class.java)) } R.id.action_new_snell -> { startActivity(Intent(requireActivity(), SnellSettingsActivity::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_config -> { startActivity(Intent(requireActivity(), ConfigSettingsActivity::class.java)) } R.id.action_new_chain -> { startActivity(Intent(requireActivity(), ChainSettingsActivity::class.java)) } /*R.id.action_new_balancer -> { startActivity(Intent(requireActivity(), BalancerSettingsActivity::class.java)) }*/ 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_icmp_ping -> { pingTest(true) } R.id.action_connection_tcp_ping -> { pingTest(false) } R.id.action_connection_url_test -> { urlTest() } R.id.action_filter_groups -> { runOnDefaultDispatcher filter@{ val group = SagerDatabase.groupDao.getById(DataStore.currentGroupId())!! if (group.subscription?.type != SubscriptionType.OOCv1) { snackbar(getString(R.string.group_filter_ns)).show() return@filter } val subscription = group.subscription!! val profiles = SagerDatabase.proxyDao.getByGroup(DataStore.currentGroupId()) val groups = profiles.mapNotNull { it.requireBean().group } .toSet() .toTypedArray() val checked = groups.map { it in subscription.selectedGroups }.toBooleanArray() if (groups.isEmpty()) { snackbar(getString(R.string.group_filter_groups_nf)).show() return@filter } onMainDispatcher { MaterialAlertDialogBuilder(requireContext()).setTitle(R.string.group_filter_groups) .setMultiChoiceItems(groups, checked) { _, which, isChecked -> val selected = groups[which] if (isChecked) { subscription.selectedGroups.add(selected) } else { subscription.selectedGroups.remove(selected) } } .setPositiveButton(android.R.string.ok) { _, _ -> runOnDefaultDispatcher { GroupManager.updateGroup(group) } } .setNegativeButton(android.R.string.cancel, null) .show() } } } R.id.group_filter_owners -> { runOnDefaultDispatcher filter@{ val group = SagerDatabase.groupDao.getById(DataStore.currentGroupId())!! if (group.subscription?.type != SubscriptionType.OOCv1) { snackbar(getString(R.string.group_filter_ns)).show() return@filter } val subscription = group.subscription!! val profiles = SagerDatabase.proxyDao.getByGroup(DataStore.currentGroupId()) val owners = profiles.mapNotNull { it.requireBean().owner } .toSet() .toTypedArray() val checked = owners.map { it in subscription.selectedOwners }.toBooleanArray() if (owners.isEmpty()) { snackbar(getString(R.string.group_filter_owners_nf)).show() return@filter } onMainDispatcher { MaterialAlertDialogBuilder(requireContext()).setTitle(R.string.group_filter_groups) .setMultiChoiceItems(owners, checked) { _, which, isChecked -> val selected = owners[which] if (isChecked) { subscription.selectedOwners.add(selected) } else { subscription.selectedOwners.remove(selected) } } .setPositiveButton(android.R.string.ok) { _, _ -> runOnDefaultDispatcher { GroupManager.updateGroup(group) } } .setNegativeButton(android.R.string.cancel, null) .show() } } } R.id.action_filter_tags -> { runOnDefaultDispatcher filter@{ val group = DataStore.currentGroup() if (group.subscription?.type != SubscriptionType.OOCv1) { snackbar(getString(R.string.group_filter_ns)).show() return@filter } val subscription = group.subscription!! val profiles = SagerDatabase.proxyDao.getByGroup(group.id) val groups = profiles.flatMap { it.requireBean().tags ?: listOf() } .toSet() .toTypedArray() val checked = groups.map { it in subscription.selectedTags }.toBooleanArray() if (groups.isEmpty()) { snackbar(getString(R.string.group_filter_tags_nf)).show() return@filter } onMainDispatcher { MaterialAlertDialogBuilder(requireContext()).setTitle(R.string.group_filter_tags) .setMultiChoiceItems(groups, checked) { _, which, isChecked -> val selected = groups[which] if (isChecked) { subscription.selectedTags.add(selected) } else { subscription.selectedTags.remove(selected) } } .setPositiveButton(android.R.string.ok) { _, _ -> runOnDefaultDispatcher { GroupManager.updateGroup(group) } } .setNegativeButton(android.R.string.cancel, null) .show() } } } } return true } inner class TestDialog { val binding = LayoutProgressListBinding.inflate(layoutInflater) val builder = MaterialAlertDialogBuilder(requireContext()).setView(binding.root) .setNegativeButton(android.R.string.cancel) { _, _ -> cancel() } .setCancelable(false) lateinit var cancel: () -> Unit val results = ArrayList() val adapter = TestAdapter() suspend fun insert(profile: ProxyEntity) { binding.listView.post { results.add(profile) adapter.notifyItemInserted(results.size - 1) binding.listView.scrollToPosition(results.size - 1) } } suspend fun update(profile: ProxyEntity) { binding.listView.post { val index = results.indexOf(profile) adapter.notifyItemChanged(index) } } init { binding.listView.layoutManager = FixedLinearLayoutManager(binding.listView) binding.listView.itemAnimator = DefaultItemAnimator() binding.listView.adapter = adapter } inner class TestAdapter : RecyclerView.Adapter() { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = TestResultHolder(LayoutProfileBinding.inflate(layoutInflater, parent, false)) override fun onBindViewHolder(holder: TestResultHolder, position: Int) { holder.bind(results[position]) } override fun getItemCount() = results.size } inner class TestResultHolder(val binding: LayoutProfileBinding) : RecyclerView.ViewHolder( binding.root ) { init { binding.edit.isGone = true binding.share.isGone = true } fun bind(profile: ProxyEntity) { binding.profileName.text = profile.displayName() binding.profileType.text = profile.displayType() when (profile.status) { -1 -> { binding.profileStatus.text = profile.error binding.profileStatus.setTextColor(requireContext().getColorAttr(android.R.attr.textColorSecondary)) } 0 -> { binding.profileStatus.setText(R.string.connection_test_testing) binding.profileStatus.setTextColor(requireContext().getColorAttr(android.R.attr.textColorSecondary)) } 1 -> { binding.profileStatus.text = getString(R.string.available, profile.ping) binding.profileStatus.setTextColor(requireContext().getColour(R.color.material_green_500)) } 2 -> { binding.profileStatus.text = profile.error binding.profileStatus.setTextColor(requireContext().getColour(R.color.material_red_500)) } 3 -> { binding.profileStatus.setText(R.string.unavailable) binding.profileStatus.setTextColor(requireContext().getColour(R.color.material_red_500)) } } if (profile.status == 3) { binding.content.setOnClickListener { alert(profile.error ?: "").show() } } else { binding.content.setOnClickListener {} } } } } fun stopService() { if (SagerNet.started) SagerNet.stopService() } @Suppress("EXPERIMENTAL_API_USAGE") fun pingTest(icmpPing: Boolean) { stopService() val test = TestDialog() val testJobs = mutableListOf() val dialog = test.builder.show() val mainJob = runOnDefaultDispatcher { val group = DataStore.currentGroup() var profilesUnfiltered = SagerDatabase.proxyDao.getByGroup(group.id) if (group.subscription?.type == SubscriptionType.OOCv1) { val subscription = group.subscription!! if (subscription.selectedGroups.isNotEmpty()) { profilesUnfiltered = profilesUnfiltered.filter { it.requireBean().group in subscription.selectedGroups } } if (subscription.selectedOwners.isNotEmpty()) { profilesUnfiltered = profilesUnfiltered.filter { it.requireBean().owner in subscription.selectedOwners } } if (subscription.selectedTags.isNotEmpty()) { profilesUnfiltered = profilesUnfiltered.filter { profile -> profile.requireBean().tags.containsAll( subscription.selectedTags ) } } } val profiles = ConcurrentLinkedQueue(profilesUnfiltered) val testPool = newFixedThreadPoolContext(5, "Connection test pool") repeat(5) { testJobs.add(launch(testPool) { while (isActive) { val profile = profiles.poll() ?: break if (icmpPing) { if (!profile.requireBean().canICMPing()) { profile.status = -1 profile.error = app.getString(R.string.connection_test_icmp_ping_unavailable) test.insert(profile) continue } } else { if (!profile.requireBean().canTCPing()) { profile.status = -1 profile.error = app.getString(R.string.connection_test_tcp_ping_unavailable) test.insert(profile) continue } } profile.status = 0 test.insert(profile) var address = profile.requireBean().serverAddress if (!address.isIpAddress()) { try { InetAddress.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) { val result = Libcore.icmpPing( address, 5000 ) if (!isActive) break if (result != -1) { profile.status = 1 profile.ping = result } else { profile.status = 2 profile.error = getString(R.string.connection_test_unreachable) } test.update(profile) } else { val socket = Socket() try { socket.soTimeout = 5000 socket.bind(InetSocketAddress(0)) protectFromVpn(socket.fileDescriptor.int) val start = SystemClock.elapsedRealtime() socket.connect( InetSocketAddress( address, profile.requireBean().serverPort ), 5000 ) 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() testPool.close() ProfileManager.updateProfile(test.results.filter { it.status != 0 }) onMainDispatcher { test.binding.progressCircular.isGone = true dialog.getButton(DialogInterface.BUTTON_NEGATIVE).setText(android.R.string.ok) } } test.cancel = { mainJob.cancel() testJobs.forEach { it.cancel() } runOnDefaultDispatcher { ProfileManager.updateProfile(test.results.filter { it.status != 0 }) } } } @Suppress("EXPERIMENTAL_API_USAGE") fun urlTest() { stopService() val test = TestDialog() val dialog = test.builder.show() val testJobs = mutableListOf() val dnsInstance = LocalDnsInstance() val mainJob = runOnDefaultDispatcher { val group = DataStore.currentGroup() var profilesUnfiltered = SagerDatabase.proxyDao.getByGroup(group.id) if (group.subscription?.type == SubscriptionType.OOCv1) { val subscription = group.subscription!! if (subscription.selectedGroups.isNotEmpty()) { profilesUnfiltered = profilesUnfiltered.filter { it.requireBean().group in subscription.selectedGroups } } if (subscription.selectedOwners.isNotEmpty()) { profilesUnfiltered = profilesUnfiltered.filter { it.requireBean().owner in subscription.selectedOwners } } if (subscription.selectedTags.isNotEmpty()) { profilesUnfiltered = profilesUnfiltered.filter { profile -> profile.requireBean().tags.containsAll( subscription.selectedTags ) } } } val profiles = ConcurrentLinkedQueue(profilesUnfiltered) val urlTest = UrlTest() dnsInstance.launch() repeat(5) { testJobs.add(launch { while (isActive) { val profile = profiles.poll() ?: break profile.status = 0 test.insert(profile) 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) ProfileManager.updateProfile(profile) } }) } testJobs.joinAll() dnsInstance.closeQuietly() onMainDispatcher { test.binding.progressCircular.isGone = true dialog.getButton(DialogInterface.BUTTON_NEGATIVE).setText(android.R.string.ok) } } test.cancel = { dnsInstance.closeQuietly() mainJob.cancel() runOnDefaultDispatcher { GroupManager.postReload(DataStore.currentGroupId()) } } } inner class GroupPagerAdapter : FragmentStateAdapter(this), ProfileManager.Listener, GroupManager.Listener { var selectedGroupIndex = 0 var groupList: ArrayList = ArrayList() fun reload() { if (!select) { groupPager.unregisterOnPageChangeCallback(updateSelectedCallback) } runOnDefaultDispatcher { groupList = ArrayList(SagerDatabase.groupDao.allGroups()) if (groupList.isEmpty()) { SagerDatabase.groupDao.createGroup(ProxyGroup(ungrouped = true)) groupList = ArrayList(SagerDatabase.groupDao.allGroups()) } val hideUngrouped = groupList.size > 1 && SagerDatabase.proxyDao.countByGroup( groupList.find { it.ungrouped }!!.id ) == 0L if (hideUngrouped) groupList.removeAll { it.ungrouped } val selectedGroup = selectedItem?.groupId ?: DataStore.currentGroupId() if (selectedGroup != 0L) { val selectedIndex = groupList.indexOfFirst { it.id == selectedGroup } selectedGroupIndex = selectedIndex onMainDispatcher { groupPager.setCurrentItem(selectedIndex, false) } } groupPager.post { if (!select) { groupPager.registerOnPageChangeCallback(updateSelectedCallback) } } onMainDispatcher { notifyDataSetChanged() val hideTab = groupList.size < 2 tabLayout.isGone = hideTab toolbar.elevation = if (hideTab) 0F else dp2px(4).toFloat() } } } init { reload() } override fun getItemCount(): Int { return groupList.size } override fun createFragment(position: Int): Fragment { return GroupFragment().apply { proxyGroup = groupList[position] 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(profileId: Long, trafficStats: TrafficStats) = Unit override suspend fun onUpdated(profile: ProxyEntity) = 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 var scrolled = false override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, ): View? { return LayoutProfileListBinding.inflate(inflater).root } lateinit var undoManager: UndoSnackbarManager lateinit var adapter: ConfigurationAdapter 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 ((activity as? MainActivity) ?: return false).state.let { it.canStop || it == BaseService.State.Stopped } } private fun isProfileEditable(id: Long): Boolean { return ((activity as? MainActivity) ?: return false).state == BaseService.State.Stopped || id != DataStore.selectedProxy } lateinit var layoutManager: LinearLayoutManager lateinit var configurationListView: RecyclerView val select by lazy { (parentFragment as ConfigurationFragment).select } val selectedItem by lazy { (parentFragment as ConfigurationFragment).selectedItem } 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 && proxyGroup.type == GroupType.BASIC) { 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 if (isProfileEditable((viewHolder as ConfigurationHolder).entity.id)) { super.getSwipeDirs(recyclerView, viewHolder) } else 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) { val index = viewHolder.bindingAdapterPosition adapter.remove(index) undoManager.remove(index to (viewHolder as ConfigurationHolder).entity) } 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() { if (::adapter.isInitialized) { ProfileManager.removeListener(adapter) GroupManager.removeListener(adapter) } 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 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) { 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) { 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) } } override suspend fun onUpdated(profileId: Long, trafficStats: TrafficStats) { val index = configurationIdList.indexOf(profileId) if (index != -1) { val holder = layoutManager.findViewByPosition(index) ?.let { configurationListView.getChildViewHolder(it) } as ConfigurationHolder? if (holder != null) { holder.entity.stats = trafficStats onMainDispatcher { holder.bind(holder.entity) } } } } 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) val subscription = proxyGroup.subscription if (subscription != null) { if (subscription.selectedGroups.isNotEmpty()) { newProfiles = newProfiles.filter { it.requireBean().group in subscription.selectedGroups } } if (subscription.selectedOwners.isNotEmpty()) { newProfiles = newProfiles.filter { it.requireBean().owner in subscription.selectedOwners } } if (subscription.selectedTags.isNotEmpty()) { newProfiles = newProfiles.filter { profile -> profile.requireBean().tags.containsAll( subscription.selectedTags ) } } } 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) fun bind(proxyEntity: ProxyEntity) { val pf = requireParentFragment() as? ConfigurationFragment ?: return entity = proxyEntity if (select) { view.setOnClickListener { (requireActivity() as ProfileSelectActivity).returnProfile(proxyEntity.id) } } else { val pa = activity as MainActivity 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 (pa.state.canStop && reloadAccess.tryLock()) { SagerNet.stopService() delay(1000L) SagerNet.startService() reloadAccess.unlock() } } else if (SagerNet.isTv) { if (SagerNet.started) { SagerNet.stopService() } else { SagerNet.startService() } } } } } profileName.text = proxyEntity.displayName() profileType.text = proxyEntity.displayType() var rx = proxyEntity.rx var tx = proxyEntity.tx val stats = proxyEntity.stats if (stats != null) { rx += stats.rxTotal tx += stats.txTotal } 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) { profileStatus.setText(R.string.unavailable) profileStatus.setOnClickListener { alert(proxyEntity.error ?: "").show() } } else { profileStatus.setOnClickListener(null) } editButton.setOnClickListener { it.context.startActivity( proxyEntity.settingIntent( it.context, proxyGroup.type == GroupType.SUBSCRIPTION ) ) } shareLayout.isGone = proxyEntity.type == ProxyEntity.TYPE_CHAIN editButton.isGone = select runOnDefaultDispatcher { val selected = (selectedItem?.id ?: DataStore.selectedProxy) == proxyEntity.id val started = selected && SagerNet.started && DataStore.currentProfile == proxyEntity.id onMainDispatcher { editButton.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) if (proxyEntity.vmessBean == null) { popup.menu.findItem(R.id.action_group_qr).subMenu.removeItem(R.id.action_v2rayn_qr) popup.menu.findItem(R.id.action_group_clipboard).subMenu.removeItem(R.id.action_v2rayn_clipboard) } 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.ptBean != null || proxyEntity.brookBean != null) { popup.menu.removeItem(R.id.action_group_configuration) } popup.setOnMenuItemClickListener(this@ConfigurationHolder) popup.show() } if (!(select || proxyEntity.type == ProxyEntity.TYPE_CHAIN)) { val validateResult = if (pf.securityAdvisory) { proxyEntity.requireBean().isInsecure() } else ResultLocal when (validateResult) { is ResultInsecure -> onMainDispatcher { shareLayout.isVisible = true shareLayer.setBackgroundColor(Color.RED) shareButton.setImageResource(R.drawable.ic_baseline_warning_24) shareButton.setColorFilter(Color.WHITE) shareLayout.setOnClickListener { MaterialAlertDialogBuilder(requireContext()).setTitle(R.string.insecure) .setMessage(resources.openRawResource(validateResult.textRes) .bufferedReader() .use { it.readText() }) .setPositiveButton(android.R.string.ok) { _, _ -> showShare(it) } .show() .apply { findViewById(android.R.id.message)?.apply { Linkify.addLinks(this, Linkify.WEB_URLS) movementMethod = LinkMovementMethod.getInstance() } } } } is ResultDeprecated -> onMainDispatcher { shareLayout.isVisible = true shareLayer.setBackgroundColor(Color.YELLOW) shareButton.setImageResource(R.drawable.ic_baseline_warning_24) shareButton.setColorFilter(Color.GRAY) shareLayout.setOnClickListener { MaterialAlertDialogBuilder(requireContext()).setTitle(R.string.deprecated) .setMessage(resources.openRawResource(validateResult.textRes) .bufferedReader() .use { it.readText() }) .setPositiveButton(android.R.string.ok) { _, _ -> showShare(it) } .show() .apply { findViewById(android.R.id.message)?.apply { Linkify.addLinks(this, Linkify.WEB_URLS) movementMethod = LinkMovementMethod.getInstance() } } } } else -> onMainDispatcher { shareLayer.setBackgroundColor(Color.TRANSPARENT) shareButton.setImageResource(R.drawable.ic_social_share) shareButton.setColorFilter(Color.GRAY) shareButton.isVisible = true shareLayout.setOnClickListener { showShare(it) } } } } } } fun showCode(link: String) { QRCodeDialog(link).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 { when (item.itemId) { R.id.action_standard_qr -> showCode(entity.toLink()!!) R.id.action_standard_clipboard -> export(entity.toLink()!!) R.id.action_universal_qr -> showCode(entity.requireBean().toUniversalLink()) R.id.action_universal_clipboard -> export( entity.requireBean().toUniversalLink() ) R.id.action_v2rayn_qr -> showCode(entity.vmessBean!!.toV2rayN()) R.id.action_v2rayn_clipboard -> export(entity.vmessBean!!.toV2rayN()) 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() } } } } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ui/DebugFragment.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 import android.os.Bundle import android.view.View import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.databinding.LayoutDebugBinding class DebugFragment : NamedFragment(R.layout.layout_debug) { override fun name() = "Debug" override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) val binding = LayoutDebugBinding.bind(view) binding.debugCrash.setOnClickListener { error("test crash") } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ui/GroupFragment.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 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.GroupManager import io.nekohasekai.sagernet.database.ProxyGroup import io.nekohasekai.sagernet.database.SagerDatabase 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.ListHolderListener import io.nekohasekai.sagernet.widget.QRCodeDialog import io.nekohasekai.sagernet.widget.UndoSnackbarManager import kotlinx.coroutines.delay 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, ListHolderListener) 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 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)) } } 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.mapNotNull { it.toLink() }.joinToString("\n") 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() val hideUngrouped = SagerDatabase.proxyDao.countByGroup(groups.find { it.ungrouped }!!.id) == 0L if (groups.size > 1 && hideUngrouped) 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) { if (groupList.size == 1 && groupList[0].ungrouped) { groupList.clear() onMainDispatcher { notifyItemRemoved(0) } } 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() 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 showCode(link: String) { QRCodeDialog(link).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() } when (item.itemId) { R.id.action_universal_qr -> { showCode(proxyGroup.toUniversalLink()) } R.id.action_universal_clipboard -> { export(proxyGroup.toUniversalLink()) } R.id.action_export_clipboard -> { runOnDefaultDispatcher { val profiles = SagerDatabase.proxyDao.getByGroup(selectedGroup.id) val links = profiles.mapNotNull { it.toLink() }.joinToString("\n") onMainDispatcher { SagerNet.trySetPrimaryClip(links) snackbar(getString(R.string.copy_toast_msg)).show() } } } R.id.action_export_file -> { startFilesForResult(exportProfiles, "profiles.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) } runOnDefaultDispatcher { val showMenu = SagerDatabase.proxyDao.countByGroup(group.id) > 0 onMainDispatcher { optionsButton.isVisible = showMenu } } 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) } 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 val progress = GroupUpdater.progress[proxyGroup.id]!! subscriptionUpdateProgress.max = progress.max subscriptionUpdateProgress.progress = progress.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 val textLayout = groupTraffic.parent as View if (subscription != null && subscription.bytesUsed > 0L) { groupTraffic.isVisible = true groupTraffic.text = if (subscription.bytesRemaining > 0L) { getString( R.string.subscription_traffic, Formatter.formatFileSize( context, subscription.bytesUsed ), Formatter.formatFileSize( context, subscription.bytesRemaining ) ) } else { getString( R.string.subscription_used, Formatter.formatFileSize( context, subscription.bytesUsed ) ) } 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 ================================================ /****************************************************************************** * * * 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 import android.content.DialogInterface import android.os.Bundle import android.os.Parcelable import android.view.Menu import android.view.MenuItem import android.view.View import androidx.annotation.LayoutRes import androidx.appcompat.app.AlertDialog import androidx.core.view.ViewCompat import androidx.preference.* import cn.hutool.core.util.NumberUtil import com.github.shadowsocks.plugin.Empty import com.github.shadowsocks.plugin.fragment.AlertDialogFragment import com.takisoft.preferencex.PreferenceFragmentCompat import com.takisoft.preferencex.SimpleMenuPreference import io.nekohasekai.sagernet.GroupType import io.nekohasekai.sagernet.Key import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.SubscriptionType import io.nekohasekai.sagernet.database.* import io.nekohasekai.sagernet.database.preference.OnPreferenceDataStoreChangeListener import io.nekohasekai.sagernet.ktx.applyDefaultValues import io.nekohasekai.sagernet.ktx.onMainDispatcher import io.nekohasekai.sagernet.ktx.runOnDefaultDispatcher import io.nekohasekai.sagernet.utils.DirectBoot import io.nekohasekai.sagernet.widget.ListListener import io.nekohasekai.sagernet.widget.UserAgentPreference import kotlinx.parcelize.Parcelize @Suppress("UNCHECKED_CAST") class GroupSettingsActivity( @LayoutRes resId: Int = R.layout.layout_config_settings, ) : ThemedActivity(resId), OnPreferenceDataStoreChangeListener { fun ProxyGroup.init() { DataStore.groupName = name ?: "" DataStore.groupType = type DataStore.groupOrder = order val subscription = subscription ?: SubscriptionBean().applyDefaultValues() DataStore.subscriptionType = subscription.type DataStore.subscriptionLink = subscription.link DataStore.subscriptionToken = subscription.token DataStore.subscriptionForceResolve = subscription.forceResolve DataStore.subscriptionDeduplication = subscription.deduplication DataStore.subscriptionForceVMessAEAD = subscription.forceVMessAEAD 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 " + System.currentTimeMillis() / 1000 type = DataStore.groupType order = DataStore.groupOrder val isSubscription = type == GroupType.SUBSCRIPTION if (isSubscription) { subscription = (subscription ?: SubscriptionBean().applyDefaultValues()).apply { type = DataStore.subscriptionType link = DataStore.subscriptionLink token = DataStore.subscriptionToken forceResolve = DataStore.subscriptionForceResolve deduplication = DataStore.subscriptionDeduplication forceVMessAEAD = DataStore.subscriptionForceVMessAEAD 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) 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 subscriptionType = findPreference(Key.SUBSCRIPTION_TYPE)!! val subscriptionLink = findPreference(Key.SUBSCRIPTION_LINK)!! val subscriptionToken = findPreference(Key.SUBSCRIPTION_TOKEN)!! val subscriptionForceVMessAEAD = findPreference(Key.SUBSCRIPTION_FORCE_VMESS_AEAD)!! val subscriptionUserAgent = findPreference(Key.SUBSCRIPTION_USER_AGENT)!! fun updateSubscriptionType(subscriptionType: Int = DataStore.subscriptionType) { val isRaw = subscriptionType == SubscriptionType.RAW val isOOCv1 = subscriptionType == SubscriptionType.OOCv1 subscriptionForceVMessAEAD.isVisible = isRaw subscriptionLink.isVisible = !isOOCv1 subscriptionToken.isVisible = isOOCv1 subscriptionUserAgent.isOOCv1 = isOOCv1 subscriptionUserAgent.notifyChanged() } updateSubscriptionType() subscriptionType.setOnPreferenceChangeListener { _, newValue -> updateSubscriptionType((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 -> NumberUtil.isInteger(newValue as String) && newValue.toInt() >= 15 } subscriptionAutoUpdate.setOnPreferenceChangeListener { _, newValue -> subscriptionAutoUpdateDelay.isEnabled = (newValue as Boolean) true } } fun PreferenceFragmentCompat.viewCreated(view: View, savedInstanceState: Bundle?) { } fun PreferenceFragmentCompat.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 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" } 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().apply { activity = this@GroupSettingsActivity }) .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 } GroupManager.updateGroup(entity.apply { serialize() }) } if (editingId == DataStore.selectedProxy && DataStore.directBootAware) DirectBoot.update() 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() { lateinit var activity: GroupSettingsActivity override fun onCreatePreferencesFix(savedInstanceState: Bundle?, rootKey: String?) { preferenceManager.preferenceDataStore = DataStore.profileCacheStore activity.apply { createPreferences(savedInstanceState, rootKey) } } 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(GroupIdArg(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 { return if (preference.text.isNullOrBlank()) { preference.context.getString(androidx.preference.R.string.not_set) } else { "\u2022".repeat(preference.text.length) } } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ui/LicenseActivity.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 import android.os.Bundle import com.mikepenz.aboutlibraries.LibsBuilder import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.widget.ListHolderListener class LicenseActivity : ThemedActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.layout_license) setSupportActionBar(findViewById(R.id.toolbar)) supportActionBar?.apply { setTitle(R.string.oss_licenses) setDisplayHomeAsUpEnabled(true) setHomeAsUpIndicator(R.drawable.ic_navigation_close) } ListHolderListener.setup(this) val libs = LibsBuilder().withExcludedLibraries( // Can't parse ${project.artifactId} in pom.xml "cn_hutool__hutool_core", "cn_hutool__hutool_json", "cn_hutool__hutool_crypto", "cn_hutool__hutool_cache" ).withAboutIconShown(false).withFields(R.string::class.java.fields).supportFragment() supportFragmentManager.beginTransaction().replace(R.id.fragment_holder, libs) .commitAllowingStateLoss() } override fun onSupportNavigateUp(): Boolean { finish() return true } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ui/LogcatFragment.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 import android.annotation.SuppressLint import android.content.Intent import android.graphics.Typeface import android.os.Bundle import android.view.* import androidx.appcompat.widget.Toolbar import androidx.core.content.FileProvider import androidx.core.graphics.TypefaceCompat import androidx.drawerlayout.widget.DrawerLayout import cn.hutool.core.util.RuntimeUtil import com.termux.terminal.TerminalColors import com.termux.terminal.TerminalSession import com.termux.terminal.TerminalSessionClient import com.termux.view.TerminalViewClient import io.nekohasekai.sagernet.BuildConfig import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.SagerNet import io.nekohasekai.sagernet.databinding.LayoutLogcatBinding import io.nekohasekai.sagernet.ktx.* import io.nekohasekai.sagernet.utils.CrashHandler import java.io.File import java.io.FileOutputStream import java.io.IOException import java.util.* import kotlin.math.max import kotlin.math.min class LogcatFragment : ToolbarFragment(R.layout.layout_logcat), TerminalSessionClient, TerminalViewClient, Toolbar.OnMenuItemClickListener { lateinit var binding: LayoutLogcatBinding var fontSize = dp2px(8) @SuppressLint("RestrictedApi") 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) val terminalView = binding.terminalView // Make it divisible by 2 since that is the minimal adjustment step: if (fontSize % 2 == 1) fontSize-- terminalView.setTerminalViewClient(this) terminalView.setTextSize(fontSize) terminalView.setTypeface( TypefaceCompat.createFromResourcesFontFile( view.context, resources, R.font.jetbrains_mono, "res/font/jetbrains_mono.ttf", Typeface.MONOSPACE.style, ) ) reloadSession() registerForContextMenu(terminalView) } override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { binding.terminalView.showContextMenu() } fun reloadSession() { val terminalView = binding.terminalView terminalView.mTermSession?.also { it.finishIfRunning() } val session = TerminalSession( "/system/bin/logcat", app.cacheDir.absolutePath, arrayOf( "-C", "-v", "tag,color", "AndroidRuntime:D", "ProxyInstance:D", "GuardedProcessPool:D", "VpnService:D", "libcore:D", "xray-core:D", "libsslocal:D", "libss-local:D", "libtrojan:D", "libtrojan:D", "libnaive:D", "libbrook:D", "libhysteria:D", "libpingtunnel:D", "librelaybaton:D", "libwg:D", "*:S" ), arrayOf(), 3000, this ) terminalView.attachSession(session) terminalView.updateSize() } override fun onMenuItemClick(item: MenuItem): Boolean { when (item.itemId) { R.id.action_clear_logcat -> { runOnDefaultDispatcher { try { RuntimeUtil.exec("/system/bin/logcat", "-c").waitFor() } catch (e: Exception) { onMainDispatcher { snackbar(e.readableMessage).show() } return@runOnDefaultDispatcher } onMainDispatcher { reloadSession() } } } R.id.action_send_logcat -> { val context = requireContext() runOnDefaultDispatcher { val logFile = File.createTempFile("AnXray ", ".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 ) ) } catch (e: IOException) { Logs.w(e) logFile.appendText("Export logcat error: " + CrashHandler.formatThrowable(e)) } 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 + ".log", logFile ) ), context.getString(R.string.abc_shareactionprovider_share_with) ) ) } } } return true } override fun onDestroy() { super.onDestroy() if (::binding.isInitialized) { binding.terminalView.mTermSession?.finishIfRunning() } } override fun onTextChanged(changedSession: TerminalSession?) { } override fun onTitleChanged(changedSession: TerminalSession?) { } override fun onSessionFinished(finishedSession: TerminalSession?) { } override fun onCopyTextToClipboard(session: TerminalSession?, text: String?) { if (text.isNullOrBlank()) return SagerNet.trySetPrimaryClip(text) snackbar(R.string.copy_success).show() } override fun onPasteTextFromClipboard(session: TerminalSession) { } override fun onBell(session: TerminalSession?) { } override fun onColorsChanged(session: TerminalSession?) { } override fun onTerminalCursorStateChange(state: Boolean) { } override fun getTerminalCursorStyle(): Int { return 0 } override fun onScale(scale: Float): Float { if (scale < 0.9f || scale > 1.1f) { val increase = scale > 1f changeFontSize(increase) return 1.0f } return scale } companion object { private val MIN_FONTSIZE = dp2px(4) private val MAX_FONTSIZE = dp2px(12) } private fun changeFontSize(increase: Boolean) { val terminalView = binding.terminalView fontSize += if (increase) 1 else -1 fontSize = max(MIN_FONTSIZE, min(fontSize, MAX_FONTSIZE)) terminalView.setTextSize(fontSize) } override fun onSingleTapUp(e: MotionEvent?) { } override fun shouldBackButtonBeMappedToEscape(): Boolean { return false } override fun shouldEnforceCharBasedInput(): Boolean { return false } override fun shouldUseCtrlSpaceWorkaround(): Boolean { return false } override fun isTerminalViewSelected(): Boolean { return true } override fun copyModeChanged(copyMode: Boolean) { val activity = requireActivity() as MainActivity // Disable drawer while copying. activity.binding.drawerLayout.setDrawerLockMode(if (copyMode) DrawerLayout.LOCK_MODE_LOCKED_CLOSED else DrawerLayout.LOCK_MODE_UNLOCKED) } override fun onKeyDown(keyCode: Int, e: KeyEvent?, session: TerminalSession?): Boolean { return false } override fun onKeyUp(keyCode: Int, e: KeyEvent?): Boolean { return false } override fun onLongPress(event: MotionEvent?): Boolean { return false } override fun readControlKey(): Boolean { return false } override fun readAltKey(): Boolean { return false } override fun readShiftKey(): Boolean { return false } override fun readFnKey(): Boolean { return false } override fun onCodePoint( codePoint: Int, ctrlDown: Boolean, session: TerminalSession? ): Boolean { return false } override fun disableInput(): Boolean { return true } override fun onEmulatorSet() { val props = Properties() props.load(requireContext().assets.open("terminal.properties")) TerminalColors.COLOR_SCHEME.updateWith(props) val emulator = binding.terminalView.mTermSession.emulator emulator.mColors.reset() } override fun onScroll(offset: Int) { val activity = requireActivity() as MainActivity val topRow = binding.terminalView.topRow if (offset < 0) { activity.binding.stats.apply { if (isShown) performHide() } } val screen = binding.terminalView.mEmulator.screen if (topRow == 0 && screen.activeTranscriptRows > 0) activity.binding.fab.apply { if (isShown) hide() } else activity.binding.fab.apply { if (!isShown) show() } } override fun logError(tag: String?, message: String?) { } override fun logWarn(tag: String?, message: String?) { } override fun logInfo(tag: String?, message: String?) { } override fun logDebug(tag: String?, message: String?) { } override fun logVerbose(tag: String?, message: String?) { } override fun logStackTraceWithMessage(tag: String?, message: String?, e: Exception?) { } override fun logStackTrace(tag: String?, e: Exception?) { } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ui/MainActivity.kt ================================================ /****************************************************************************** * * * Copyright (C) 2021 by nekohasekai * * Copyright (C) 2021 by Max Lv * * Copyright (C) 2021 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.ui import android.content.Intent import android.net.Uri import android.os.Bundle import android.os.RemoteException import android.provider.Settings import android.view.KeyEvent import android.view.MenuItem import android.widget.Toast import androidx.annotation.IdRes import androidx.core.view.ViewCompat import androidx.preference.PreferenceDataStore import cn.hutool.core.codec.Base64Decoder import cn.hutool.core.util.ZipUtil 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.* import io.nekohasekai.sagernet.aidl.AppStats import io.nekohasekai.sagernet.aidl.ISagerNetService import io.nekohasekai.sagernet.aidl.TrafficStats import io.nekohasekai.sagernet.bg.BaseService import io.nekohasekai.sagernet.bg.SagerConnection import io.nekohasekai.sagernet.database.* 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.* import io.nekohasekai.sagernet.plugin.PluginManager import io.nekohasekai.sagernet.widget.ListHolderListener import com.github.shadowsocks.plugin.PluginManager as ShadowsocksPluginPluginManager 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, R.style.Theme_SagerNet_LightBlack ) ) { 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) } binding.fab.setOnClickListener { if (state.canStop) SagerNet.stopService() else connect.launch( null ) } binding.stats.setOnClickListener { if (state == BaseService.State.Connected) binding.stats.testConnection() } setContentView(binding.root) ViewCompat.setOnApplyWindowInsetsListener(binding.coordinator, ListHolderListener) changeState(BaseService.State.Idle) connection.connect(this, this) DataStore.configurationStore.registerChangeListener(this) GroupManager.userInterface = GroupInterfaceAdapter(this) if (intent?.action == Intent.ACTION_VIEW) { onNewIntent(intent) } } 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 (state != BaseService.State.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") val type = uri.getQueryParameter("type") when (type?.lowercase()) { "sip008" -> { subscription.type = SubscriptionType.SIP008 } } } else { val data = uri.encodedQuery.takeIf { !it.isNullOrBlank() } ?: return try { group = KryoConverters.deserialize( ProxyGroup().apply { export = true }, ZipUtil.unZlib(Base64Decoder.decode(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 pluginId = if (pluginName.startsWith("shadowsocks-")) pluginName.substringAfter("shadowsocks-") else pluginName val pluginEntity = PluginEntry.find(pluginName) if (pluginEntity == null) { snackbar(getString(R.string.plugin_unknown, pluginName)).show() return } val existsButOnShitSystem = if (pluginName == pluginId) { PluginManager.fetchPlugins().map { it.id }.contains(pluginName) } else { ShadowsocksPluginPluginManager.fetchPlugins(true).map { it.id }.contains(pluginId) } if (existsButOnShitSystem) { MaterialAlertDialogBuilder(this).setTitle(R.string.missing_plugin).setMessage( getString( R.string.plugin_exists_but_on_shit_system, profileName, getString(pluginEntity.nameId) ) ).setPositiveButton(R.string.action_learn_more) { _, _ -> launchCustomTab("https://sagernet.org/plugin/") }.show() return } MaterialAlertDialogBuilder(this).setTitle(R.string.missing_plugin) .setMessage( getString( R.string.profile_requiring_plugin, profileName, getString(pluginEntity.nameId) ) ) .setPositiveButton(R.string.action_download) { _, _ -> showDownloadDialog(pluginEntity) } .setNeutralButton(android.R.string.cancel, null) .setNeutralButton(R.string.action_learn_more) { _, _ -> launchCustomTab("https://sagernet.org/plugin/") } .show() } private fun showDownloadDialog(pluginEntry: PluginEntry) { var index = 0 var playIndex = -1 var fdroidIndex = -1 var downloadIndex = -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)) 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 } fun displayFragment(fragment: ToolbarFragment) { 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()) connection.bandwidthTimeout = connection.bandwidthTimeout } R.id.nav_group -> displayFragment(GroupFragment()) R.id.nav_route -> displayFragment(RouteFragment()) R.id.nav_settings -> displayFragment(SettingsFragment()) R.id.nav_traffic -> { displayFragment(TrafficFragment()) connection.trafficTimeout = connection.trafficTimeout } R.id.nav_tools -> displayFragment(ToolsFragment()) R.id.nav_logcat -> displayFragment(LogcatFragment()) R.id.nav_faq -> { launchCustomTab("https://anxray.org/") return false } R.id.nav_about -> displayFragment(AboutFragment()) else -> return false } navigation.menu.findItem(id).isChecked = true return true } fun ruleCreated() { navigation.menu.findItem(R.id.nav_route).isChecked = true supportFragmentManager.beginTransaction() .replace(R.id.fragment_holder, RouteFragment()) .commitAllowingStateLoss() if (SagerNet.started) { snackbar(getString(R.string.restart)).setAction(R.string.apply) { SagerNet.reloadService() }.show() } } var state = BaseService.State.Idle var doStop = false private fun changeState( state: BaseService.State, msg: String? = null, animate: Boolean = false, ) { val started = state == BaseService.State.Connected if (!started) { statsUpdated(emptyList()) } binding.fab.changeState(state, this.state, animate) binding.stats.changeState(state) if (msg != null) snackbar(getString(R.string.vpn_error, msg)).show() this.state = state when (state) { BaseService.State.Connected, BaseService.State.Stopped -> { statsUpdated(emptyList()) } } } override fun snackbarInternal(text: CharSequence): Snackbar { return Snackbar.make(binding.coordinator, text, Snackbar.LENGTH_LONG).apply { if (binding.fab.isShown) { anchorView = binding.fab } } } override fun stateChanged(state: BaseService.State, profileName: String?, msg: String?) { changeState(state, msg, true) } override fun statsUpdated(stats: List) { (supportFragmentManager.findFragmentById(R.id.fragment_holder) as? TrafficFragment)?.emitStats( stats ) } override fun routeAlert(type: Int, routeName: String) { when (type) { 0 -> { // need vpn Toast.makeText( this, getString(R.string.route_need_vpn, routeName), Toast.LENGTH_SHORT ).show() } 1 -> { // need fds MaterialAlertDialogBuilder(this).setTitle(R.string.foreground_detector) .setMessage(getString(R.string.route_need_fds, routeName)) .setPositiveButton(R.string.enable) { _, _ -> startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)) } .setNegativeButton(android.R.string.cancel, null) .show() } } } val connection = SagerConnection(true) override fun onServiceConnected(service: ISagerNetService) = changeState( try { BaseService.State.values()[service.state].also { SagerNet.started = it.canStop } } 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() } override fun trafficUpdated(profileId: Long, stats: TrafficStats, isCurrent: Boolean) { if (profileId == 0L) return if (isCurrent) binding.stats.updateTraffic( stats.txRateProxy, stats.rxRateProxy ) runOnDefaultDispatcher { ProfileManager.postTrafficUpdated(profileId, stats) } } override fun profilePersisted(profileId: Long) { runOnDefaultDispatcher { ProfileManager.postUpdate(profileId) } } override fun observatoryResultsUpdated(groupId: Long) { runOnDefaultDispatcher { GroupManager.postReload(groupId) } } override fun onPreferenceDataStoreChanged(store: PreferenceDataStore, key: String) { when (key) { Key.SERVICE_MODE -> onBinderDied() Key.PROXY_APPS, Key.BYPASS_MODE, Key.INDIVIDUAL -> { if (state.canStop) { snackbar(getString(R.string.restart)).setAction(R.string.apply) { SagerNet.reloadService() }.show() } } } } override fun onStart() { super.onStart() connection.bandwidthTimeout = 1000 } override fun onStop() { connection.bandwidthTimeout = 0 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 ================================================ /****************************************************************************** * 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 import androidx.fragment.app.Fragment abstract class NamedFragment : Fragment { constructor() : super() constructor(contentLayoutId: Int) : super(contentLayoutId) abstract fun name(): String } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ui/ProfileSelectActivity.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 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) { 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)) .commitAllowingStateLoss() } fun returnProfile(profileId: Long) { setResult(RESULT_OK, Intent().apply { putExtra(EXTRA_PROFILE_ID, profileId) }) finish() } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ui/RouteFragment.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 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 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.ListHolderListener 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, ListHolderListener) 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 -> { runOnDefaultDispatcher { SagerDatabase.rulesDao.deleteAll() DataStore.rulesFirstCreate = false ruleAdapter.reload() } } 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://www.v2fly.org/config/routing.html#ruleobject") } } } 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 ================================================ /****************************************************************************** * * * 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 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 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.SwitchPreference import com.github.shadowsocks.plugin.Empty import com.github.shadowsocks.plugin.fragment.AlertDialogFragment import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.takisoft.preferencex.PreferenceFragmentCompat import io.nekohasekai.sagernet.AppStatus import io.nekohasekai.sagernet.Key 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.database.preference.OnPreferenceDataStoreChangeListener import io.nekohasekai.sagernet.ktx.app import io.nekohasekai.sagernet.ktx.onMainDispatcher import io.nekohasekai.sagernet.ktx.runOnDefaultDispatcher import io.nekohasekai.sagernet.utils.DirectBoot 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 @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 = listOf(packageName) name = app.getString(R.string.route_for, PackageCache.loadLabel(packageName)) } }.init() } fun RuleEntity.init() { DataStore.routeName = name DataStore.routeDomain = domains DataStore.routeIP = ip DataStore.routePort = port DataStore.routeSourcePort = sourcePort DataStore.routeNetwork = network DataStore.routeSource = source DataStore.routeProtocol = protocol DataStore.routeAttrs = attrs DataStore.routeOutboundRule = outbound DataStore.routeOutbound = when (outbound) { 0L -> 0 -1L -> 1 -2L -> 2 else -> 3 } DataStore.routeReverse = reverse DataStore.routeRedirect = redirect DataStore.routePackages = packages.joinToString("\n") DataStore.routeForegroundStatus = "" for (status in appStatus) when (status) { AppStatus.FOREGROUND, AppStatus.BACKGROUND -> { DataStore.routeForegroundStatus = status } } } fun RuleEntity.serialize() { name = DataStore.routeName domains = DataStore.routeDomain ip = DataStore.routeIP port = DataStore.routePort sourcePort = DataStore.routeSourcePort network = DataStore.routeNetwork source = DataStore.routeSource protocol = DataStore.routeProtocol attrs = DataStore.routeAttrs outbound = when (DataStore.routeOutbound) { 0 -> 0L 1 -> -1L 2 -> -2L else -> DataStore.routeOutboundRule } reverse = DataStore.routeReverse redirect = DataStore.routeRedirect packages = DataStore.routePackages.split("\n").filter { it.isNotBlank() } val routeForegroundStatus = DataStore.routeForegroundStatus appStatus = if (routeForegroundStatus.isNotBlank()) { listOf(routeForegroundStatus) } else { emptyList() } if (DataStore.editingId == 0L) { enabled = true } } fun needSave(): Boolean { if (!DataStore.dirty) return false if (DataStore.routePackages.isBlank() && DataStore.routeForegroundStatus.isBlank() && DataStore.routeDomain.isBlank() && DataStore.routeIP.isBlank() && DataStore.routePort.isBlank() && DataStore.routeSourcePort.isBlank() && DataStore.routeNetwork.isBlank() && DataStore.routeSource.isBlank() && DataStore.routeProtocol.isBlank() && DataStore.routeAttrs.isBlank() && !(DataStore.routeReverse && DataStore.routeRedirect.isNotBlank())) { return false } return true } fun PreferenceFragmentCompat.createPreferences( savedInstanceState: Bundle?, rootKey: String?, ) { addPreferencesFromResource(R.xml.route_preferences) } 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 reverse: SwitchPreference lateinit var redirect: EditTextPreference lateinit var apps: AppListPreference fun PreferenceFragmentCompat.viewCreated(view: View, savedInstanceState: Bundle?) { outbound = findPreference(Key.ROUTE_OUTBOUND)!! reverse = findPreference(Key.ROUTE_REVERSE)!! redirect = findPreference(Key.ROUTE_REDIRECT)!! apps = findPreference(Key.ROUTE_PACKAGES)!! fun updateReverse(enabled: Boolean = outbound.value == "3") { reverse.isVisible = enabled redirect.isVisible = enabled redirect.isEnabled = reverse.isChecked } updateReverse() reverse.setOnPreferenceChangeListener { _, newValue -> redirect.isEnabled = newValue as Boolean true } outbound.setOnPreferenceChangeListener { _, newValue -> if (newValue.toString() == "3") { updateReverse(true) selectProfileForAdd.launch( Intent( this@RouteSettingsActivity, ProfileSelectActivity::class.java ) ) false } else { updateReverse(false) true } } apps.setOnPreferenceClickListener { selectAppList.launch( Intent( this@RouteSettingsActivity, AppListActivity::class.java ) ) true } } fun PreferenceFragmentCompat.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().apply { activity = this@RouteSettingsActivity }) .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() }) } if (editingId == DataStore.selectedProxy && DataStore.directBootAware) DirectBoot.update() 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() { lateinit var activity: RouteSettingsActivity override fun onCreatePreferencesFix(savedInstanceState: Bundle?, rootKey: String?) { preferenceManager.preferenceDataStore = DataStore.profileCacheStore activity.apply { createPreferences(savedInstanceState, rootKey) } } 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 { return if (preference.text.isNullOrBlank()) { preference.context.getString(androidx.preference.R.string.not_set) } else { "\u2022".repeat(preference.text.length) } } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ui/ScannerActivity.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 import android.annotation.SuppressLint import android.content.Intent import android.content.pm.ActivityInfo import android.content.pm.ShortcutManager import android.graphics.ImageDecoder import android.net.Uri import android.os.Build import android.os.Bundle import android.provider.MediaStore import android.view.KeyEvent 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.view.isGone import com.google.android.material.snackbar.Snackbar import com.google.zxing.BinaryBitmap import com.google.zxing.DecodeHintType import com.google.zxing.NotFoundException import com.google.zxing.RGBLuminanceSource import com.google.zxing.common.GlobalHistogramBinarizer import com.google.zxing.qrcode.QRCodeReader import com.journeyapps.barcodescanner.BarcodeCallback import com.journeyapps.barcodescanner.BarcodeResult import com.journeyapps.barcodescanner.CaptureManager import com.journeyapps.barcodescanner.MixedDecoder 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.ktx.* import io.nekohasekai.sagernet.widget.ListHolderListener class ScannerActivity : ThemedActivity(), BarcodeCallback { lateinit var capture: CaptureManager lateinit var binding: LayoutScannerBinding @SuppressLint("SourceLockedOrientationActivity") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) if (Build.VERSION.SDK_INT != Build.VERSION_CODES.O) { requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT } if (Build.VERSION.SDK_INT >= 25) getSystemService()!!.reportShortcutUsed("scan") binding = LayoutScannerBinding.inflate(layoutInflater) setContentView(binding.root) ListHolderListener.setup(this) setSupportActionBar(findViewById(R.id.toolbar)) supportActionBar?.apply { setDisplayHomeAsUpEnabled(true) setHomeAsUpIndicator(R.drawable.ic_navigation_close) } binding.barcodeScanner.statusView.isGone = true binding.barcodeScanner.viewFinder.isGone = true binding.barcodeScanner.barcodeView.setDecoderFactory { MixedDecoder(QRCodeReader()) } capture = CaptureManager(this, binding.barcodeScanner) binding.barcodeScanner.decodeSingle(this) } override fun snackbarInternal(text: CharSequence): Snackbar { return Snackbar.make(binding.barcodeScanner, text, Snackbar.LENGTH_LONG) } 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 intArray = IntArray(bitmap.width * bitmap.height) bitmap.getPixels(intArray, 0, bitmap.width, 0, 0, bitmap.width, bitmap.height) val source = RGBLuminanceSource(bitmap.width, bitmap.height, intArray) val qrReader = QRCodeReader() try { val result = try { qrReader.decode( BinaryBitmap(GlobalHistogramBinarizer(source)), mapOf(DecodeHintType.TRY_HARDER to true) ) } catch (e: NotFoundException) { qrReader.decode( BinaryBitmap(GlobalHistogramBinarizer(source.invert())), mapOf(DecodeHintType.TRY_HARDER to true) ) } val results = parseProxies(result.text ?: "") if (results.isNotEmpty()) { onMainDispatcher { finish() } val currentGroupId = DataStore.selectedGroupForImport() if (DataStore.selectedGroup != currentGroupId) { DataStore.selectedGroup = currentGroupId } for (profile in results) { ProfileManager.createProfile(currentGroupId, profile) } } else { 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 = Uri.parse(e.link) }) finish() } catch (e: Throwable) { Logs.w(e) onMainDispatcher { Toast.makeText(app, R.string.action_import_err, Toast.LENGTH_SHORT) .show() } } } } 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) } } /** * See also: https://stackoverflow.com/a/31350642/2245107 */ override fun shouldUpRecreateTask(targetIntent: Intent?) = super.shouldUpRecreateTask(targetIntent) || isTaskRoot override fun onResume() { super.onResume() capture.onResume() } override fun onPause() { super.onPause() capture.onPause() } override fun onDestroy() { super.onDestroy() capture.onDestroy() } override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) capture.onSaveInstanceState(outState) } override fun onRequestPermissionsResult( requestCode: Int, permissions: Array, grantResults: IntArray, ) { super.onRequestPermissionsResult(requestCode, permissions, grantResults) capture.onRequestPermissionsResult(requestCode, permissions, grantResults) } override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { return binding.barcodeScanner.onKeyDown(keyCode, event) || super.onKeyDown(keyCode, event) } override fun barcodeResult(result: BarcodeResult) { finish() val text = result.result.text runOnDefaultDispatcher { try { val results = parseProxies(text) if (results.isNotEmpty()) { val currentGroupId = DataStore.selectedGroupForImport() if (DataStore.selectedGroup != currentGroupId) { DataStore.selectedGroup = currentGroupId } for (profile in results) { ProfileManager.createProfile(currentGroupId, profile) } } else { 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 = Uri.parse(e.link) }) finish() } catch (e: Throwable) { Logs.w(e) onMainDispatcher { Toast.makeText(app, R.string.action_import_err, Toast.LENGTH_SHORT).show() } } } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ui/SettingsFragment.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 import android.os.Bundle import android.view.View import androidx.core.view.ViewCompat import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.widget.ListHolderListener class SettingsFragment : ToolbarFragment(R.layout.layout_config_settings) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) ViewCompat.setOnApplyWindowInsetsListener(view, ListHolderListener) toolbar.setTitle(R.string.settings) parentFragmentManager.beginTransaction() .replace(R.id.settings, SettingsPreferenceFragment()) .commitAllowingStateLoss() } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ui/SettingsPreferenceFragment.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 import android.content.Intent import android.os.Build import android.os.Bundle import android.view.View import androidx.core.app.ActivityCompat import androidx.preference.EditTextPreference import androidx.preference.Preference import androidx.preference.SwitchPreference import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.takisoft.preferencex.PreferenceFragmentCompat import com.takisoft.preferencex.SimpleMenuPreference import io.nekohasekai.sagernet.Key import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.SagerNet import io.nekohasekai.sagernet.TunImplementation 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 io.nekohasekai.sagernet.widget.ColorPickerPreference import java.io.File class SettingsPreferenceFragment : PreferenceFragmentCompat() { private lateinit var isProxyApps: SwitchPreference override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) listView.layoutManager = FixedLinearLayoutManager(listView) } val reloadListener = Preference.OnPreferenceChangeListener { _, _ -> needReload() true } override fun onCreatePreferencesFix(savedInstanceState: Bundle?, rootKey: String?) { preferenceManager.preferenceDataStore = DataStore.configurationStore DataStore.initGlobal() addPreferencesFromResource(R.xml.global_preferences) val appTheme = findPreference(Key.APP_THEME)!! if (!isExpert) { appTheme.remove() } else { appTheme.setOnPreferenceChangeListener { _, newTheme -> if (SagerNet.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 portSocks5 = findPreference(Key.SOCKS_PORT)!! val speedInterval = findPreference(Key.SPEED_INTERVAL)!! val serviceMode = findPreference(Key.SERVICE_MODE)!! val allowAccess = findPreference(Key.ALLOW_ACCESS)!! val requireHttp = findPreference(Key.REQUIRE_HTTP)!! val appendHttpProxy = findPreference(Key.APPEND_HTTP_PROXY)!! val portHttp = findPreference(Key.HTTP_PORT)!! when { Build.VERSION.SDK_INT < Build.VERSION_CODES.N -> { requireHttp.remove() appendHttpProxy.remove() portHttp.setIcon(R.drawable.ic_baseline_http_24) portHttp.onPreferenceChangeListener = reloadListener } Build.VERSION.SDK_INT < Build.VERSION_CODES.Q -> { portHttp.isEnabled = requireHttp.isChecked appendHttpProxy.remove() requireHttp.setOnPreferenceChangeListener { _, newValue -> portHttp.isEnabled = newValue as Boolean needReload() true } } else -> { portHttp.isEnabled = requireHttp.isChecked appendHttpProxy.isEnabled = requireHttp.isChecked requireHttp.setOnPreferenceChangeListener { _, newValue -> portHttp.isEnabled = newValue as Boolean appendHttpProxy.isEnabled = newValue as Boolean needReload() true } } } val portLocalDns = findPreference(Key.LOCAL_DNS_PORT)!! val showStopButton = findPreference(Key.SHOW_STOP_BUTTON)!! if (Build.VERSION.SDK_INT < 24) { showStopButton.remove() } val showDirectSpeed = findPreference(Key.SHOW_DIRECT_SPEED)!! val ipv6Mode = findPreference(Key.IPV6_MODE)!! val domainStrategy = findPreference(Key.DOMAIN_STRATEGY)!! val trafficSniffing = findPreference(Key.TRAFFIC_SNIFFING)!! val enableMux = findPreference(Key.ENABLE_MUX)!! val enableMuxForAll = findPreference(Key.ENABLE_MUX_FOR_ALL)!! val muxConcurrency = findPreference(Key.MUX_CONCURRENCY)!! val tcpKeepAliveInterval = findPreference(Key.TCP_KEEP_ALIVE_INTERVAL)!! val bypassLan = findPreference(Key.BYPASS_LAN)!! val bypassLanInCoreOnly = findPreference(Key.BYPASS_LAN_IN_CORE_ONLY)!! bypassLanInCoreOnly.isEnabled = bypassLan.isChecked bypassLan.setOnPreferenceChangeListener { _, newValue -> bypassLanInCoreOnly.isEnabled = newValue as Boolean needReload() true } 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 requireTransproxy = findPreference(Key.REQUIRE_TRANSPROXY)!! val transproxyPort = findPreference(Key.TRANSPROXY_PORT)!! val transproxyMode = findPreference(Key.TRANSPROXY_MODE)!! val enableLog = findPreference(Key.ENABLE_LOG)!! transproxyPort.isEnabled = requireTransproxy.isChecked transproxyMode.isEnabled = requireTransproxy.isChecked requireTransproxy.setOnPreferenceChangeListener { _, newValue -> transproxyPort.isEnabled = newValue as Boolean transproxyMode.isEnabled = newValue needReload() true } val providerTrojan = findPreference(Key.PROVIDER_TROJAN)!! val providerShadowsocksAEAD = findPreference(Key.PROVIDER_SS_AEAD)!! val providerShadowsocksStream = findPreference(Key.PROVIDER_SS_STREAM)!! if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { providerShadowsocksAEAD.setEntries(R.array.ss_aead_provider_api21) providerShadowsocksAEAD.setEntryValues(R.array.ss_aead_provider_api21_values) providerShadowsocksStream.setEntries(R.array.ss_stream_provider_api21) providerShadowsocksStream.setEntryValues(R.array.ss_stream_provider_api21_values) } if (!isExpert) { providerTrojan.setEntries(R.array.trojan_provider) providerTrojan.setEntryValues(R.array.trojan_provider_value) } val dnsHosts = findPreference(Key.DNS_HOSTS)!! portLocalDns.setOnBindEditTextListener(EditTextPreferenceModifiers.Port) muxConcurrency.setOnBindEditTextListener(EditTextPreferenceModifiers.Port) portSocks5.setOnBindEditTextListener(EditTextPreferenceModifiers.Port) portHttp.setOnBindEditTextListener(EditTextPreferenceModifiers.Port) dnsHosts.setOnBindEditTextListener(EditTextPreferenceModifiers.Hosts) 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 utlsFingerprint = findPreference(Key.UTLS_FINGERPRINT)!! val appTrafficStatistics = findPreference(Key.APP_TRAFFIC_STATISTICS)!! val profileTrafficStatistics = findPreference(Key.PROFILE_TRAFFIC_STATISTICS)!! speedInterval.isEnabled = profileTrafficStatistics.isChecked profileTrafficStatistics.setOnPreferenceChangeListener { _, newValue -> speedInterval.isEnabled = newValue as Boolean true } serviceMode.setOnPreferenceChangeListener { _, _ -> if (SagerNet.started) SagerNet.stopService() true } val tunImplementation = findPreference(Key.TUN_IMPLEMENTATION)!! val destinationOverride = findPreference(Key.DESTINATION_OVERRIDE)!! val resolveDestination = findPreference(Key.RESOLVE_DESTINATION)!! val enablePcap = findPreference(Key.ENABLE_PCAP)!! speedInterval.onPreferenceChangeListener = reloadListener portSocks5.onPreferenceChangeListener = reloadListener portHttp.onPreferenceChangeListener = reloadListener appendHttpProxy.onPreferenceChangeListener = reloadListener showStopButton.onPreferenceChangeListener = reloadListener showDirectSpeed.onPreferenceChangeListener = reloadListener domainStrategy.onPreferenceChangeListener = reloadListener trafficSniffing.onPreferenceChangeListener = reloadListener enableMux.onPreferenceChangeListener = reloadListener enableMuxForAll.onPreferenceChangeListener = reloadListener muxConcurrency.onPreferenceChangeListener = reloadListener tcpKeepAliveInterval.onPreferenceChangeListener = reloadListener bypassLanInCoreOnly.onPreferenceChangeListener = reloadListener remoteDns.onPreferenceChangeListener = reloadListener directDns.onPreferenceChangeListener = reloadListener enableDnsRouting.onPreferenceChangeListener = reloadListener enableFakeDns.onPreferenceChangeListener = reloadListener dnsHosts.onPreferenceChangeListener = reloadListener portLocalDns.onPreferenceChangeListener = reloadListener ipv6Mode.onPreferenceChangeListener = reloadListener allowAccess.onPreferenceChangeListener = reloadListener transproxyPort.onPreferenceChangeListener = reloadListener transproxyMode.onPreferenceChangeListener = reloadListener enableLog.onPreferenceChangeListener = reloadListener providerTrojan.onPreferenceChangeListener = reloadListener providerShadowsocksAEAD.onPreferenceChangeListener = reloadListener providerShadowsocksStream.onPreferenceChangeListener = reloadListener utlsFingerprint.onPreferenceChangeListener = reloadListener appTrafficStatistics.onPreferenceChangeListener = reloadListener tunImplementation.onPreferenceChangeListener = reloadListener destinationOverride.onPreferenceChangeListener = reloadListener resolveDestination.onPreferenceChangeListener = reloadListener enablePcap.setOnPreferenceChangeListener { _, newValue -> if (newValue as Boolean) { val path = File(app.externalAssets, "pcap").absolutePath MaterialAlertDialogBuilder(requireContext()).apply { setTitle(R.string.pcap) setMessage(resources.getString(R.string.pcap_notice, path)) setPositiveButton(android.R.string.ok) { _, _ -> needReload() } setNegativeButton(android.R.string.copy) { _, _ -> SagerNet.trySetPrimaryClip(path) snackbar(R.string.copy_success).show() } }.show() if (tunImplementation.value != "${TunImplementation.GVISOR}") { tunImplementation.value = "${TunImplementation.GVISOR}" } } else needReload() true } } override fun onResume() { super.onResume() if (::isProxyApps.isInitialized) { isProxyApps.isChecked = DataStore.proxyApps } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ui/StatsFragment.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 import android.os.Bundle import android.text.format.Formatter import android.view.View import android.view.ViewGroup import androidx.appcompat.widget.PopupMenu import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.recyclerview.widget.RecyclerView import io.nekohasekai.sagernet.Key import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.SagerNet import io.nekohasekai.sagernet.aidl.AppStats import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.database.SagerDatabase import io.nekohasekai.sagernet.databinding.LayoutTrafficItemBinding import io.nekohasekai.sagernet.databinding.LayoutTrafficListBinding import io.nekohasekai.sagernet.ktx.* import io.nekohasekai.sagernet.utils.PackageCache class StatsFragment : Fragment(R.layout.layout_traffic_list) { lateinit var binding: LayoutTrafficListBinding lateinit var adapter: ActiveAdapter override fun onViewCreated(view: View, savedInstanceState: Bundle?) { binding = LayoutTrafficListBinding.bind(view) adapter = ActiveAdapter() binding.trafficList.layoutManager = FixedLinearLayoutManager(binding.trafficList) binding.trafficList.adapter = adapter (parentFragment as TrafficFragment).listeners.add(::emitStats) runOnDefaultDispatcher { emitStats(emptyList()) } } fun emitStats(statsList: List) { var data = statsList.associate { it.packageName to it.copy() }.toMutableMap() for (stats in SagerDatabase.statsDao.all()) { if (data.containsKey(stats.packageName)) { data[stats.packageName]!! += stats } else { data[stats.packageName] = stats.toStats() } } for (stats in data.values) { stats.tcpConnectionsTotal += stats.tcpConnections stats.udpConnectionsTotal += stats.udpConnections stats.uplinkTotal += stats.uplink stats.downlinkTotal += stats.downlink } if (data.isEmpty()) { runOnMainDispatcher { binding.holder.isVisible = true binding.trafficList.isVisible = false if (!SagerNet.started || DataStore.serviceMode != Key.MODE_VPN) { binding.holder.text = getString(R.string.traffic_holder) } else if ((activity as MainActivity).connection.service?.trafficStatsEnabled != true) { binding.holder.text = getString(R.string.app_statistics_disabled) } else { binding.holder.text = getString(R.string.no_statistics) } } binding.trafficList.post { adapter.data = emptyList() adapter.notifyDataSetChanged() } } else { runOnMainDispatcher { binding.holder.isVisible = false binding.trafficList.isVisible = true } data = data.toSortedMap { ka, kb -> val a = data[ka]!! val b = data[kb]!! val dataA = a.uplinkTotal + a.downlinkTotal val dataB = b.uplinkTotal + b.downlinkTotal if (dataA != dataB) { dataB.compareTo(dataA) } else { val connA = a.tcpConnectionsTotal + a.udpConnectionsTotal val connB = b.tcpConnectionsTotal + b.udpConnectionsTotal if (connA != connB) { connB.compareTo(connA) } else { b.packageName.compareTo(a.packageName) } } } binding.trafficList.post { adapter.data = data.values.toList() adapter.notifyDataSetChanged() } } } inner class ActiveAdapter : RecyclerView.Adapter() { init { setHasStableIds(true) } lateinit var data: List override fun getItemId(position: Int): Long { return data[position].uid.toLong() } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ActiveViewHolder { return ActiveViewHolder( LayoutTrafficItemBinding.inflate(layoutInflater, parent, false) ) } override fun onBindViewHolder(holder: ActiveViewHolder, position: Int) { holder.bind(data[position]) } override fun getItemCount(): Int { if (!::data.isInitialized) return 0 return data.size } } inner class ActiveViewHolder(val binding: LayoutTrafficItemBinding) : RecyclerView.ViewHolder( binding.root ) { lateinit var stats: AppStats fun bind(stats: AppStats) { this.stats = stats PackageCache.awaitLoadSync() val packageName = if (stats.uid > 1000) { PackageCache.uidMap[stats.uid]?.iterator()?.next() ?: "android" } else { "android" } binding.menu.setOnClickListener { val popup = PopupMenu(requireContext(), it) popup.menuInflater.inflate(R.menu.traffic_item_menu, popup.menu) popup.setOnMenuItemClickListener( (requireParentFragment() as TrafficFragment).ItemMenuListener( stats ) ) popup.show() } binding.label.text = PackageCache.loadLabel(packageName) binding.desc.text = "$packageName (${stats.uid})" binding.tcpConnections.text = getString( R.string.tcp_connections, stats.tcpConnectionsTotal ) binding.udpConnections.text = getString( R.string.udp_connections, stats.udpConnectionsTotal ) binding.trafficUplink.text = getString( R.string.traffic_uplink_total, Formatter.formatFileSize(requireContext(), stats.uplinkTotal), ) binding.trafficDownlink.text = getString( R.string.traffic_downlink_total, Formatter.formatFileSize(requireContext(), stats.downlinkTotal), ) val info = PackageCache.installedApps[packageName] if (info != null) runOnDefaultDispatcher { try { val icon = info.loadIcon(app.packageManager) onMainDispatcher { binding.icon.setImageDrawable(icon) } } catch (ignored: Exception) { } } } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ui/ThemedActivity.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 import android.content.res.Configuration import android.os.Bundle import android.widget.TextView import androidx.annotation.StringRes import androidx.appcompat.app.AppCompatActivity import androidx.core.app.ActivityCompat import com.google.android.material.snackbar.Snackbar import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.utils.Theme abstract class ThemedActivity : AppCompatActivity { constructor() : super() constructor(contentLayoutId: Int) : super(contentLayoutId) var themeResId = 0 var uiMode = 0 override fun onCreate(savedInstanceState: Bundle?) { Theme.apply(this) Theme.applyNightTheme() super.onCreate(savedInstanceState) uiMode = resources.configuration.uiMode } 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 if (DataStore.appTheme == Theme.BLACK) { Theme.apply(this) } 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 ================================================ /****************************************************************************** * * * 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 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 ================================================ /****************************************************************************** * 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 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.BuildConfig import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.databinding.LayoutToolsBinding import io.nekohasekai.sagernet.ktx.isExpert 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(CloudflareFragment()) if (BuildConfig.DEBUG || isExpert) tools.add(DebugFragment()) 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/TrafficFragment.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 import android.app.Activity import android.content.ActivityNotFoundException import android.content.Intent import android.net.Uri import android.os.Bundle import android.provider.Settings import android.view.MenuItem import android.view.View import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.widget.PopupMenu import androidx.appcompat.widget.Toolbar 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.SagerNet import io.nekohasekai.sagernet.aidl.AppStats import io.nekohasekai.sagernet.databinding.LayoutTrafficBinding import io.nekohasekai.sagernet.ktx.* import io.nekohasekai.sagernet.utils.PackageCache class TrafficFragment : ToolbarFragment(R.layout.layout_traffic), Toolbar.OnMenuItemClickListener { lateinit var binding: LayoutTrafficBinding lateinit var adapter: TrafficAdapter override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) toolbar.setTitle(R.string.menu_traffic) toolbar.inflateMenu(R.menu.traffic_menu) toolbar.setOnMenuItemClickListener(this) binding = LayoutTrafficBinding.bind(view) adapter = TrafficAdapter() binding.trafficPager.adapter = adapter TabLayoutMediator(binding.trafficTab, binding.trafficPager) { tab, position -> tab.text = when (position) { 0 -> getString(R.string.traffic_active) else -> getString(R.string.traffic_stats) } tab.view.setOnLongClickListener { // clear toast true } }.attach() (requireActivity() as MainActivity).connection.trafficTimeout = 1500 } override fun onMenuItemClick(item: MenuItem): Boolean { when (item.itemId) { R.id.action_clear_traffic_statistics -> { (requireActivity() as MainActivity).connection.service?.resetTrafficStats() runOnDefaultDispatcher { emitStats(emptyList()) } } } return true } inner class TrafficAdapter() : FragmentStateAdapter(this) { override fun getItemCount(): Int { return 2 } override fun createFragment(position: Int): Fragment { return when (position) { 0 -> ActiveFragment() else -> StatsFragment() } } } val listeners = mutableListOf<(List) -> Unit>() fun emitStats(statsList: List) { runOnDefaultDispatcher { for (listener in listeners) listener(statsList) } } override fun onStart() { super.onStart() (requireActivity() as MainActivity).connection.trafficTimeout = 1500 } override fun onStop() { super.onStop() (requireActivity() as MainActivity).connection.trafficTimeout = 0 } override fun onDestroy() { super.onDestroy() (requireActivity() as MainActivity).connection.trafficTimeout = 0 } val createRule = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { if (it.resultCode == Activity.RESULT_OK) { (requireActivity() as MainActivity).ruleCreated() } } inner class ItemMenuListener(val stats: AppStats) : PopupMenu.OnMenuItemClickListener { override fun onMenuItemClick(item: MenuItem): Boolean { when (item.itemId) { R.id.copy_label -> { val success = SagerNet.trySetPrimaryClip(PackageCache.loadLabel(stats.packageName)) snackbar(if (success) R.string.copy_success else R.string.copy_failed).show() } R.id.copy_package_name -> { val success = SagerNet.trySetPrimaryClip(stats.packageName) snackbar(if (success) R.string.copy_success else R.string.copy_failed).show() } R.id.open_app -> { try { val launch = app.packageManager.getLaunchIntentForPackage(stats.packageName) if (launch == null) { snackbar(R.string.app_no_launcher).show() } else { startActivity(launch) } } catch (e: Exception) { snackbar(e.readableMessage).show() } } R.id.open_settings -> { try { startActivity(Intent().apply { action = Settings.ACTION_APPLICATION_DETAILS_SETTINGS data = Uri.fromParts( "package", stats.packageName, null ) }) } catch (e: Exception) { snackbar(e.readableMessage).show() } } R.id.open_market -> { try { startActivity( Intent( Intent.ACTION_VIEW, Uri.parse("market://details?id=${stats.packageName}") ) ) } catch (e: ActivityNotFoundException) { requireContext().launchCustomTab("https://play.google.com/store/apps/details?id=${stats.packageName}") } } R.id.create_rule -> { createRule.launch(Intent( requireContext(), RouteSettingsActivity::class.java ).apply { putExtra(RouteSettingsActivity.EXTRA_PACKAGE_NAME, stats.packageName) }) } } return true } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ui/VpnRequestActivity.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 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.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) } 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/profile/BalancerSettingsActivity.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.annotation.SuppressLint import android.app.Activity import android.content.Intent import android.graphics.Color import android.os.Bundle import android.text.format.Formatter import android.text.method.LinkMovementMethod import android.text.util.Linkify 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.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.takisoft.preferencex.PreferenceFragmentCompat import com.takisoft.preferencex.SimpleMenuPreference import io.nekohasekai.sagernet.Key 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.BalancerBean import io.nekohasekai.sagernet.ktx.* import io.nekohasekai.sagernet.ui.ProfileSelectActivity import io.nekohasekai.sagernet.widget.GroupPreference @SuppressLint("Registered") class BalancerSettingsActivity : ProfileSettingsActivity(R.layout.layout_chain_settings) { override fun createEntity() = BalancerBean() val proxyList = ArrayList() override fun BalancerBean.init() { DataStore.profileName = name DataStore.balancerType = type DataStore.balancerStrategy = strategy DataStore.balancerGroup = groupId DataStore.serverProtocol = proxies.joinToString(",") } override fun BalancerBean.serialize() { name = DataStore.profileName type = DataStore.balancerType strategy = DataStore.balancerStrategy groupId = DataStore.balancerGroup proxies = proxyList.map { it.id } initializeDefaultValues() } lateinit var balancerType: SimpleMenuPreference lateinit var balancerGroup: GroupPreference override fun PreferenceFragmentCompat.createPreferences( savedInstanceState: Bundle?, rootKey: String?, ) { addPreferencesFromResource(R.xml.balancer_preferences) balancerType = findPreference(Key.BALANCER_TYPE)!! balancerGroup = findPreference(Key.BALANCER_GROUP)!! itemView = findViewById(R.id.list_cell) balancerType.setOnPreferenceChangeListener { _, newValue -> updateType(newValue.toString().toInt()) true } } fun updateType(type: Int = DataStore.balancerType) { when (type) { BalancerBean.TYPE_LIST -> { balancerGroup.isVisible = false configurationList.isVisible = true itemView.isVisible = true } BalancerBean.TYPE_GROUP -> { balancerGroup.isVisible = true configurationList.isVisible = false itemView.isVisible = false } }; } lateinit var itemView: LinearLayout 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.balancer_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() } updateType() } 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@BalancerSettingsActivity).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@BalancerSettingsActivity, ProfileSelectActivity::class.java ) ) } } } inner class ProfileHolder(binding: LayoutProfileBinding) : RecyclerView.ViewHolder(binding.root) { val profileName = binding.profileName val profileType = binding.profileType val profileAddress = binding.profileAddress val trafficText: TextView = binding.trafficText val selectedView = binding.selectedView val editButton = binding.edit val shareLayout = binding.share val shareLayer = binding.shareLayer val shareButton = binding.shareIcon fun bind(proxyEntity: ProxyEntity) { profileName.text = proxyEntity.displayName() profileType.text = proxyEntity.displayType() var rx = proxyEntity.rx var tx = proxyEntity.tx val stats = proxyEntity.stats if (stats != null) { rx += stats.rxTotal tx += stats.txTotal } 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@BalancerSettingsActivity, ProfileSelectActivity::class.java ).apply { putExtra(ProfileSelectActivity.EXTRA_SELECTED, proxyEntity) }) } shareLayout.isVisible = false if (proxyEntity.type != 8) runOnDefaultDispatcher { val validateResult = if (DataStore.securityAdvisory) { proxyEntity.requireBean().isInsecure() } else ResultLocal when (validateResult) { is ResultInsecure -> onMainDispatcher { shareLayout.isVisible = true shareLayer.setBackgroundColor(Color.RED) shareButton.setImageResource(R.drawable.ic_baseline_warning_24) shareButton.setColorFilter(Color.WHITE) shareLayout.setOnClickListener { MaterialAlertDialogBuilder(this@BalancerSettingsActivity).setTitle(R.string.insecure) .setMessage(resources.openRawResource(validateResult.textRes) .bufferedReader().use { it.readText() }) .setPositiveButton(android.R.string.ok, null).show().apply { findViewById(android.R.id.message)?.apply { Linkify.addLinks(this, Linkify.WEB_URLS) movementMethod = LinkMovementMethod.getInstance() } } } } is ResultDeprecated -> onMainDispatcher { shareLayout.isVisible = true shareLayer.setBackgroundColor(Color.YELLOW) shareButton.setImageResource(R.drawable.ic_baseline_warning_24) shareButton.setColorFilter(Color.GRAY) shareLayout.setOnClickListener { MaterialAlertDialogBuilder(this@BalancerSettingsActivity).setTitle(R.string.deprecated) .setMessage(resources.openRawResource(validateResult.textRes) .bufferedReader().use { it.readText() }) .setPositiveButton(android.R.string.ok, null).show().apply { findViewById(android.R.id.message)?.apply { Linkify.addLinks(this, Linkify.WEB_URLS) movementMethod = LinkMovementMethod.getInstance() } } } } else -> onMainDispatcher { shareLayout.isVisible = false } } } } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ui/profile/BrookSettingsActivity.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 com.takisoft.preferencex.PreferenceFragmentCompat import com.takisoft.preferencex.SimpleMenuPreference 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.brook.BrookBean import io.nekohasekai.sagernet.ktx.app class BrookSettingsActivity : ProfileSettingsActivity() { override fun createEntity() = BrookBean() override fun BrookBean.init() { DataStore.profileName = name DataStore.serverAddress = serverAddress DataStore.serverPort = serverPort DataStore.serverProtocol = protocol DataStore.serverPassword = password DataStore.serverPath = wsPath } override fun BrookBean.serialize() { name = DataStore.profileName serverAddress = DataStore.serverAddress serverPort = DataStore.serverPort password = DataStore.serverPassword protocol = DataStore.serverProtocol wsPath = DataStore.serverPath } lateinit var protocol: SimpleMenuPreference val protocolValue = app.resources.getStringArray(R.array.brook_protocol_value) lateinit var path: EditTextPreference override fun PreferenceFragmentCompat.createPreferences( savedInstanceState: Bundle?, rootKey: String?, ) { addPreferencesFromResource(R.xml.brook_preferences) findPreference(Key.SERVER_PORT)!!.apply { setOnBindEditTextListener(EditTextPreferenceModifiers.Port) } findPreference(Key.SERVER_PASSWORD)!!.apply { summaryProvider = PasswordSummaryProvider } protocol = findPreference(Key.SERVER_PROTOCOL)!! path = findPreference(Key.SERVER_PATH)!! if (protocol.value !in protocolValue) { protocol.value = protocolValue[0] } updateProtocol(protocol.value) protocol.setOnPreferenceChangeListener { _, newValue -> updateProtocol(newValue as String) true } } fun updateProtocol(value: String) { path.isVisible = value.startsWith("ws") } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ui/profile/ChainSettingsActivity.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.annotation.SuppressLint import android.app.Activity import android.content.Intent import android.graphics.Color import android.os.Bundle import android.text.format.Formatter import android.text.method.LinkMovementMethod import android.text.util.Linkify 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.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.takisoft.preferencex.PreferenceFragmentCompat 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 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 profileAddress = binding.profileAddress val trafficText: TextView = binding.trafficText val selectedView = binding.selectedView val editButton = binding.edit val shareLayout = binding.share val shareLayer = binding.shareLayer val shareButton = binding.shareIcon fun bind(proxyEntity: ProxyEntity) { profileName.text = proxyEntity.displayName() profileType.text = proxyEntity.displayType() var rx = proxyEntity.rx var tx = proxyEntity.tx val stats = proxyEntity.stats if (stats != null) { rx += stats.rxTotal tx += stats.txTotal } 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 if (proxyEntity.type != 8) runOnDefaultDispatcher { val validateResult = if (DataStore.securityAdvisory) { proxyEntity.requireBean().isInsecure() } else ResultLocal when (validateResult) { is ResultInsecure -> onMainDispatcher { shareLayout.isVisible = true shareLayer.setBackgroundColor(Color.RED) shareButton.setImageResource(R.drawable.ic_baseline_warning_24) shareButton.setColorFilter(Color.WHITE) shareLayout.setOnClickListener { MaterialAlertDialogBuilder(this@ChainSettingsActivity).setTitle(R.string.insecure) .setMessage(resources.openRawResource(validateResult.textRes) .bufferedReader().use { it.readText() }) .setPositiveButton(android.R.string.ok, null).show().apply { findViewById(android.R.id.message)?.apply { Linkify.addLinks(this, Linkify.WEB_URLS) movementMethod = LinkMovementMethod.getInstance() } } } } is ResultDeprecated -> onMainDispatcher { shareLayout.isVisible = true shareLayer.setBackgroundColor(Color.YELLOW) shareButton.setImageResource(R.drawable.ic_baseline_warning_24) shareButton.setColorFilter(Color.GRAY) shareLayout.setOnClickListener { MaterialAlertDialogBuilder(this@ChainSettingsActivity).setTitle(R.string.deprecated) .setMessage(resources.openRawResource(validateResult.textRes) .bufferedReader().use { it.readText() }) .setPositiveButton(android.R.string.ok, null).show().apply { findViewById(android.R.id.message)?.apply { Linkify.addLinks(this, Linkify.WEB_URLS) movementMethod = LinkMovementMethod.getInstance() } } } } else -> onMainDispatcher { shareLayout.isVisible = false } } } } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ui/profile/ConfigEditActivity.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.annotation.SuppressLint import android.content.DialogInterface import android.graphics.Color import android.os.Bundle import android.view.Menu import android.view.MenuItem import androidx.appcompat.app.AlertDialog import cn.hutool.json.JSONObject import com.blacksquircle.ui.editorkit.listener.OnChangeListener import com.blacksquircle.ui.editorkit.model.ColorScheme import com.blacksquircle.ui.feature.editor.customview.ExtendedKeyboard import com.blacksquircle.ui.language.base.model.SyntaxScheme 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.R import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.databinding.LayoutEditConfigBinding import io.nekohasekai.sagernet.ktx.getColorAttr import io.nekohasekai.sagernet.ktx.onMainDispatcher import io.nekohasekai.sagernet.ktx.readableMessage import io.nekohasekai.sagernet.ktx.runOnDefaultDispatcher import io.nekohasekai.sagernet.ui.ThemedActivity class ConfigEditActivity : ThemedActivity() { var config = "" var dirty = 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) } } @SuppressLint("InlinedApi") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val 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.colorScheme = mkTheme() binding.editor.language = JsonLanguage() binding.editor.onChangeListener = OnChangeListener { config = binding.editor.text.toString() if (!dirty) { dirty = true DataStore.dirty = true } } binding.editor.setHorizontallyScrolling(true) binding.actionTab.setOnClickListener { binding.editor.insert(binding.editor.tab()) } val extendedKeyboard = findViewById(R.id.extended_keyboard) extendedKeyboard.setKeyListener { char -> binding.editor.insert(char) } extendedKeyboard.setHasFixedSize(true) extendedKeyboard.submitList("{}();,.=|&![]<>+-/*?:_".map { it.toString() }) extendedKeyboard.setBackgroundColor(getColorAttr(R.attr.primaryOrTextPrimary)) runOnDefaultDispatcher { config = DataStore.serverConfig onMainDispatcher { binding.editor.setTextContent(config) } } } fun saveAndExit() { config = try { JSONObject(config).toStringPretty() } catch (e: Exception) { MaterialAlertDialogBuilder(this).setTitle(R.string.error_title) .setMessage(e.readableMessage).show() return } DataStore.serverConfig = config 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) } fun mkTheme(): ColorScheme { val colorPrimary = getColorAttr(R.attr.colorPrimary) val colorPrimaryDark = getColorAttr(R.attr.colorPrimaryDark) return ColorScheme( textColor = colorPrimary, backgroundColor = Color.WHITE, gutterColor = colorPrimary, gutterDividerColor = Color.WHITE, gutterCurrentLineNumberColor = Color.WHITE, gutterTextColor = Color.WHITE, selectedLineColor = Color.parseColor("#D3D3D3"), selectionColor = colorPrimary, suggestionQueryColor = Color.parseColor("#7CE0F3"), findResultBackgroundColor = Color.parseColor("#5F5E5A"), delimiterBackgroundColor = Color.parseColor("#5F5E5A"), syntaxScheme = SyntaxScheme( numberColor = Color.parseColor("#BB8FF8"), operatorColor = Color.BLACK, keywordColor = Color.parseColor("#EB347E"), typeColor = Color.parseColor("#7FD0E4"), langConstColor = Color.parseColor("#EB347E"), preprocessorColor = Color.parseColor("#EB347E"), variableColor = Color.parseColor("#7FD0E4"), methodColor = Color.parseColor("#B6E951"), stringColor = colorPrimaryDark, commentColor = Color.parseColor("#89826D"), tagColor = Color.parseColor("#F8F8F8"), tagNameColor = Color.parseColor("#EB347E"), attrNameColor = Color.parseColor("#B6E951"), attrValueColor = Color.parseColor("#EBE48C"), entityRefColor = Color.parseColor("#BB8FF8") ) ) } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ui/profile/ConfigSettingsActivity.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 com.takisoft.preferencex.PreferenceFragmentCompat import io.nekohasekai.sagernet.Key import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.fmt.internal.ConfigBean import io.nekohasekai.sagernet.ktx.onMainDispatcher import io.nekohasekai.sagernet.ktx.runOnDefaultDispatcher import io.nekohasekai.sagernet.widget.EditConfigPreference class ConfigSettingsActivity : ProfileSettingsActivity() { override fun createEntity() = ConfigBean() var config = "" var dirty = false override fun ConfigBean.init() { DataStore.profileName = name DataStore.serverProtocol = type DataStore.serverConfig = content config = content } override fun ConfigBean.serialize() { name = DataStore.profileName type = DataStore.serverProtocol content = config } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) supportActionBar!!.setTitle(R.string.config_settings) } 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) { runOnDefaultDispatcher { val newConfig = DataStore.serverConfig if (newConfig != config) { config = newConfig onMainDispatcher { editConfigPreference.notifyChanged() } } } } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ui/profile/HttpSettingsActivity.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 com.takisoft.preferencex.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.http.HttpBean class HttpSettingsActivity : ProfileSettingsActivity() { override fun createEntity() = HttpBean() override fun HttpBean.init() { DataStore.profileName = name DataStore.serverAddress = serverAddress DataStore.serverPort = serverPort DataStore.serverUsername = username DataStore.serverPassword = password DataStore.serverTLS = tls DataStore.serverSNI = sni } override fun HttpBean.serialize() { name = DataStore.profileName serverAddress = DataStore.serverAddress serverPort = DataStore.serverPort username = DataStore.serverUsername password = DataStore.serverPassword tls = DataStore.serverTLS sni = DataStore.serverSNI } override fun PreferenceFragmentCompat.createPreferences( savedInstanceState: Bundle?, rootKey: String?, ) { addPreferencesFromResource(R.xml.http_preferences) findPreference(Key.SERVER_PORT)!!.apply { setOnBindEditTextListener(EditTextPreferenceModifiers.Port) } findPreference(Key.SERVER_PASSWORD)!!.apply { summaryProvider = PasswordSummaryProvider } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ui/profile/HysteriaSettingsActivity.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 com.takisoft.preferencex.PreferenceFragmentCompat import com.takisoft.preferencex.SimpleMenuPreference 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 class HysteriaSettingsActivity : ProfileSettingsActivity() { override fun createEntity() = HysteriaBean().applyDefaultValues() override fun HysteriaBean.init() { DataStore.profileName = name DataStore.serverAddress = serverAddress DataStore.serverPort = serverPort DataStore.serverObfs = obfuscation DataStore.serverAuthType = authPayloadType DataStore.serverPassword = authPayload DataStore.serverSNI = sni DataStore.serverCertificates = caText DataStore.serverAllowInsecure = allowInsecure DataStore.serverUploadSpeed = uploadMbps DataStore.serverDownloadSpeed = downloadMbps DataStore.serverStreamReceiveWindow = streamReceiveWindow DataStore.serverConnectionReceiveWindow = connectionReceiveWindow DataStore.serverDisableMtuDiscovery = disableMtuDiscovery } override fun HysteriaBean.serialize() { name = DataStore.profileName serverAddress = DataStore.serverAddress serverPort = DataStore.serverPort obfuscation = DataStore.serverObfs authPayloadType = DataStore.serverAuthType authPayload = DataStore.serverPassword sni = DataStore.serverSNI caText = DataStore.serverCertificates allowInsecure = DataStore.serverAllowInsecure uploadMbps = DataStore.serverUploadSpeed downloadMbps = DataStore.serverDownloadSpeed streamReceiveWindow = DataStore.serverStreamReceiveWindow connectionReceiveWindow = DataStore.serverConnectionReceiveWindow disableMtuDiscovery = DataStore.serverDisableMtuDiscovery } 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 } 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_PORT)!!.apply { setOnBindEditTextListener(EditTextPreferenceModifiers.Port) } findPreference(Key.SERVER_PASSWORD)!!.apply { summaryProvider = PasswordSummaryProvider } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ui/profile/NaiveSettingsActivity.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 com.takisoft.preferencex.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.serverHeaders = extraHeaders } override fun NaiveBean.serialize() { name = DataStore.profileName serverAddress = DataStore.serverAddress serverPort = DataStore.serverPort username = DataStore.serverUsername password = DataStore.serverPassword proto = DataStore.serverProtocol extraHeaders = DataStore.serverHeaders.replace("\r\n", "\n") } 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 } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ui/profile/PingTunnelSettingsActivity.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 cn.hutool.core.util.NumberUtil import com.takisoft.preferencex.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.pingtunnel.PingTunnelBean class PingTunnelSettingsActivity : ProfileSettingsActivity() { override fun createEntity() = PingTunnelBean() override fun PingTunnelBean.init() { DataStore.profileName = name DataStore.serverAddress = serverAddress DataStore.serverPassword = key } override fun PingTunnelBean.serialize() { name = DataStore.profileName serverAddress = DataStore.serverAddress key = DataStore.serverPassword } override fun PreferenceFragmentCompat.createPreferences( savedInstanceState: Bundle?, rootKey: String?, ) { addPreferencesFromResource(R.xml.pingtunnel_preferences) findPreference(Key.SERVER_PASSWORD)!!.apply { summaryProvider = PasswordSummaryProvider setOnBindEditTextListener(EditTextPreferenceModifiers.Number) setOnPreferenceChangeListener { _, newValue -> newValue.toString().let { it.isBlank() || NumberUtil.isInteger(it) } } } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ui/profile/ProfileSettingsActivity.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.content.DialogInterface import android.os.Bundle import android.os.Parcelable import android.view.Menu import android.view.MenuItem import android.view.View 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 com.github.shadowsocks.plugin.Empty import com.github.shadowsocks.plugin.fragment.AlertDialogFragment import com.takisoft.preferencex.PreferenceFragmentCompat 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.SagerDatabase import io.nekohasekai.sagernet.database.preference.OnPreferenceDataStoreChangeListener import io.nekohasekai.sagernet.fmt.AbstractBean import io.nekohasekai.sagernet.ktx.applyDefaultValues import io.nekohasekai.sagernet.ktx.onMainDispatcher import io.nekohasekai.sagernet.ktx.runOnDefaultDispatcher import io.nekohasekai.sagernet.ui.ThemedActivity import io.nekohasekai.sagernet.utils.DirectBoot 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() 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 { val proxyEntity = SagerDatabase.proxyDao.getById(editingId) if (proxyEntity == null) { onMainDispatcher { finish() } return@runOnDefaultDispatcher } DataStore.editingGroup = proxyEntity.groupId (proxyEntity.requireBean() as T).init() } onMainDispatcher { supportFragmentManager .beginTransaction() .replace(R.id.settings, MyPreferenceFragmentCompat().apply { activity = this@ProfileSettingsActivity }) .commit() } } } } open suspend fun saveAndExit() { val editingId = DataStore.editingId if (editingId == 0L) { val editingGroup = DataStore.editingGroup ProfileManager.createProfile(editingGroup, createEntity().apply { serialize() }) } else { val entity = SagerDatabase.proxyDao.getById(DataStore.editingId) if (entity == null) { finish() return } if (entity.id == DataStore.selectedProxy) { SagerNet.stopService() } ProfileManager.updateProfile(entity.apply { (requireBean() as T).serialize() }) } if (editingId == DataStore.selectedProxy && DataStore.directBootAware) DirectBoot.update() 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 (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() { lateinit var activity: ProfileSettingsActivity<*> override fun onCreatePreferencesFix(savedInstanceState: Bundle?, rootKey: String?) { preferenceManager.preferenceDataStore = DataStore.profileCacheStore activity.apply { createPreferences(savedInstanceState, rootKey) if (isSubscription) { // findPreference(Key.PROFILE_NAME)?.isEnabled = false } } } 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(activity) } 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 } 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 { return if (preference.text.isNullOrBlank()) { preference.context.getString(androidx.preference.R.string.not_set) } else { "\u2022".repeat(preference.text.length) } } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ui/profile/RelayBatonSettingsActivity.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 com.takisoft.preferencex.PreferenceFragmentCompat import io.nekohasekai.sagernet.Key import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.fmt.relaybaton.RelayBatonBean class RelayBatonSettingsActivity : ProfileSettingsActivity() { override fun createEntity() = RelayBatonBean() override fun RelayBatonBean.init() { DataStore.profileName = name DataStore.serverAddress = serverAddress DataStore.serverUsername = username DataStore.serverPassword = password } override fun RelayBatonBean.serialize() { name = DataStore.profileName serverAddress = DataStore.serverAddress username = DataStore.serverUsername password = DataStore.serverPassword } override fun PreferenceFragmentCompat.createPreferences( savedInstanceState: Bundle?, rootKey: String?, ) { addPreferencesFromResource(R.xml.relaybaton_preferences) findPreference(Key.SERVER_PASSWORD)!!.apply { summaryProvider = PasswordSummaryProvider } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ui/profile/SSHSettingsActivity.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 com.takisoft.preferencex.PreferenceFragmentCompat import com.takisoft.preferencex.SimpleMenuPreference 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 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/ShadowsocksRSettingsActivity.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 com.takisoft.preferencex.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.shadowsocksr.ShadowsocksRBean class ShadowsocksRSettingsActivity : ProfileSettingsActivity() { override fun createEntity() = ShadowsocksRBean() override fun ShadowsocksRBean.init() { DataStore.profileName = name DataStore.serverAddress = serverAddress DataStore.serverPort = serverPort DataStore.serverPassword = password DataStore.serverMethod = method DataStore.serverProtocol = protocol DataStore.serverProtocolParam = protocolParam DataStore.serverObfs = obfs DataStore.serverObfsParam = obfsParam } override fun ShadowsocksRBean.serialize() { name = DataStore.profileName serverAddress = DataStore.serverAddress serverPort = DataStore.serverPort method = DataStore.serverMethod password = DataStore.serverPassword protocol = DataStore.serverProtocol protocolParam = DataStore.serverProtocolParam obfs = DataStore.serverObfs obfsParam = DataStore.serverObfsParam } override fun PreferenceFragmentCompat.createPreferences( savedInstanceState: Bundle?, rootKey: String?, ) { addPreferencesFromResource(R.xml.shadowsocksr_preferences) findPreference(Key.SERVER_PORT)!!.apply { setOnBindEditTextListener(EditTextPreferenceModifiers.Port) } findPreference(Key.SERVER_PASSWORD)!!.apply { summaryProvider = PasswordSummaryProvider } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ui/profile/ShadowsocksSettingsActivity.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.app.Activity import android.content.BroadcastReceiver import android.content.DialogInterface import android.os.Bundle import android.view.View import androidx.activity.result.component1 import androidx.activity.result.component2 import androidx.activity.result.contract.ActivityResultContracts import androidx.fragment.app.setFragmentResultListener import androidx.lifecycle.lifecycleScope import androidx.lifecycle.whenCreated import androidx.preference.EditTextPreference import androidx.preference.Preference import com.github.shadowsocks.plugin.* import com.github.shadowsocks.plugin.fragment.AlertDialogFragment import com.github.shadowsocks.preference.PluginConfigurationDialogFragment import com.github.shadowsocks.preference.PluginPreference import com.github.shadowsocks.preference.PluginPreferenceDialogFragment import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar import com.takisoft.preferencex.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.shadowsocks.ShadowsocksBean import io.nekohasekai.sagernet.ktx.listenForPackageChanges import io.nekohasekai.sagernet.ktx.readableMessage import io.nekohasekai.sagernet.ktx.runOnDefaultDispatcher import io.nekohasekai.sagernet.ktx.showAllowingStateLoss import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch class ShadowsocksSettingsActivity : ProfileSettingsActivity(), Preference.OnPreferenceChangeListener { override fun createEntity() = ShadowsocksBean() private lateinit var plugin: PluginPreference private lateinit var pluginConfigure: EditTextPreference private lateinit var pluginConfiguration: PluginConfiguration private lateinit var receiver: BroadcastReceiver override fun ShadowsocksBean.init() { DataStore.profileName = name DataStore.serverAddress = serverAddress DataStore.serverPort = serverPort DataStore.serverMethod = method DataStore.serverPassword = password DataStore.serverPlugin = plugin } override fun ShadowsocksBean.serialize() { name = DataStore.profileName serverAddress = DataStore.serverAddress serverPort = DataStore.serverPort method = DataStore.serverMethod password = DataStore.serverPassword plugin = DataStore.serverPlugin } override fun onAttachedToWindow() { super.onAttachedToWindow() receiver = listenForPackageChanges(false) { lifecycleScope.launch(Dispatchers.Main) { // wait until changes were flushed whenCreated { initPlugins() } } } } override fun PreferenceFragmentCompat.createPreferences( savedInstanceState: Bundle?, rootKey: String?, ) { addPreferencesFromResource(R.xml.shadowsocks_preferences) findPreference(Key.SERVER_PORT)!!.apply { setOnBindEditTextListener(EditTextPreferenceModifiers.Port) } findPreference(Key.SERVER_PASSWORD)!!.apply { summaryProvider = PasswordSummaryProvider } plugin = findPreference(Key.SERVER_PLUGIN)!! pluginConfigure = findPreference(Key.SERVER_PLUGIN_CONFIGURE)!! pluginConfigure.setOnBindEditTextListener(EditTextPreferenceModifiers.Monospace) pluginConfigure.onPreferenceChangeListener = this@ShadowsocksSettingsActivity pluginConfiguration = PluginConfiguration(DataStore.serverPlugin ?: "") initPlugins() } override fun PreferenceFragmentCompat.viewCreated(view: View, savedInstanceState: Bundle?) { setFragmentResultListener(PluginPreferenceDialogFragment::class.java.name) { _, bundle -> val selected = plugin.plugins.lookup.getValue( bundle.getString(PluginPreferenceDialogFragment.KEY_SELECTED_ID)!!) val override = pluginConfiguration.pluginsOptions.keys.firstOrNull { plugin.plugins.lookup[it] == selected } pluginConfiguration = PluginConfiguration(pluginConfiguration.pluginsOptions, override ?: selected.id) DataStore.serverPlugin = pluginConfiguration.toString() DataStore.dirty = true plugin.value = pluginConfiguration.selected pluginConfigure.isEnabled = selected !is NoPlugin pluginConfigure.text = pluginConfiguration.getOptions().toString() if (!selected.trusted) { Snackbar.make(requireView(), R.string.plugin_untrusted, Snackbar.LENGTH_LONG).show() } } AlertDialogFragment.setResultListener(this, UnsavedChangesDialogFragment::class.java.simpleName) { which, _ -> when (which) { DialogInterface.BUTTON_POSITIVE -> { runOnDefaultDispatcher { saveAndExit() } } DialogInterface.BUTTON_NEGATIVE -> requireActivity().finish() } } } private fun initPlugins() { plugin.value = pluginConfiguration.selected plugin.init() pluginConfigure.isEnabled = plugin.selectedEntry?.let { it is NoPlugin } == false pluginConfigure.text = pluginConfiguration.getOptions().toString() } private fun showPluginEditor() { PluginConfigurationDialogFragment().apply { setArg(Key.SERVER_PLUGIN_CONFIGURE, pluginConfiguration.selected) setTargetFragment(child, 0) }.showAllowingStateLoss(supportFragmentManager, Key.SERVER_PLUGIN_CONFIGURE) } override fun onPreferenceChange(preference: Preference?, newValue: Any?): Boolean = try { val selected = pluginConfiguration.selected pluginConfiguration = PluginConfiguration((pluginConfiguration.pluginsOptions + (pluginConfiguration.selected to PluginOptions(selected, newValue as? String?))).toMutableMap(), selected) DataStore.serverPlugin = pluginConfiguration.toString() DataStore.dirty = true true } catch (exc: RuntimeException) { Snackbar.make(child.requireView(), exc.readableMessage, Snackbar.LENGTH_LONG).show() false } private val configurePlugin = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { (resultCode, data) -> when (resultCode) { Activity.RESULT_OK -> { val options = data?.getStringExtra(PluginContract.EXTRA_OPTIONS) pluginConfigure.text = options onPreferenceChange(null, options) } PluginContract.RESULT_FALLBACK -> showPluginEditor() } } override fun PreferenceFragmentCompat.displayPreferenceDialog(preference: Preference): Boolean { when (preference.key) { Key.SERVER_PLUGIN -> PluginPreferenceDialogFragment().apply { setArg(Key.SERVER_PLUGIN) setTargetFragment(child, 0) }.showAllowingStateLoss(supportFragmentManager, Key.SERVER_PLUGIN) Key.SERVER_PLUGIN_CONFIGURE -> { val intent = PluginManager.buildIntent(plugin.selectedEntry!!.id, PluginContract.ACTION_CONFIGURE) if (intent.resolveActivity(packageManager) == null) showPluginEditor() else { configurePlugin.launch(intent .putExtra(PluginContract.EXTRA_OPTIONS, pluginConfiguration.getOptions().toString())) } } else -> return false } return true } val pluginHelp = registerForActivityResult( ActivityResultContracts.StartActivityForResult()) { (resultCode, data) -> if (resultCode == Activity.RESULT_OK) MaterialAlertDialogBuilder(this) .setTitle("?") .setMessage(data?.getCharSequenceExtra(PluginContract.EXTRA_HELP_MESSAGE)) .show() } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ui/profile/SnellSettingsActivity.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 com.takisoft.preferencex.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.snell.SnellBean class SnellSettingsActivity : ProfileSettingsActivity() { override fun createEntity() = SnellBean() override fun SnellBean.init() { DataStore.profileName = name DataStore.serverAddress = serverAddress DataStore.serverPort = serverPort DataStore.serverPassword = psk DataStore.serverProtocolVersion = version DataStore.serverObfs = obfsMode DataStore.serverObfsParam = obfsHost } override fun SnellBean.serialize() { name = DataStore.profileName serverAddress = DataStore.serverAddress serverPort = DataStore.serverPort psk = DataStore.serverPassword version = DataStore.serverProtocolVersion obfsMode = DataStore.serverObfs obfsHost = DataStore.serverObfsParam } override fun PreferenceFragmentCompat.createPreferences( savedInstanceState: Bundle?, rootKey: String?, ) { addPreferencesFromResource(R.xml.snell_preferences) findPreference(Key.SERVER_PORT)!!.apply { setOnBindEditTextListener(EditTextPreferenceModifiers.Port) } findPreference(Key.SERVER_PASSWORD)!!.apply { summaryProvider = PasswordSummaryProvider } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ui/profile/SocksSettingsActivity.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.SwitchPreference import com.takisoft.preferencex.PreferenceFragmentCompat import com.takisoft.preferencex.SimpleMenuPreference 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 class SocksSettingsActivity : ProfileSettingsActivity() { override fun createEntity() = SOCKSBean() override fun SOCKSBean.init() { DataStore.profileName = name DataStore.serverAddress = serverAddress DataStore.serverPort = serverPort DataStore.serverProtocolVersion = protocol DataStore.serverUsername = username DataStore.serverPassword = password DataStore.serverTLS = tls DataStore.serverSNI = sni } override fun SOCKSBean.serialize() { name = DataStore.profileName serverAddress = DataStore.serverAddress serverPort = DataStore.serverPort protocol = DataStore.serverProtocolVersion username = DataStore.serverUsername password = DataStore.serverPassword tls = DataStore.serverTLS sni = DataStore.serverSNI } 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)!! val useTls = findPreference(Key.SERVER_TLS)!! val sni = findPreference(Key.SERVER_SNI)!! fun updateProtocol(version: Int) { password.isVisible = version == SOCKSBean.PROTOCOL_SOCKS5 useTls.isVisible = version == SOCKSBean.PROTOCOL_SOCKS5 sni.isVisible = version == SOCKSBean.PROTOCOL_SOCKS5 } updateProtocol(DataStore.serverProtocolVersion) protocol.setOnPreferenceChangeListener { _, newValue -> updateProtocol((newValue as String).toInt()) true } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ui/profile/StandardV2RaySettingsActivity.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.PreferenceCategory import androidx.preference.SwitchPreference import com.takisoft.preferencex.PreferenceFragmentCompat import com.takisoft.preferencex.SimpleMenuPreference 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.v2ray.StandardV2RayBean import io.nekohasekai.sagernet.fmt.v2ray.VLESSBean import io.nekohasekai.sagernet.fmt.v2ray.VMessBean import io.nekohasekai.sagernet.ktx.app abstract class StandardV2RaySettingsActivity : ProfileSettingsActivity() { var bean: StandardV2RayBean? = null override fun StandardV2RayBean.init() { bean = this DataStore.profileName = name DataStore.serverAddress = serverAddress DataStore.serverPort = serverPort DataStore.serverUserId = uuid DataStore.serverEncryption = encryption if (this is VMessBean) { DataStore.serverAlterId = alterId } DataStore.serverNetwork = type DataStore.serverHeader = headerType DataStore.serverHost = host when (type) { "kcp" -> DataStore.serverPath = mKcpSeed "quic" -> DataStore.serverPath = quicKey "grpc" -> { DataStore.serverPath = grpcServiceName DataStore.serverMultiMode = grpcMultiMode } else -> DataStore.serverPath = path } DataStore.serverSecurity = security DataStore.serverSNI = sni DataStore.serverALPN = alpn DataStore.serverCertificates = certificates DataStore.serverFlow = flow DataStore.serverQuicSecurity = quicSecurity DataStore.serverWsBrowserForwarding = wsUseBrowserForwarder DataStore.serverAllowInsecure = allowInsecure } override fun StandardV2RayBean.serialize() { name = DataStore.profileName serverAddress = DataStore.serverAddress serverPort = DataStore.serverPort uuid = DataStore.serverUserId encryption = DataStore.serverEncryption if (this is VMessBean) { alterId = DataStore.serverAlterId } type = DataStore.serverNetwork headerType = DataStore.serverHeader host = DataStore.serverHost when (type) { "kcp" -> mKcpSeed = DataStore.serverPath "quic" -> quicKey = DataStore.serverPath "grpc" -> { grpcServiceName = DataStore.serverPath grpcMultiMode = DataStore.serverMultiMode } else -> path = DataStore.serverPath } security = DataStore.serverSecurity sni = DataStore.serverSNI alpn = DataStore.serverALPN certificates = DataStore.serverCertificates flow = DataStore.serverFlow quicSecurity = DataStore.serverQuicSecurity wsUseBrowserForwarder = DataStore.serverWsBrowserForwarding allowInsecure = DataStore.serverAllowInsecure } lateinit var encryption: SimpleMenuPreference lateinit var network: SimpleMenuPreference lateinit var header: SimpleMenuPreference lateinit var requestHost: EditTextPreference lateinit var path: EditTextPreference lateinit var quicSecurity: SimpleMenuPreference lateinit var security: SimpleMenuPreference lateinit var multiMode: SwitchPreference lateinit var xtlsFlow: SimpleMenuPreference lateinit var securityCategory: PreferenceCategory lateinit var wsCategory: PreferenceCategory lateinit var vmessExperimentsCategory: PreferenceCategory override fun PreferenceFragmentCompat.createPreferences( savedInstanceState: Bundle?, rootKey: String?, ) { addPreferencesFromResource(R.xml.standard_v2ray_preferences) findPreference(Key.SERVER_PORT)!!.apply { setOnBindEditTextListener(EditTextPreferenceModifiers.Port) } encryption = findPreference(Key.SERVER_ENCRYPTION)!! network = findPreference(Key.SERVER_NETWORK)!! header = findPreference(Key.SERVER_HEADER)!! requestHost = findPreference(Key.SERVER_HOST)!! path = findPreference(Key.SERVER_PATH)!! quicSecurity = findPreference(Key.SERVER_QUIC_SECURITY)!! security = findPreference(Key.SERVER_SECURITY)!! securityCategory = findPreference(Key.SERVER_SECURITY_CATEGORY)!! wsCategory = findPreference(Key.SERVER_WS_CATEGORY)!! xtlsFlow = findPreference(Key.SERVER_FLOW)!! multiMode = findPreference(Key.SERVER_MULTI_MODE)!! val alterId = findPreference(Key.SERVER_ALTER_ID)!! if (bean is VLESSBean) { alterId.isVisible = false encryption.setEntries(R.array.vless_encryption_entry) encryption.setEntryValues(R.array.vless_encryption_value) val vev = resources.getStringArray(R.array.vless_encryption_value) if (encryption.value !in vev) { encryption.value = vev[0] } } else { alterId.setOnBindEditTextListener(EditTextPreferenceModifiers.Port) encryption.setEntries(R.array.vmess_encryption_entry) encryption.setEntryValues(R.array.vmess_encryption_value) val vev = resources.getStringArray(R.array.vmess_encryption_value) if (encryption.value !in vev) { encryption.value = "auto" } } findPreference(Key.SERVER_USER_ID)!!.apply { summaryProvider = PasswordSummaryProvider } updateView(network.value) network.setOnPreferenceChangeListener { _, newValue -> updateView(newValue as String) true } security.setOnPreferenceChangeListener { _, newValue -> updateTle(newValue as String) true } vmessExperimentsCategory = findPreference(Key.SERVER_VMESS_EXPERIMENTS_CATEGORY)!! vmessExperimentsCategory.isVisible = false } val tcpHeadersValue = app.resources.getStringArray(R.array.tcp_headers_value) val kcpQuicHeadersValue = app.resources.getStringArray(R.array.kcp_quic_headers_value) val quicSecurityValue = app.resources.getStringArray(R.array.quic_security_value) val xtlsFlowValue = app.resources.getStringArray(R.array.xtls_flow_value) fun updateView(network: String) { if (bean is StandardV2RayBean) { when (network) { "tcp", "kcp" -> { security.setEntries(R.array.transport_layer_encryption_entry) security.setEntryValues(R.array.transport_layer_encryption_value) security.value = DataStore.serverSecurity val tlev = resources.getStringArray(R.array.transport_layer_encryption_value) if (security.value !in tlev) { security.value = tlev[0] } } else -> { security.setEntries(R.array.transport_layer_encryption_entry) security.setEntryValues(R.array.transport_layer_encryption_value) security.value = DataStore.serverSecurity val tlev = resources.getStringArray(R.array.transport_layer_encryption_value) if (security.value !in tlev) { security.value = tlev[0] } } } } updateTle(security.value) val isQuic = network == "quic" val isGRPC = network == "grpc" val isWs = network == "ws" quicSecurity.isVisible = isQuic if (isQuic) { if (DataStore.serverQuicSecurity !in quicSecurityValue) { quicSecurity.value = quicSecurityValue[0] } else { quicSecurity.value = DataStore.serverQuicSecurity } } multiMode.isVisible = isGRPC wsCategory.isVisible = isWs when (network) { "tcp" -> { header.setEntries(R.array.tcp_headers_entry) header.setEntryValues(R.array.tcp_headers_value) if (DataStore.serverHeader !in tcpHeadersValue) { header.value = tcpHeadersValue[0] } else { header.value = DataStore.serverHeader } var isHttp = header.value == "http" requestHost.isVisible = isHttp path.isVisible = isHttp header.setOnPreferenceChangeListener { _, newValue -> isHttp = newValue == "http" requestHost.isVisible = isHttp path.isVisible = isHttp true } requestHost.setTitle(R.string.http_host) path.setTitle(R.string.http_path) header.isVisible = true } "http" -> { requestHost.setTitle(R.string.http_host) path.setTitle(R.string.http_path) header.isVisible = false requestHost.isVisible = true path.isVisible = true } "ws" -> { requestHost.setTitle(R.string.ws_host) path.setTitle(R.string.ws_path) header.isVisible = false requestHost.isVisible = true path.isVisible = true } "kcp" -> { header.setEntries(R.array.kcp_quic_headers_entry) header.setEntryValues(R.array.kcp_quic_headers_value) path.setTitle(R.string.kcp_seed) if (DataStore.serverHeader !in kcpQuicHeadersValue) { header.value = kcpQuicHeadersValue[0] } else { header.value = DataStore.serverHeader } header.onPreferenceChangeListener = null header.isVisible = true requestHost.isVisible = false path.isVisible = true } "quic" -> { header.setEntries(R.array.kcp_quic_headers_entry) header.setEntryValues(R.array.kcp_quic_headers_value) path.setTitle(R.string.quic_key) if (DataStore.serverHeader !in kcpQuicHeadersValue) { header.value = kcpQuicHeadersValue[0] } else { header.value = DataStore.serverHeader } header.onPreferenceChangeListener = null header.isVisible = true requestHost.isVisible = false path.isVisible = true } "grpc" -> { path.setTitle(R.string.grpc_service_name) header.isVisible = false requestHost.isVisible = false path.isVisible = true } } } fun updateTle(tle: String) { val isTLS = tle == "tls" val isXTLS = tle == "xtls" securityCategory.isVisible = isTLS || isXTLS xtlsFlow.isVisible = isXTLS if (isXTLS) { if (DataStore.serverFlow !in xtlsFlowValue) { xtlsFlow.value = xtlsFlowValue[0] } else { xtlsFlow.value = DataStore.serverFlow } } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ui/profile/TrojanGoSettingsActivity.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.app.Activity import android.content.BroadcastReceiver import android.content.DialogInterface import android.os.Bundle import android.view.View import androidx.activity.result.component1 import androidx.activity.result.component2 import androidx.activity.result.contract.ActivityResultContracts import androidx.fragment.app.setFragmentResultListener import androidx.lifecycle.lifecycleScope import androidx.lifecycle.whenCreated import androidx.preference.EditTextPreference import androidx.preference.Preference import androidx.preference.PreferenceCategory import com.github.shadowsocks.plugin.* import com.github.shadowsocks.plugin.fragment.AlertDialogFragment import com.github.shadowsocks.preference.PluginConfigurationDialogFragment import com.github.shadowsocks.preference.PluginPreference import com.github.shadowsocks.preference.PluginPreferenceDialogFragment import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar import com.takisoft.preferencex.PreferenceFragmentCompat import com.takisoft.preferencex.SimpleMenuPreference 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.* import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch class TrojanGoSettingsActivity : ProfileSettingsActivity(), Preference.OnPreferenceChangeListener { override fun createEntity() = TrojanGoBean() private lateinit var plugin: PluginPreference private lateinit var pluginConfigure: EditTextPreference private lateinit var pluginConfiguration: PluginConfiguration private lateinit var receiver: BroadcastReceiver override fun TrojanGoBean.init() { DataStore.profileName = name DataStore.serverAddress = serverAddress DataStore.serverPort = serverPort DataStore.serverPassword = password DataStore.serverSNI = sni 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 } DataStore.serverPlugin = plugin } override fun TrojanGoBean.serialize() { name = DataStore.profileName serverAddress = DataStore.serverAddress serverPort = DataStore.serverPort password = DataStore.serverPassword sni = DataStore.serverSNI type = DataStore.serverNetwork host = DataStore.serverHost path = DataStore.serverPath encryption = when (val security = DataStore.serverEncryption) { "ss" -> { "ss;" + DataStore.serverMethod + ":" + DataStore.serverPassword1 } else -> { security } } plugin = DataStore.serverPlugin } override fun onAttachedToWindow() { super.onAttachedToWindow() receiver = listenForPackageChanges(false) { lifecycleScope.launch(Dispatchers.Main) { // wait until changes were flushed whenCreated { initPlugins() } } } } 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 } plugin = findPreference(Key.SERVER_PLUGIN)!! pluginConfigure = findPreference(Key.SERVER_PLUGIN_CONFIGURE)!! pluginConfigure.setOnBindEditTextListener(EditTextPreferenceModifiers.Monospace) pluginConfigure.onPreferenceChangeListener = this@TrojanGoSettingsActivity pluginConfiguration = PluginConfiguration(DataStore.serverPlugin) initPlugins() } 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 } } } override fun PreferenceFragmentCompat.viewCreated(view: View, savedInstanceState: Bundle?) { setFragmentResultListener(PluginPreferenceDialogFragment::class.java.name) { _, bundle -> val selected = plugin.plugins.lookup.getValue( bundle.getString(PluginPreferenceDialogFragment.KEY_SELECTED_ID)!!) val override = pluginConfiguration.pluginsOptions.keys.firstOrNull { plugin.plugins.lookup[it] == selected } pluginConfiguration = PluginConfiguration(pluginConfiguration.pluginsOptions, override ?: selected.id) DataStore.serverPlugin = pluginConfiguration.toString() DataStore.dirty = true plugin.value = pluginConfiguration.selected pluginConfigure.isEnabled = selected !is NoPlugin pluginConfigure.text = pluginConfiguration.getOptions().toString() if (!selected.trusted) { Snackbar.make(requireView(), R.string.plugin_untrusted, Snackbar.LENGTH_LONG).show() } } AlertDialogFragment.setResultListener(this, UnsavedChangesDialogFragment::class.java.simpleName) { which, _ -> when (which) { DialogInterface.BUTTON_POSITIVE -> { runOnDefaultDispatcher { saveAndExit() } } DialogInterface.BUTTON_NEGATIVE -> requireActivity().finish() } } } private fun initPlugins() { plugin.value = pluginConfiguration.selected plugin.init(true) pluginConfigure.isEnabled = plugin.selectedEntry?.let { it is NoPlugin } == false pluginConfigure.text = pluginConfiguration.getOptions().toString() } private fun showPluginEditor() { PluginConfigurationDialogFragment().apply { setArg(Key.SERVER_PLUGIN_CONFIGURE, pluginConfiguration.selected) setTargetFragment(child, 0) }.showAllowingStateLoss(supportFragmentManager, Key.SERVER_PLUGIN_CONFIGURE) } override fun onPreferenceChange(preference: Preference?, newValue: Any?): Boolean = try { val selected = pluginConfiguration.selected pluginConfiguration = PluginConfiguration((pluginConfiguration.pluginsOptions + (pluginConfiguration.selected to PluginOptions(selected, newValue as? String?))).toMutableMap(), selected) DataStore.serverPlugin = pluginConfiguration.toString() DataStore.dirty = true true } catch (exc: RuntimeException) { Snackbar.make(child.requireView(), exc.readableMessage, Snackbar.LENGTH_LONG).show() false } private val configurePlugin = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { (resultCode, data) -> when (resultCode) { Activity.RESULT_OK -> { val options = data?.getStringExtra(PluginContract.EXTRA_OPTIONS) pluginConfigure.text = options onPreferenceChange(null, options) } PluginContract.RESULT_FALLBACK -> showPluginEditor() } } override fun PreferenceFragmentCompat.displayPreferenceDialog(preference: Preference): Boolean { when (preference.key) { Key.SERVER_PLUGIN -> PluginPreferenceDialogFragment().apply { setArg(Key.SERVER_PLUGIN) setTargetFragment(child, 0) }.showAllowingStateLoss(supportFragmentManager, Key.SERVER_PLUGIN) Key.SERVER_PLUGIN_CONFIGURE -> { val intent = PluginManager.buildIntent(plugin.selectedEntry!!.id, PluginContract.ACTION_CONFIGURE) if (intent.resolveActivity(packageManager) == null) showPluginEditor() else { configurePlugin.launch(intent .putExtra(PluginContract.EXTRA_OPTIONS, pluginConfiguration.getOptions().toString())) } } else -> return false } return true } val pluginHelp = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { (resultCode, data) -> if (resultCode == Activity.RESULT_OK) MaterialAlertDialogBuilder(this) .setTitle("?") .setMessage(data?.getCharSequenceExtra(PluginContract.EXTRA_HELP_MESSAGE)) .show() } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ui/profile/TrojanSettingsActivity.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.SwitchPreference import com.takisoft.preferencex.PreferenceFragmentCompat import com.takisoft.preferencex.SimpleMenuPreference 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.TrojanBean import io.nekohasekai.sagernet.ktx.app class TrojanSettingsActivity : ProfileSettingsActivity() { override fun createEntity() = TrojanBean() override fun TrojanBean.init() { DataStore.profileName = name DataStore.serverAddress = serverAddress DataStore.serverPort = serverPort DataStore.serverPassword = password DataStore.serverSecurity = security DataStore.serverSNI = sni DataStore.serverALPN = alpn DataStore.serverFlow = flow DataStore.serverAllowInsecure = allowInsecure } override fun TrojanBean.serialize() { name = DataStore.profileName serverAddress = DataStore.serverAddress serverPort = DataStore.serverPort password = DataStore.serverPassword security = DataStore.serverSecurity sni = DataStore.serverSNI alpn = DataStore.serverALPN flow = DataStore.serverFlow allowInsecure = DataStore.serverAllowInsecure } lateinit var security: SimpleMenuPreference lateinit var tlsSni: EditTextPreference lateinit var tlsAlpn: EditTextPreference lateinit var xtlsFlow: SimpleMenuPreference lateinit var allowInsecure: SwitchPreference override fun PreferenceFragmentCompat.createPreferences( savedInstanceState: Bundle?, rootKey: String?, ) { addPreferencesFromResource(R.xml.trojan_preferences) findPreference(Key.SERVER_PORT)!!.apply { setOnBindEditTextListener(EditTextPreferenceModifiers.Port) } findPreference(Key.SERVER_PASSWORD)!!.apply { summaryProvider = PasswordSummaryProvider } security = findPreference(Key.SERVER_SECURITY)!! tlsSni = findPreference(Key.SERVER_SNI)!! tlsAlpn = findPreference(Key.SERVER_ALPN)!! xtlsFlow = findPreference(Key.SERVER_FLOW)!! allowInsecure = findPreference(Key.SERVER_ALLOW_INSECURE)!! updateTle(security.value) security.setOnPreferenceChangeListener { _, newValue -> updateTle(newValue as String) true } } val xtlsFlowValue = app.resources.getStringArray(R.array.xtls_flow_value) fun updateTle(tle: String) { when (tle) { "tls" -> { xtlsFlow.isVisible = false } "xtls" -> { xtlsFlow.isVisible = true if (DataStore.serverFlow !in xtlsFlowValue) { xtlsFlow.value = xtlsFlowValue[0] } else { xtlsFlow.value = DataStore.serverFlow } } } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ui/profile/VLESSSettingsActivity.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 io.nekohasekai.sagernet.fmt.v2ray.VLESSBean class VLESSSettingsActivity : StandardV2RaySettingsActivity() { override fun createEntity() = VLESSBean() } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ui/profile/VMessSettingsActivity.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 io.nekohasekai.sagernet.fmt.v2ray.VMessBean class VMessSettingsActivity : StandardV2RaySettingsActivity() { override fun createEntity() = VMessBean() } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/ui/profile/WireGuardSettingsActivity.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 com.takisoft.preferencex.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.wireguard.WireGuardBean class WireGuardSettingsActivity : ProfileSettingsActivity() { override fun createEntity() = WireGuardBean() override fun WireGuardBean.init() { DataStore.profileName = name DataStore.serverLocalAddress = localAddress DataStore.serverPrivateKey = privateKey DataStore.serverAddress = serverAddress DataStore.serverPort = serverPort DataStore.serverCertificates = peerPublicKey DataStore.serverPassword = peerPreSharedKey } override fun WireGuardBean.serialize() { name = DataStore.profileName localAddress = DataStore.serverLocalAddress privateKey = DataStore.serverPrivateKey serverAddress = DataStore.serverAddress serverPort = DataStore.serverPort peerPublicKey = DataStore.serverCertificates peerPreSharedKey = DataStore.serverPassword } override fun PreferenceFragmentCompat.createPreferences( savedInstanceState: Bundle?, rootKey: String?, ) { addPreferencesFromResource(R.xml.wireguard_preferences) findPreference(Key.SERVER_PORT)!!.apply { setOnBindEditTextListener(EditTextPreferenceModifiers.Port) } findPreference(Key.SERVER_PASSWORD)!!.apply { summaryProvider = PasswordSummaryProvider } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/utils/Cloudflare.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.utils import com.wireguard.crypto.KeyPair import io.nekohasekai.sagernet.fmt.gson.gson import io.nekohasekai.sagernet.fmt.wireguard.WireGuardBean import io.nekohasekai.sagernet.ktx.createProxyClient import io.nekohasekai.sagernet.utils.cf.DeviceResponse import io.nekohasekai.sagernet.utils.cf.RegisterRequest import io.nekohasekai.sagernet.utils.cf.UpdateDeviceRequest import okhttp3.MediaType.Companion.toMediaType import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.internal.closeQuietly // kang from wgcf object Cloudflare { private const val API_URL = "https://api.cloudflareclient.com" private const val API_VERSION = "v0a1922" private const val CLIENT_VERSION_KEY = "CF-Client-Version" private const val CLIENT_VERSION = "a-6.3-1922" fun makeWireGuardConfiguration(): WireGuardBean { val keyPair = KeyPair() val okhttpClient = createProxyClient() var body = RegisterRequest.newRequest(keyPair.publicKey) var response = okhttpClient.newCall( Request.Builder() .url("$API_URL/$API_VERSION/reg") .header("Accept", "application/json") .header(CLIENT_VERSION_KEY, CLIENT_VERSION) .post(body.toRequestBody("application/json".toMediaType())) .build() ).execute() if (!response.isSuccessful) error(response) val device = gson.fromJson(response.body!!.string(), DeviceResponse::class.java) val accessToken = device.token body = UpdateDeviceRequest.newRequest() response = okhttpClient.newCall( Request.Builder() .url(API_URL + "/" + API_VERSION + "/reg/" + device.id + "/account/reg/" + device.id) .header("Authorization", "Bearer $accessToken") .header("Accept", "application/json") .header(CLIENT_VERSION_KEY, CLIENT_VERSION) .patch(body.toRequestBody("application/json".toMediaType())) .build() ).execute() try { if (!response.isSuccessful) error(response) val peer = device.config.peers[0] val localAddresses = device.config.interfaceX.addresses return WireGuardBean().apply { name = "CloudFlare Warp ${device.account.id}" privateKey = keyPair.privateKey.toBase64() peerPublicKey = peer.publicKey serverAddress = peer.endpoint.host.substringBeforeLast(":") serverPort = peer.endpoint.host.substringAfterLast(":").toInt() localAddress = localAddresses.v4 + "\n" + localAddresses.v6 } } finally { response.body?.closeQuietly() } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/utils/Commandline.kt ================================================ /****************************************************************************** * * * Copyright (C) 2021 by nekohasekai * * Copyright (C) 2021 by Max Lv * * Copyright (C) 2021 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.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 ================================================ /****************************************************************************** * 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.utils import android.annotation.SuppressLint import android.content.Intent import android.os.Build import androidx.core.content.FileProvider import com.jakewharton.processphoenix.ProcessPhoenix import io.nekohasekai.sagernet.BuildConfig import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.database.preference.PublicDatabase import io.nekohasekai.sagernet.ktx.Logs import io.nekohasekai.sagernet.ktx.app import io.nekohasekai.sagernet.ktx.use import java.io.* 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) { val logFile = File.createTempFile("AnXray Crash Report ", ".log", File(app.cacheDir, "log").also { it.mkdirs() } ) var report = buildReportHeader() report += "\n" report += "Thread: $thread\n\n" report += formatThrowable(throwable) + "\n\n" report += "Logcat: \n\n" logFile.writeText(report) try { Runtime.getRuntime().exec(arrayOf("logcat", "-d")).inputStream.use( FileOutputStream( logFile, true ) ) } catch (e: IOException) { Logs.w(e) logFile.appendText("Export logcat error: " + formatThrowable(e)) } ProcessPhoenix.triggerRebirth( app, Intent.createChooser( Intent(Intent.ACTION_SEND).setType("text/x-log") .setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) .putExtra( Intent.EXTRA_STREAM, FileProvider.getUriForFile( app, BuildConfig.APPLICATION_ID + ".log", logFile ) ), app.getString(R.string.abc_shareactionprovider_share_with) ) ) } 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 += "SagerNet ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE}) ${BuildConfig.FLAVOR.uppercase()}\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 ================================================ /****************************************************************************** * * * Copyright (C) 2021 by nekohasekai * * Copyright (C) 2021 by Max Lv * * Copyright (C) 2021 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.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/DeviceStorageApp.kt ================================================ /****************************************************************************** * * * Copyright (C) 2021 by nekohasekai * * Copyright (C) 2021 by Max Lv * * Copyright (C) 2021 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.utils import android.annotation.SuppressLint import android.annotation.TargetApi import android.app.Application import android.content.Context @SuppressLint("Registered") @TargetApi(24) class DeviceStorageApp(context: Context) : Application() { init { attachBaseContext(context.createDeviceProtectedStorageContext()) } /** * Thou shalt not get the REAL underlying application context which would no longer be operating under device * protected storage. */ override fun getApplicationContext() = this } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/utils/DirectBoot.kt ================================================ /****************************************************************************** * * * Copyright (C) 2021 by nekohasekai * * Copyright (C) 2021 by Max Lv * * Copyright (C) 2021 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.utils import android.annotation.TargetApi import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter import io.nekohasekai.sagernet.SagerNet import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.database.ProfileManager import io.nekohasekai.sagernet.database.ProxyEntity import io.nekohasekai.sagernet.ktx.app import io.nekohasekai.sagernet.ktx.marshall import io.nekohasekai.sagernet.ktx.unmarshall import kotlinx.coroutines.runBlocking import java.io.File import java.io.IOException @TargetApi(24) object DirectBoot : BroadcastReceiver() { private val file = File(SagerNet.deviceStorage.noBackupFilesDir, "directBootProfile") private var registered = false fun getDeviceProfile(): ProxyEntity? = try { file.readBytes().unmarshall(::ProxyEntity) } catch (_: IOException) { null } fun clean() { file.delete() // File(SagerNet.deviceStorage.noBackupFilesDir, BaseService.CONFIG_FILE).delete() } /** * app.currentProfile will call this. */ fun update(profile: ProxyEntity? = ProfileManager.getProfile(DataStore.selectedProxy)) = if (profile == null) clean() else file.writeBytes(profile.marshall()) fun flushTrafficStats() { getDeviceProfile()?.also { runBlocking { if (it.dirty) ProfileManager.updateProfile(it) } } update() } fun listenForUnlock() { if (registered) return app.registerReceiver(this, IntentFilter(Intent.ACTION_BOOT_COMPLETED)) registered = true } override fun onReceive(context: Context, intent: Intent) { flushTrafficStats() app.unregisterReceiver(this) registered = false } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/utils/HttpsTest.kt ================================================ /****************************************************************************** * * * Copyright (C) 2021 by nekohasekai * * Copyright (C) 2021 by Max Lv * * Copyright (C) 2021 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.utils import android.os.SystemClock import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.ktx.* import kotlinx.coroutines.delay import okhttp3.Call import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.internal.closeQuietly import java.io.IOException /** * Based on: https://android.googlesource.com/platform/frameworks/base/+/b19a838/services/core/java/com/android/server/connectivity/NetworkMonitor.java#1071 */ class HttpsTest : ViewModel() { sealed class Status { protected abstract val status: CharSequence open fun retrieve(setStatus: (CharSequence) -> Unit, errorCallback: (String) -> Unit) = setStatus(status) object Idle : Status() { override val status get() = app.getText(R.string.vpn_connected) } object Testing : Status() { override val status get() = app.getText(R.string.connection_test_testing) } class Success(private val elapsed: Long) : Status() { override val status get() = app.getString( if (DataStore.connectionTestURL.startsWith("https://")) { R.string.connection_test_available } else { R.string.connection_test_available_http }, elapsed ) } sealed class Error : Status() { override val status get() = app.getText(R.string.connection_test_fail) protected abstract val error: String private var shown = false override fun retrieve( setStatus: (CharSequence) -> Unit, errorCallback: (String) -> Unit, ) { super.retrieve(setStatus, errorCallback) if (shown) return shown = true errorCallback(error) } class UnexpectedResponseCode(private val code: Int) : Error() { override val error get() = app.getString( R.string.connection_test_error_status_code, code ) } class IOFailure(private val e: IOException) : Error() { override val error get() = app.getString(R.string.connection_test_error, e.message) } } } private var running: Call? = null val status = MutableLiveData(Status.Idle) val okHttpClient by lazy { OkHttpClient.Builder().proxy(requireProxy()).build() } fun testConnection() { cancelTest() status.value = Status.Testing runOnDefaultDispatcher { val start = SystemClock.elapsedRealtime() running = okHttpClient.newCall( Request.Builder() .url(DataStore.connectionTestURL) .addHeader("Connection", "close") .addHeader("User-Agent", USER_AGENT) .build() ).apply { val response = try { execute() } catch (e: IOException) { if (e.readableMessage.contains("failed to connect to /127.0.0.1") && e.readableMessage.contains( "ECONNREFUSED" ) ) { delay(1000L) onMainDispatcher { testConnection() } return@runOnDefaultDispatcher } if (!isCanceled()) { onMainDispatcher { status.value = Status.Error.IOFailure(e) running = null } } return@runOnDefaultDispatcher } if (isCanceled()) { return@runOnDefaultDispatcher } val code = response.code val elapsed = SystemClock.elapsedRealtime() - start response.closeQuietly() runOnMainDispatcher { status.value = if (code == 204 || code == 200) { Status.Success(elapsed) } else { Status.Error.UnexpectedResponseCode(code) } running = null } } } } private fun cancelTest() { running?.cancel() running = null } fun invalidate() { cancelTest() status.value = Status.Idle } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/utils/PackageCache.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.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 object PackageCache { lateinit var installedPackages: Map lateinit var installedApps: Map lateinit var packageMap: Map val uidMap = HashMap>() val loaded = Mutex(true) fun register() { reload() app.listenForPackageChanges(false) { reload() labelMap.clear() } loaded.unlock() } @SuppressLint("InlinedApi") fun reload() { installedPackages = app.packageManager.getInstalledPackages(PackageManager.GET_PERMISSIONS or PackageManager.MATCH_UNINSTALLED_PACKAGES) .filter { when (it.packageName) { "android" -> true else -> it.requestedPermissions?.contains(Manifest.permission.INTERNET) == true } } .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] suspend fun awaitLoad() { if (::packageMap.isInitialized) { return } loaded.withLock { // just await } } fun awaitLoadSync() { if (::packageMap.isInitialized) { 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 ================================================ /****************************************************************************** * * * Copyright (C) 2021 by nekohasekai * * Copyright (C) 2021 by Max Lv * * Copyright (C) 2021 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.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 ================================================ /****************************************************************************** * * * 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.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 import io.nekohasekai.sagernet.ktx.isExpert object Theme { const val RED = 1 const val PINK = 2 const val PURPLE = 3 const val DEEP_PURPLE = 4 const val INDIGO = 5 const val BLUE = 6 const val LIGHT_BLUE = 7 const val CYAN = 8 const val TEAL = 9 const val GREEN = 10 const val LIGHT_GREEN = 11 const val LIME = 12 const val YELLOW = 13 const val AMBER = 14 const val ORANGE = 15 const val DEEP_ORANGE = 16 const val BROWN = 17 const val GREY = 18 const val BLUE_GREY = 19 const val BLACK = 20 private fun defaultTheme() = BLACK fun apply(context: Context) { context.setTheme(getTheme()) } fun getTheme(): Int { return getTheme(if (isExpert) DataStore.appTheme else defaultTheme()) } fun getTheme(theme: Int): Int { return when (theme) { RED -> R.style.Theme_SagerNet_Red PINK -> R.style.Theme_SagerNet 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 -> if (usingNightMode()) R.style.Theme_SagerNet_Black else R.style.Theme_SagerNet_LightBlack else -> getTheme(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/utils/cf/DeviceResponse.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.utils.cf import com.google.gson.annotations.SerializedName data class DeviceResponse( @SerializedName("created") var created: String = "", @SerializedName("type") var type: String = "", @SerializedName("locale") var locale: String = "", @SerializedName("enabled") var enabled: Boolean = false, @SerializedName("token") var token: String = "", @SerializedName("waitlist_enabled") var waitlistEnabled: Boolean = false, @SerializedName("install_id") var installId: String = "", @SerializedName("warp_enabled") var warpEnabled: Boolean = false, @SerializedName("name") var name: String = "", @SerializedName("fcm_token") var fcmToken: String = "", @SerializedName("tos") var tos: String = "", @SerializedName("model") var model: String = "", @SerializedName("id") var id: String = "", @SerializedName("place") var place: Int = 0, @SerializedName("config") var config: Config = Config(), @SerializedName("updated") var updated: String = "", @SerializedName("key") var key: String = "", @SerializedName("account") var account: Account = Account() ) { data class Config( @SerializedName("peers") var peers: List = listOf(), @SerializedName("services") var services: Services = Services(), @SerializedName("interface") var interfaceX: Interface = Interface(), @SerializedName("client_id") var clientId: String = "" ) { data class Peer( @SerializedName("public_key") var publicKey: String = "", @SerializedName("endpoint") var endpoint: Endpoint = Endpoint() ) { data class Endpoint( @SerializedName("v6") var v6: String = "", @SerializedName("host") var host: String = "", @SerializedName("v4") var v4: String = "" ) } data class Services( @SerializedName("http_proxy") var httpProxy: String = "" ) data class Interface( @SerializedName("addresses") var addresses: Addresses = Addresses() ) { data class Addresses( @SerializedName("v6") var v6: String = "", @SerializedName("v4") var v4: String = "" ) } } data class Account( @SerializedName("account_type") var accountType: String = "", @SerializedName("role") var role: String = "", @SerializedName("referral_renewal_countdown") var referralRenewalCountdown: Int = 0, @SerializedName("created") var created: String = "", @SerializedName("usage") var usage: Int = 0, @SerializedName("warp_plus") var warpPlus: Boolean = false, @SerializedName("referral_count") var referralCount: Int = 0, @SerializedName("license") var license: String = "", @SerializedName("quota") var quota: Int = 0, @SerializedName("premium_data") var premiumData: Int = 0, @SerializedName("id") var id: String = "", @SerializedName("updated") var updated: String = "" ) } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/utils/cf/RegisterRequest.kt ================================================ /****************************************************************************** * Copyright (C) 2021 by nekohasekai @sekai.icu> * * * * 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 //www.gnu.org/licenses/>. * * * */ package io.nekohasekai.sagernet.utils.cf import com.google.gson.Gson import com.google.gson.annotations.SerializedName import com.wireguard.crypto.Key import java.text.SimpleDateFormat import java.util.* data class RegisterRequest( @SerializedName("fcm_token") var fcmToken: String = "", @SerializedName("install_id") var installedId: String = "", var key: String = "", var locale: String = "", var model: String = "", var tos: String = "", var type: String = "" ) { companion object { fun newRequest(publicKey: Key): String { val request = RegisterRequest() request.fcmToken = "" request.installedId = "" request.key = publicKey.toBase64() request.locale = "en_US" request.model = "PC" val format = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'000000'+08:00", Locale.US) request.tos = format.format(Date()) request.type = "Android" return Gson().toJson(request) } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/utils/cf/UpdateDeviceRequest.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.utils.cf import com.google.gson.Gson data class UpdateDeviceRequest( var name: String, var active: Boolean ) { companion object { fun newRequest(name: String = "SagerNet Client", active: Boolean = true) = Gson().toJson(UpdateDeviceRequest(name, active)) } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/widget/AppListPreference.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.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) ?: 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 ================================================ /****************************************************************************** * * * Copyright (C) 2021 by nekohasekai * * Copyright (C) 2021 by Max Lv * * Copyright (C) 2021 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.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/ColorPickerPreference.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.widget import android.annotation.SuppressLint import android.content.Context import android.os.Parcel import android.os.Parcelable import android.os.Parcelable.Creator import android.util.AttributeSet import android.widget.ImageView import androidx.core.content.ContextCompat import androidx.core.content.res.TypedArrayUtils import androidx.preference.DialogPreference import androidx.preference.PreferenceViewHolder import com.takisoft.colorpicker.ColorPickerDialog import com.takisoft.colorpicker.ColorStateDrawable import com.takisoft.preferencex.PreferenceFragmentCompat import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.ktx.app class ColorPickerPreference @JvmOverloads constructor( context: Context, attrs: AttributeSet?, defStyleAttr: Int, defStyleRes: Int = 0 ) : DialogPreference(context, attrs, defStyleAttr, defStyleRes) { companion object { init { PreferenceFragmentCompat.registerPreferenceFragment( ColorPickerPreference::class.java, ColorPickerPreferenceDialogFragmentCompat::class.java ) } } init { widgetLayoutResource = R.layout.preference_widget_color_swatch } val colors = app.resources.getIntArray(R.array.material_colors) lateinit var colorDescriptions: Array private var colorIndex = 0 var columns = 0 @get:ColorPickerDialog.Size var size = 0 var isSortColors = false private var colorWidget: ImageView? = null @SuppressLint("RestrictedApi") constructor(context: Context, attrs: AttributeSet?) : this( context, attrs, TypedArrayUtils.getAttr( context, R.attr.dialogPreferenceStyle, android.R.attr.dialogPreferenceStyle ) ) constructor(context: Context) : this(context, null) override fun onBindViewHolder(holder: PreferenceViewHolder) { super.onBindViewHolder(holder) colorWidget = holder.findViewById(R.id.color_picker_widget) as ImageView setColorOnWidget(colors[colorIndex]) } private fun setColorOnWidget(color: Int) { if (colorWidget == null) { return } val colorDrawable = arrayOf( ContextCompat.getDrawable( context, R.drawable.colorpickerpreference_pref_swatch ) ) colorWidget!!.setImageDrawable(ColorStateDrawable(colorDrawable, color)) } /** * Returns the current color. * * @return The current color. */ fun getColor(): Int { return colorIndex } fun setColor(colorIndex: Int) { setInternalColor(colors.indexOfFirst { it == colorIndex }, false) } private fun setInternalColor(colorIndexToSet: Int, force: Boolean) { val colorIndex = if (colorIndexToSet >= colors.size || colorIndexToSet < 0) colors.size - 1 else colorIndexToSet val oldColor = getPersistedInt(colors.size) - 1 val changed = oldColor != colorIndex if (changed || force) { this.colorIndex = colorIndex persistInt(colorIndex + 1) setColorOnWidget(colors[colorIndex]) notifyChanged() } } override fun onSetInitialValue(defaultValueObj: Any?) { setInternalColor( getPersistedInt( colors.size ) - 1, true ) } override fun onSaveInstanceState(): Parcelable { val superState = super.onSaveInstanceState() if (isPersistent) { // No need to save instance state since it's persistent return superState } val myState = SavedState(superState) myState.color = colorIndex return myState } override fun onRestoreInstanceState(state: Parcelable) { if (state.javaClass != SavedState::class.java) { // Didn't save state for us in onSaveInstanceState super.onRestoreInstanceState(state) return } val myState = state as SavedState super.onRestoreInstanceState(myState.superState) colorIndex = myState.color } private class SavedState : BaseSavedState { var color = 0 constructor(source: Parcel) : super(source) { color = source.readInt() } constructor(superState: Parcelable?) : super(superState) {} override fun writeToParcel(dest: Parcel, flags: Int) { super.writeToParcel(dest, flags) dest.writeInt(color) } companion object { @JvmField val CREATOR: Creator = object : Creator { override fun createFromParcel(`in`: Parcel): SavedState { return SavedState(`in`) } override fun newArray(size: Int): Array { return arrayOfNulls(size) } } } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/widget/ColorPickerPreferenceDialogFragmentCompat.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.widget import android.app.Dialog import android.content.DialogInterface import android.os.Bundle import androidx.preference.PreferenceDialogFragmentCompat import com.takisoft.colorpicker.ColorPickerDialog import com.takisoft.colorpicker.OnColorSelectedListener class ColorPickerPreferenceDialogFragmentCompat : PreferenceDialogFragmentCompat(), OnColorSelectedListener { private var pickedColor = 0 override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { val pref = colorPickerPreference val params = ColorPickerDialog.Params.Builder(context) .setSelectedColor(pref.getColor()) .setColors(pref.colors) // .setColorContentDescriptions(pref.colorDescriptions) .setSize(pref.size) .setSortColors(pref.isSortColors) .setColumns(pref.columns) .build() val dialog = ColorPickerDialog(requireContext(), this, params) dialog.setTitle(pref.dialogTitle) return dialog } override fun onDialogClosed(positiveResult: Boolean) { val preference = colorPickerPreference if (positiveResult && preference.callChangeListener(pickedColor)) { preference.setColor(pickedColor) } } override fun onColorSelected(color: Int) { pickedColor = color super.onClick(dialog, DialogInterface.BUTTON_POSITIVE) } val colorPickerPreference: ColorPickerPreference get() = preference as ColorPickerPreference } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/widget/EditConfigPreference.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.widget import android.content.Context import android.content.Intent 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.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) } override fun getSummary(): CharSequence { val config = DataStore.serverConfig return if (DataStore.serverConfig.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/io/nekohasekai/sagernet/widget/FabProgressBehavior.kt ================================================ /****************************************************************************** * * * Copyright (C) 2021 by nekohasekai * * Copyright (C) 2021 by Max Lv * * Copyright (C) 2021 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.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 ================================================ /****************************************************************************** * * * 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.widget import android.content.Context import android.util.AttributeSet import com.takisoft.preferencex.SimpleMenuPreference import io.nekohasekai.sagernet.database.SagerDatabase class GroupPreference : SimpleMenuPreference { 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) init { val groups = SagerDatabase.groupDao.allGroups() entries = groups.map { it.displayName() }.toTypedArray() entryValues = groups.map { "${it.id}" }.toTypedArray() } override fun getSummary(): CharSequence { if (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 ================================================ /****************************************************************************** * * * 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.widget import android.content.Context import android.net.Uri import android.util.AttributeSet import androidx.core.widget.addTextChangedListener import com.google.android.material.textfield.TextInputLayout import com.takisoft.preferencex.EditTextPreference import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.ktx.app import io.nekohasekai.sagernet.ktx.readableMessage import okhttp3.HttpUrl.Companion.toHttpUrl class LinkOrContentPreference : EditTextPreference { constructor(context: Context?) : super(context) constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super( context, attrs, defStyleAttr ) constructor( context: Context?, attrs: AttributeSet?, defStyleAttr: Int, defStyleRes: Int ) : super(context, attrs, defStyleAttr, defStyleRes) init { dialogLayoutResource = R.layout.layout_link_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 } } catch (e: Exception) { linkLayout.error = e.readableMessage linkLayout.isErrorEnabled = true } } validate() it.addTextChangedListener { validate() } } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/widget/LinkPreference.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.widget import android.content.Context import android.util.AttributeSet import androidx.core.widget.addTextChangedListener import com.google.android.material.textfield.TextInputLayout import com.takisoft.preferencex.EditTextPreference import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.ktx.app import io.nekohasekai.sagernet.ktx.readableMessage import okhttp3.HttpUrl.Companion.toHttpUrl class LinkPreference : EditTextPreference { var defaultValue: String? = null constructor(context: Context) : this(context, null) constructor( context: Context, attrs: AttributeSet?, ) : this(context, attrs, com.takisoft.preferencex.R.attr.editTextPreferenceStyle) constructor( context: Context, attrs: AttributeSet?, defStyleAttr: Int, ) : this(context, attrs, defStyleAttr, 0) constructor( context: Context, attrs: AttributeSet?, defStyleAttr: Int, defStyleRes: Int, ) : super(context, attrs, defStyleAttr, defStyleRes) { val a = context.obtainStyledAttributes( attrs, R.styleable.Preference, defStyleAttr, defStyleRes ) if (a.hasValue(androidx.preference.R.styleable.Preference_defaultValue)) { defaultValue = onGetDefaultValue( a, androidx.preference.R.styleable.Preference_defaultValue )?.toString() } else if (a.hasValue(androidx.preference.R.styleable.Preference_android_defaultValue)) { defaultValue = onGetDefaultValue( a, androidx.preference.R.styleable.Preference_android_defaultValue )?.toString() } } init { dialogLayoutResource = R.layout.layout_link_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 { 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 } } catch (e: Exception) { linkLayout.error = e.readableMessage linkLayout.isErrorEnabled = true } } validate() it.addTextChangedListener { validate() } } setOnPreferenceChangeListener { _, newValue -> if ((newValue as String).isBlank()) { text = defaultValue false } else try { newValue.toHttpUrl() true } catch (ignored: Exception) { false } } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/widget/OOCv1TokenPreference.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.widget import android.content.Context import android.util.AttributeSet import androidx.core.widget.addTextChangedListener import cn.hutool.core.util.CharUtil import cn.hutool.json.JSONObject import com.google.android.material.textfield.TextInputLayout import com.takisoft.preferencex.EditTextPreference import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.ktx.readableMessage import okhttp3.HttpUrl.Companion.toHttpUrl class OOCv1TokenPreference : EditTextPreference { constructor(context: Context?) : super(context) constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) constructor( context: Context?, attrs: AttributeSet?, defStyleAttr: Int ) : super(context, attrs, defStyleAttr) constructor( context: Context?, attrs: AttributeSet?, defStyleAttr: Int, defStyleRes: Int ) : super(context, attrs, defStyleAttr, defStyleRes) init { dialogLayoutResource = R.layout.layout_link_dialog setOnBindEditTextListener { editText -> editText.isSingleLine = false val linkLayout = editText.rootView.findViewById(R.id.input_layout) fun validate() { if (editText.text.isBlank()) { linkLayout.isErrorEnabled = false return } var isValid = true try { val tokenObject = JSONObject(editText.text) val version = tokenObject.getInt("version") if (version != 1) { isValid = false if (version != null) { linkLayout.error = "Unsupported OOC version $version" } else { linkLayout.error = "Missing field: version" } } if (isValid) { val baseUrl = tokenObject.getStr("baseUrl") when { baseUrl.isNullOrBlank() -> { linkLayout.error = "Missing field: baseUrl" isValid = false } baseUrl.endsWith("/") -> { linkLayout.error = "baseUrl must not contain a trailing slash" isValid = false } !baseUrl.startsWith("https://") -> { isValid = false linkLayout.error = "Protocol scheme must be https" } else -> try { baseUrl.toHttpUrl() } catch (e: Exception) { isValid = false linkLayout.error = e.readableMessage } } } if (isValid && tokenObject.getStr("secret").isNullOrBlank()) { isValid = false linkLayout.error = "Missing field: secret" } if (isValid && tokenObject.getStr("userId").isNullOrBlank()) { isValid = false linkLayout.error = "Missing field: userId" } if (isValid) { val certSha256 = tokenObject.getStr("certSha256") if (!certSha256.isNullOrBlank()) { when { certSha256.length != 64 -> { isValid = false linkLayout.error = "certSha256 must be a SHA-256 hexadecimal string" } !certSha256.all { CharUtil.isLetterLower(it) || CharUtil.isNumber(it) } -> { isValid = false linkLayout.error = "certSha256 must be a hexadecimal string with lowercase letters" } } } } } catch (e: Exception) { isValid = false linkLayout.error = e.readableMessage } linkLayout.isErrorEnabled = !isValid } validate() editText.addTextChangedListener { validate() } } } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/widget/OutboundPreference.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.widget import android.content.Context import android.util.AttributeSet import com.takisoft.preferencex.SimpleMenuPreference import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.database.ProfileManager class OutboundPreference : SimpleMenuPreference { 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) init { setEntries(R.array.outbound_entry) setEntryValues(R.array.outbound_value) } override fun getSummary(): CharSequence { if (value == "3") { val routeOutbound = DataStore.routeOutboundRule if (routeOutbound > 0) { ProfileManager.getProfile(routeOutbound)?.displayName()?.let { return it } } } return super.getSummary() } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/widget/QRCodeDialog.kt ================================================ /****************************************************************************** * * * Copyright (C) 2021 by nekohasekai * * Copyright (C) 2021 by Max Lv * * Copyright (C) 2021 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.widget import android.graphics.Bitmap import android.graphics.Color import android.os.Bundle import android.view.LayoutInflater import android.view.ViewGroup import android.widget.ImageView 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 class QRCodeDialog() : DialogFragment() { companion object { private const val KEY_URL = "io.nekohasekai.sagernet.QRCodeDialog.KEY_URL" private val iso88591 = StandardCharsets.ISO_8859_1.newEncoder() } constructor(url: String) : this() { arguments = bundleOf(Pair(KEY_URL, url)) } /** * 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 { val url = arguments?.getString(KEY_URL)!! val size = resources.getDimensionPixelSize(R.dimen.qrcode_size) 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) ImageView(context).apply { layoutParams = ViewGroup.LayoutParams(size, size) 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) } }) } } 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 ================================================ /****************************************************************************** * * * Copyright (C) 2021 by nekohasekai * * Copyright (C) 2021 by Max Lv * * Copyright (C) 2021 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.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 com.google.android.material.progressindicator.DeterminateDrawable import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.bg.BaseService import io.nekohasekai.sagernet.ktx.getColorAttr 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 { companion object { private val springAnimator by lazy { DeterminateDrawable::class.java.getDeclaredField("springAnimator") .apply { isAccessible = true } } } 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) setColorFilter(context.getColorAttr(R.attr.whiteOrTextPrimary)) 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) + 100L) 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 (springAnimator.get(progress.progressDrawable) as DynamicAnimation<*>).addEndListener(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 ================================================ /****************************************************************************** * * * Copyright (C) 2021 by nekohasekai * * Copyright (C) 2021 by Max Lv * * Copyright (C) 2021 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.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 override fun getBehavior(): YourBehavior { if (!this::behavior.isInitialized) behavior = YourBehavior() return behavior } class YourBehavior : 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 ) } var hide = false override fun slideUp(child: BottomAppBar) { hide = false super.slideUp(child) } override fun slideDown(child: BottomAppBar) { hide = true 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 { performShow() setStatus(app.getText(R.string.vpn_connected)) } } else { postWhenStarted { performHide() } updateTraffic(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 updateTraffic(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) 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 ================================================ /****************************************************************************** * * * Copyright (C) 2021 by nekohasekai * * Copyright (C) 2021 by Max Lv * * Copyright (C) 2021 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.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 ================================================ /****************************************************************************** * * * 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.widget import android.content.Context import android.util.AttributeSet import com.takisoft.preferencex.EditTextPreference import io.nekohasekai.sagernet.ktx.USER_AGENT import io.nekohasekai.sagernet.ktx.USER_AGENT_ORIGIN class UserAgentPreference : EditTextPreference { 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) var isOOCv1 = false public override fun notifyChanged() { super.notifyChanged() } override fun getSummary(): CharSequence { if (text.isNullOrBlank()) { return if (isOOCv1) USER_AGENT_ORIGIN else USER_AGENT } return super.getSummary() } } ================================================ FILE: app/src/main/java/io/nekohasekai/sagernet/widget/WindowInsetsListeners.kt ================================================ /****************************************************************************** * * * Copyright (C) 2021 by nekohasekai * * Copyright (C) 2021 by Max Lv * * Copyright (C) 2021 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.widget import android.view.View import androidx.appcompat.app.AppCompatActivity import androidx.core.graphics.Insets import androidx.core.view.* import io.nekohasekai.sagernet.R object ListHolderListener : OnApplyWindowInsetsListener { override fun onApplyWindowInsets(view: View, insets: WindowInsetsCompat): WindowInsetsCompat { val statusBarInsets = insets.getInsets(WindowInsetsCompat.Type.statusBars()) view.setPadding(statusBarInsets.left, statusBarInsets.top, statusBarInsets.right, statusBarInsets.bottom) return WindowInsetsCompat.Builder(insets).apply { setInsets(WindowInsetsCompat.Type.statusBars(), Insets.NONE) /*setInsets(WindowInsetsCompat.Type.navigationBars(), insets.getInsets(WindowInsetsCompat.Type.navigationBars()))*/ }.build() } fun setup(activity: AppCompatActivity) = activity.findViewById(android.R.id.content).let { ViewCompat.setOnApplyWindowInsetsListener(it, ListHolderListener) WindowCompat.setDecorFitsSystemWindows(activity.window, false) } } object MainListListener : OnApplyWindowInsetsListener { override fun onApplyWindowInsets(view: View, insets: WindowInsetsCompat) = insets.apply { view.updatePadding(bottom = view.resources.getDimensionPixelOffset(R.dimen.main_list_padding_bottom) + insets.getInsets(WindowInsetsCompat.Type.navigationBars()).bottom) } } 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/play/release-notes/en-US/default.txt ================================================ For changelog, please check messages in the telegram update channel ( https://t.me/AnXray ). ================================================ FILE: app/src/main/play/release-notes/zh-CN/default.txt ================================================ 由于字数限制, 请至 Telegram 更新频道 https://t.me/AnXray 查看日志. ================================================ 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_construction_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/baseline_delete_sweep_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_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_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_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_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_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_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_launcher_foreground.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_ax.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/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_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_chain_settings.xml ================================================ ================================================ FILE: app/src/main/res/layout/layout_cloudflare.xml ================================================