├── .gitignore ├── LICENSE ├── README.md ├── app ├── .gitignore ├── build.gradle ├── proguard-rules.pro ├── release.keystore ├── schemas │ ├── io.nekohasekai.sfa.database.ProfileDatabase │ │ └── 1.json │ └── io.nekohasekai.sfa.database.preference.KeyValueDatabase │ │ └── 1.json └── src │ ├── main │ ├── AndroidManifest.xml │ ├── aidl │ │ └── io │ │ │ └── nekohasekai │ │ │ └── sfa │ │ │ └── aidl │ │ │ ├── IService.aidl │ │ │ └── IServiceCallback.aidl │ ├── ic_launcher-playstore.png │ ├── java │ │ └── io │ │ │ └── nekohasekai │ │ │ └── sfa │ │ │ ├── Application.kt │ │ │ ├── bg │ │ │ ├── AppChangeReceiver.kt │ │ │ ├── BootReceiver.kt │ │ │ ├── BoxService.kt │ │ │ ├── DefaultNetworkListener.kt │ │ │ ├── DefaultNetworkMonitor.kt │ │ │ ├── LocalResolver.kt │ │ │ ├── PlatformInterfaceWrapper.kt │ │ │ ├── ProxyService.kt │ │ │ ├── ServiceBinder.kt │ │ │ ├── ServiceConnection.kt │ │ │ ├── ServiceNotification.kt │ │ │ ├── TileService.kt │ │ │ ├── UpdateProfileWork.kt │ │ │ └── VPNService.kt │ │ │ ├── constant │ │ │ ├── Action.kt │ │ │ ├── Alert.kt │ │ │ ├── Bugs.kt │ │ │ ├── EnabledType.kt │ │ │ ├── Path.kt │ │ │ ├── PerAppProxyUpdateType.kt │ │ │ ├── ServiceMode.kt │ │ │ ├── SettingsKey.kt │ │ │ └── Status.kt │ │ │ ├── database │ │ │ ├── Profile.kt │ │ │ ├── ProfileDatabase.kt │ │ │ ├── ProfileManager.kt │ │ │ ├── Settings.kt │ │ │ ├── TypedProfile.kt │ │ │ └── preference │ │ │ │ ├── KeyValueDatabase.kt │ │ │ │ ├── KeyValueEntity.kt │ │ │ │ ├── OnPreferenceDataStoreChangeListener.kt │ │ │ │ └── RoomPreferenceDataStore.kt │ │ │ ├── ktx │ │ │ ├── Browsers.kt │ │ │ ├── Clips.kt │ │ │ ├── Colors.kt │ │ │ ├── Context.kt │ │ │ ├── Continuations.kt │ │ │ ├── Dialogs.kt │ │ │ ├── Dimens.kt │ │ │ ├── Inputs.kt │ │ │ ├── Intents.kt │ │ │ ├── Preferences.kt │ │ │ ├── Room.kt │ │ │ ├── Shares.kt │ │ │ └── Wrappers.kt │ │ │ ├── ui │ │ │ ├── MainActivity.kt │ │ │ ├── ShortcutActivity.kt │ │ │ ├── dashboard │ │ │ │ ├── Groups.kt │ │ │ │ ├── GroupsFragment.kt │ │ │ │ └── OverviewFragment.kt │ │ │ ├── debug │ │ │ │ ├── DebugActivity.kt │ │ │ │ └── VPNScanActivity.kt │ │ │ ├── main │ │ │ │ ├── ConfigurationFragment.kt │ │ │ │ ├── DashboardFragment.kt │ │ │ │ ├── LogFragment.kt │ │ │ │ └── SettingsFragment.kt │ │ │ ├── profile │ │ │ │ ├── EditProfileActivity.kt │ │ │ │ ├── EditProfileContentActivity.kt │ │ │ │ ├── NewProfileActivity.kt │ │ │ │ ├── QRScanActivity.kt │ │ │ │ └── ZxingQRCodeAnalyzer.kt │ │ │ ├── profileoverride │ │ │ │ ├── PerAppProxyActivity.kt │ │ │ │ └── ProfileOverrideActivity.kt │ │ │ └── shared │ │ │ │ ├── AbstractActivity.kt │ │ │ │ └── QRCodeDialog.kt │ │ │ ├── utils │ │ │ ├── ColorUtils.kt │ │ │ ├── CommandClient.kt │ │ │ ├── HTTPClient.kt │ │ │ └── MIUIUtils.kt │ │ │ └── vendor │ │ │ └── VendorInterface.kt │ ├── play │ │ └── release-notes │ │ │ └── en-US │ │ │ └── beta.txt │ └── res │ │ ├── drawable │ │ ├── bg_rounded_rectangle.xml │ │ ├── bg_rounded_rectangle_active.xml │ │ ├── ic_arrow_back_24.xml │ │ ├── ic_baseline_create_new_folder_24.xml │ │ ├── ic_baseline_file_open_24.xml │ │ ├── ic_dashboard_black_24dp.xml │ │ ├── ic_delete_24.xml │ │ ├── ic_edit_24.xml │ │ ├── ic_electric_bolt_24.xml │ │ ├── ic_expand_less_24.xml │ │ ├── ic_expand_more_24.xml │ │ ├── ic_find_in_page_24.xml │ │ ├── ic_insert_drive_file_24.xml │ │ ├── ic_ios_share_24.xml │ │ ├── ic_launcher_foreground.xml │ │ ├── ic_menu.png │ │ ├── ic_message_24.xml │ │ ├── ic_more_vert_24.xml │ │ ├── ic_note_add_24.xml │ │ ├── ic_play_arrow_24.xml │ │ ├── ic_qr_code_2_24.xml │ │ ├── ic_settings_24.xml │ │ ├── ic_stop_24.xml │ │ └── ic_update_24.xml │ │ ├── layout │ │ ├── activity_add_profile.xml │ │ ├── activity_config_override.xml │ │ ├── activity_debug.xml │ │ ├── activity_edit_profile.xml │ │ ├── activity_edit_profile_content.xml │ │ ├── activity_main.xml │ │ ├── activity_per_app_proxy.xml │ │ ├── activity_qr_scan.xml │ │ ├── activity_vpn_scan.xml │ │ ├── dialog_progress.xml │ │ ├── dialog_progressbar.xml │ │ ├── fragment_configuration.xml │ │ ├── fragment_dashboard.xml │ │ ├── fragment_dashboard_groups.xml │ │ ├── fragment_dashboard_overview.xml │ │ ├── fragment_log.xml │ │ ├── fragment_qrcode_dialog.xml │ │ ├── fragment_settings.xml │ │ ├── sheet_add_profile.xml │ │ ├── view_app_list_item.xml │ │ ├── view_app_list_item0.xml │ │ ├── view_appbar.xml │ │ ├── view_clash_mode_button.xml │ │ ├── view_configutation_item.xml │ │ ├── view_dashboard_group.xml │ │ ├── view_dashboard_group_item.xml │ │ ├── view_log_text_item.xml │ │ ├── view_prefenence_screen.xml │ │ ├── view_profile_item.xml │ │ └── view_vpn_app_item.xml │ │ ├── menu │ │ ├── app_menu.xml │ │ ├── bottom_nav_menu.xml │ │ ├── edit_configutation_menu.xml │ │ ├── per_app_menu.xml │ │ ├── profile_menu.xml │ │ └── qr_scan_menu.xml │ │ ├── mipmap-anydpi-v26 │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ │ ├── mipmap-hdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-mdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── navigation │ │ └── mobile_navigation.xml │ │ ├── values-night-v23 │ │ └── themes.xml │ │ ├── values-night-v26 │ │ └── themes.xml │ │ ├── values-v23 │ │ └── themes.xml │ │ ├── values-v26 │ │ └── themes.xml │ │ ├── values-v27 │ │ └── themes.xml │ │ ├── values-zh-rCN │ │ └── strings.xml │ │ ├── values │ │ ├── arrays.xml │ │ ├── colors.xml │ │ ├── ic_launcher_background.xml │ │ ├── strings.xml │ │ └── themes.xml │ │ └── xml │ │ ├── backup_rules.xml │ │ ├── cache_paths.xml │ │ ├── data_extraction_rules.xml │ │ └── shortcuts.xml │ ├── other │ ├── java │ │ └── io │ │ │ └── nekohasekai │ │ │ └── sfa │ │ │ └── vendor │ │ │ └── Vendor.kt │ └── play │ │ └── listings │ │ └── en-US │ │ ├── full-description.txt │ │ ├── graphics │ │ ├── icon │ │ │ └── ic_launcher-playstore.png │ │ └── phone-screenshots │ │ │ ├── 1.png │ │ │ ├── 2.png │ │ │ ├── 3.png │ │ │ └── 4.png │ │ └── short-description.txt │ └── play │ └── java │ └── io │ └── nekohasekai │ └── sfa │ └── vendor │ ├── MLKitQRCodeAnalyzer.kt │ └── Vendor.kt ├── build.gradle ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── settings.gradle └── version.properties /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea/ 5 | .DS_Store 6 | /build 7 | /captures 8 | .externalNativeBuild 9 | .cxx 10 | local.properties 11 | /app/libs/ 12 | /service-account-credentials.json -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (C) 2022 by nekohasekai 2 | 3 | This program is free software: you can redistribute it and/or modify 4 | it under the terms of the GNU General Public License as published by 5 | the Free Software Foundation, either version 3 of the License, or 6 | (at your option) any later version. 7 | 8 | This program is distributed in the hope that it will be useful, 9 | but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | GNU General Public License for more details. 12 | 13 | You should have received a copy of the GNU General Public License 14 | along with this program. If not, see . 15 | 16 | In addition, no derivative work may use the name or imply association 17 | with this application without prior consent. 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SFA 2 | 3 | Experimental Android client for sing-box, the universal proxy platform. 4 | 5 | ## Documentation 6 | 7 | https://sing-box.sagernet.org/installation/clients/sfa/ 8 | 9 | ## License 10 | 11 | ``` 12 | Copyright (C) 2022 by nekohasekai 13 | 14 | This program is free software: you can redistribute it and/or modify 15 | it under the terms of the GNU General Public License as published by 16 | the Free Software Foundation, either version 3 of the License, or 17 | (at your option) any later version. 18 | 19 | This program is distributed in the hope that it will be useful, 20 | but WITHOUT ANY WARRANTY; without even the implied warranty of 21 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 22 | GNU General Public License for more details. 23 | 24 | You should have received a copy of the GNU General Public License 25 | along with this program. If not, see . 26 | 27 | In addition, no derivative work may use the name or imply association 28 | with this application without prior consent. 29 | ``` 30 | 31 | Under the license, that forks of the app are not allowed to be listed on F-Droid or other app stores 32 | under the original name. 33 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile 22 | -------------------------------------------------------------------------------- /app/release.keystore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SagerNet/sing-box-for-android/320170a1077ea5c93872b3e055b96b8836615ef0/app/release.keystore -------------------------------------------------------------------------------- /app/schemas/io.nekohasekai.sfa.database.ProfileDatabase/1.json: -------------------------------------------------------------------------------- 1 | { 2 | "formatVersion": 1, 3 | "database": { 4 | "version": 1, 5 | "identityHash": "b7bfa362ec191b0a18660e615da81e46", 6 | "entities": [ 7 | { 8 | "tableName": "profiles", 9 | "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userOrder` INTEGER NOT NULL, `name` TEXT NOT NULL, `typed` BLOB NOT NULL)", 10 | "fields": [ 11 | { 12 | "fieldPath": "id", 13 | "columnName": "id", 14 | "affinity": "INTEGER", 15 | "notNull": true 16 | }, 17 | { 18 | "fieldPath": "userOrder", 19 | "columnName": "userOrder", 20 | "affinity": "INTEGER", 21 | "notNull": true 22 | }, 23 | { 24 | "fieldPath": "name", 25 | "columnName": "name", 26 | "affinity": "TEXT", 27 | "notNull": true 28 | }, 29 | { 30 | "fieldPath": "typed", 31 | "columnName": "typed", 32 | "affinity": "BLOB", 33 | "notNull": true 34 | } 35 | ], 36 | "primaryKey": { 37 | "autoGenerate": true, 38 | "columnNames": [ 39 | "id" 40 | ] 41 | }, 42 | "indices": [], 43 | "foreignKeys": [] 44 | } 45 | ], 46 | "views": [], 47 | "setupQueries": [ 48 | "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", 49 | "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b7bfa362ec191b0a18660e615da81e46')" 50 | ] 51 | } 52 | } -------------------------------------------------------------------------------- /app/schemas/io.nekohasekai.sfa.database.preference.KeyValueDatabase/1.json: -------------------------------------------------------------------------------- 1 | { 2 | "formatVersion": 1, 3 | "database": { 4 | "version": 1, 5 | "identityHash": "c20dc7fa2a9489b6f52aafe18f86ecea", 6 | "entities": [ 7 | { 8 | "tableName": "KeyValueEntity", 9 | "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `valueType` INTEGER NOT NULL, `value` BLOB NOT NULL, PRIMARY KEY(`key`))", 10 | "fields": [ 11 | { 12 | "fieldPath": "key", 13 | "columnName": "key", 14 | "affinity": "TEXT", 15 | "notNull": true 16 | }, 17 | { 18 | "fieldPath": "valueType", 19 | "columnName": "valueType", 20 | "affinity": "INTEGER", 21 | "notNull": true 22 | }, 23 | { 24 | "fieldPath": "value", 25 | "columnName": "value", 26 | "affinity": "BLOB", 27 | "notNull": true 28 | } 29 | ], 30 | "primaryKey": { 31 | "columnNames": [ 32 | "key" 33 | ], 34 | "autoGenerate": false 35 | }, 36 | "indices": [], 37 | "foreignKeys": [] 38 | } 39 | ], 40 | "views": [], 41 | "setupQueries": [ 42 | "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", 43 | "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'c20dc7fa2a9489b6f52aafe18f86ecea')" 44 | ] 45 | } 46 | } -------------------------------------------------------------------------------- /app/src/main/aidl/io/nekohasekai/sfa/aidl/IService.aidl: -------------------------------------------------------------------------------- 1 | package io.nekohasekai.sfa.aidl; 2 | 3 | import io.nekohasekai.sfa.aidl.IServiceCallback; 4 | 5 | interface IService { 6 | int getStatus(); 7 | void registerCallback(in IServiceCallback callback); 8 | oneway void unregisterCallback(in IServiceCallback callback); 9 | } -------------------------------------------------------------------------------- /app/src/main/aidl/io/nekohasekai/sfa/aidl/IServiceCallback.aidl: -------------------------------------------------------------------------------- 1 | package io.nekohasekai.sfa.aidl; 2 | 3 | interface IServiceCallback { 4 | void onServiceStatusChanged(int status); 5 | void onServiceAlert(int type, String message); 6 | } -------------------------------------------------------------------------------- /app/src/main/ic_launcher-playstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SagerNet/sing-box-for-android/320170a1077ea5c93872b3e055b96b8836615ef0/app/src/main/ic_launcher-playstore.png -------------------------------------------------------------------------------- /app/src/main/java/io/nekohasekai/sfa/Application.kt: -------------------------------------------------------------------------------- 1 | package io.nekohasekai.sfa 2 | 3 | import android.app.Application 4 | import android.app.NotificationManager 5 | import android.content.ClipboardManager 6 | import android.content.Context 7 | import android.content.Intent 8 | import android.content.IntentFilter 9 | import android.net.ConnectivityManager 10 | import android.net.wifi.WifiManager 11 | import android.os.PowerManager 12 | import androidx.core.content.getSystemService 13 | import go.Seq 14 | import io.nekohasekai.libbox.Libbox 15 | import io.nekohasekai.sfa.bg.AppChangeReceiver 16 | import io.nekohasekai.sfa.bg.UpdateProfileWork 17 | import kotlinx.coroutines.Dispatchers 18 | import kotlinx.coroutines.GlobalScope 19 | import kotlinx.coroutines.launch 20 | import java.util.Locale 21 | import io.nekohasekai.sfa.Application as BoxApplication 22 | 23 | class Application : Application() { 24 | 25 | override fun attachBaseContext(base: Context?) { 26 | super.attachBaseContext(base) 27 | application = this 28 | } 29 | 30 | override fun onCreate() { 31 | super.onCreate() 32 | 33 | Seq.setContext(this) 34 | Libbox.setLocale(Locale.getDefault().toLanguageTag().replace("-", "_")) 35 | 36 | @Suppress("OPT_IN_USAGE") 37 | GlobalScope.launch(Dispatchers.IO) { 38 | UpdateProfileWork.reconfigureUpdater() 39 | } 40 | 41 | registerReceiver(AppChangeReceiver(), IntentFilter().apply { 42 | addAction(Intent.ACTION_PACKAGE_ADDED) 43 | addDataScheme("package") 44 | }) 45 | } 46 | 47 | companion object { 48 | lateinit var application: BoxApplication 49 | val notification by lazy { application.getSystemService()!! } 50 | val connectivity by lazy { application.getSystemService()!! } 51 | val packageManager by lazy { application.packageManager } 52 | val powerManager by lazy { application.getSystemService()!! } 53 | val notificationManager by lazy { application.getSystemService()!! } 54 | val wifiManager by lazy { application.getSystemService()!! } 55 | val clipboard by lazy { application.getSystemService()!! } 56 | } 57 | 58 | } -------------------------------------------------------------------------------- /app/src/main/java/io/nekohasekai/sfa/bg/AppChangeReceiver.kt: -------------------------------------------------------------------------------- 1 | package io.nekohasekai.sfa.bg 2 | 3 | import android.content.BroadcastReceiver 4 | import android.content.Context 5 | import android.content.Intent 6 | import android.util.Log 7 | import io.nekohasekai.sfa.database.Settings 8 | import io.nekohasekai.sfa.ui.profileoverride.PerAppProxyActivity 9 | 10 | class AppChangeReceiver : BroadcastReceiver() { 11 | 12 | companion object { 13 | private const val TAG = "AppChangeReceiver" 14 | } 15 | 16 | override fun onReceive(context: Context, intent: Intent) { 17 | Log.d(TAG, "onReceive: ${intent.action}") 18 | checkUpdate(intent) 19 | } 20 | 21 | private fun checkUpdate(intent: Intent) { 22 | if (!Settings.perAppProxyEnabled) { 23 | Log.d(TAG, "per app proxy disabled") 24 | return 25 | } 26 | if (intent.getBooleanExtra(Intent.EXTRA_REPLACING, false)) { 27 | Log.d(TAG, "skip app update") 28 | return 29 | } 30 | val perAppProxyUpdateOnChange = Settings.perAppProxyUpdateOnChange 31 | if (perAppProxyUpdateOnChange == Settings.PER_APP_PROXY_DISABLED) { 32 | Log.d(TAG, "update on change disabled") 33 | return 34 | } 35 | val packageName = intent.dataString?.substringAfter("package:") 36 | if (packageName.isNullOrBlank()) { 37 | Log.d(TAG, "missing package name in intent") 38 | return 39 | } 40 | val isChinaApp = PerAppProxyActivity.scanChinaPackage(packageName) 41 | Log.d(TAG, "scan china app result for $packageName: $isChinaApp") 42 | if ((perAppProxyUpdateOnChange == Settings.PER_APP_PROXY_INCLUDE) xor !isChinaApp) { 43 | Settings.perAppProxyList += packageName 44 | Log.d(TAG, "added to list") 45 | } else { 46 | Settings.perAppProxyList -= packageName 47 | Log.d(TAG, "removed from list") 48 | } 49 | } 50 | 51 | } -------------------------------------------------------------------------------- /app/src/main/java/io/nekohasekai/sfa/bg/BootReceiver.kt: -------------------------------------------------------------------------------- 1 | package io.nekohasekai.sfa.bg 2 | 3 | import android.content.BroadcastReceiver 4 | import android.content.Context 5 | import android.content.Intent 6 | import io.nekohasekai.sfa.database.Settings 7 | import kotlinx.coroutines.DelicateCoroutinesApi 8 | import kotlinx.coroutines.Dispatchers 9 | import kotlinx.coroutines.GlobalScope 10 | import kotlinx.coroutines.launch 11 | import kotlinx.coroutines.withContext 12 | 13 | class BootReceiver : BroadcastReceiver() { 14 | 15 | @OptIn(DelicateCoroutinesApi::class) 16 | override fun onReceive(context: Context, intent: Intent) { 17 | when (intent.action) { 18 | Intent.ACTION_BOOT_COMPLETED, Intent.ACTION_MY_PACKAGE_REPLACED -> { 19 | } 20 | 21 | else -> return 22 | } 23 | GlobalScope.launch(Dispatchers.IO) { 24 | if (Settings.startedByUser) { 25 | withContext(Dispatchers.Main) { 26 | BoxService.start() 27 | } 28 | } 29 | } 30 | } 31 | 32 | } 33 | -------------------------------------------------------------------------------- /app/src/main/java/io/nekohasekai/sfa/bg/DefaultNetworkMonitor.kt: -------------------------------------------------------------------------------- 1 | package io.nekohasekai.sfa.bg 2 | 3 | import android.net.Network 4 | import android.os.Build 5 | import io.nekohasekai.libbox.InterfaceUpdateListener 6 | import io.nekohasekai.sfa.Application 7 | import io.nekohasekai.sfa.constant.Bugs 8 | import kotlinx.coroutines.Dispatchers 9 | import kotlinx.coroutines.GlobalScope 10 | import kotlinx.coroutines.launch 11 | import java.net.NetworkInterface 12 | 13 | object DefaultNetworkMonitor { 14 | 15 | var defaultNetwork: Network? = null 16 | private var listener: InterfaceUpdateListener? = null 17 | 18 | suspend fun start() { 19 | DefaultNetworkListener.start(this) { 20 | defaultNetwork = it 21 | checkDefaultInterfaceUpdate(it) 22 | } 23 | defaultNetwork = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { 24 | Application.connectivity.activeNetwork 25 | } else { 26 | DefaultNetworkListener.get() 27 | } 28 | } 29 | 30 | suspend fun stop() { 31 | DefaultNetworkListener.stop(this) 32 | } 33 | 34 | suspend fun require(): Network { 35 | val network = defaultNetwork 36 | if (network != null) { 37 | return network 38 | } 39 | return DefaultNetworkListener.get() 40 | } 41 | 42 | fun setListener(listener: InterfaceUpdateListener?) { 43 | this.listener = listener 44 | checkDefaultInterfaceUpdate(defaultNetwork) 45 | } 46 | 47 | private fun checkDefaultInterfaceUpdate( 48 | newNetwork: Network? 49 | ) { 50 | val listener = listener ?: return 51 | if (newNetwork != null) { 52 | val interfaceName = 53 | (Application.connectivity.getLinkProperties(newNetwork) ?: return).interfaceName 54 | for (times in 0 until 10) { 55 | var interfaceIndex: Int 56 | try { 57 | interfaceIndex = NetworkInterface.getByName(interfaceName).index 58 | } catch (e: Exception) { 59 | Thread.sleep(100) 60 | continue 61 | } 62 | if (Bugs.fixAndroidStack) { 63 | GlobalScope.launch(Dispatchers.IO) { 64 | listener.updateDefaultInterface(interfaceName, interfaceIndex, false, false) 65 | } 66 | } else { 67 | listener.updateDefaultInterface(interfaceName, interfaceIndex, false, false) 68 | } 69 | } 70 | } else { 71 | if (Bugs.fixAndroidStack) { 72 | GlobalScope.launch(Dispatchers.IO) { 73 | listener.updateDefaultInterface("", -1, false, false) 74 | } 75 | } else { 76 | listener.updateDefaultInterface("", -1, false, false) 77 | } 78 | } 79 | } 80 | 81 | } -------------------------------------------------------------------------------- /app/src/main/java/io/nekohasekai/sfa/bg/ProxyService.kt: -------------------------------------------------------------------------------- 1 | package io.nekohasekai.sfa.bg 2 | 3 | import android.app.Service 4 | import android.content.Intent 5 | import io.nekohasekai.libbox.Notification 6 | 7 | class ProxyService : Service(), PlatformInterfaceWrapper { 8 | 9 | private val service = BoxService(this, this) 10 | 11 | override fun onStartCommand(intent: Intent?, flags: Int, startId: Int) = 12 | service.onStartCommand() 13 | 14 | override fun onBind(intent: Intent) = service.onBind() 15 | override fun onDestroy() = service.onDestroy() 16 | 17 | override fun writeLog(message: String) = service.writeLog(message) 18 | 19 | override fun sendNotification(notification: Notification) = 20 | service.sendNotification(notification) 21 | 22 | } -------------------------------------------------------------------------------- /app/src/main/java/io/nekohasekai/sfa/bg/ServiceBinder.kt: -------------------------------------------------------------------------------- 1 | package io.nekohasekai.sfa.bg 2 | 3 | import android.os.RemoteCallbackList 4 | import androidx.lifecycle.MutableLiveData 5 | import io.nekohasekai.sfa.aidl.IService 6 | import io.nekohasekai.sfa.aidl.IServiceCallback 7 | import io.nekohasekai.sfa.constant.Status 8 | import kotlinx.coroutines.DelicateCoroutinesApi 9 | import kotlinx.coroutines.Dispatchers 10 | import kotlinx.coroutines.GlobalScope 11 | import kotlinx.coroutines.launch 12 | import kotlinx.coroutines.sync.Mutex 13 | import kotlinx.coroutines.sync.withLock 14 | 15 | class ServiceBinder(private val status: MutableLiveData) : IService.Stub() { 16 | private val callbacks = RemoteCallbackList() 17 | private val broadcastLock = Mutex() 18 | 19 | init { 20 | status.observeForever { 21 | broadcast { callback -> 22 | callback.onServiceStatusChanged(it.ordinal) 23 | } 24 | } 25 | } 26 | 27 | @OptIn(DelicateCoroutinesApi::class) 28 | fun broadcast(work: (IServiceCallback) -> Unit) { 29 | GlobalScope.launch(Dispatchers.Main) { 30 | broadcastLock.withLock { 31 | val count = callbacks.beginBroadcast() 32 | try { 33 | repeat(count) { 34 | try { 35 | work(callbacks.getBroadcastItem(it)) 36 | } catch (_: Exception) { 37 | } 38 | } 39 | } finally { 40 | callbacks.finishBroadcast() 41 | } 42 | } 43 | } 44 | } 45 | 46 | override fun getStatus(): Int { 47 | return (status.value ?: Status.Stopped).ordinal 48 | } 49 | 50 | override fun registerCallback(callback: IServiceCallback) { 51 | callbacks.register(callback) 52 | } 53 | 54 | override fun unregisterCallback(callback: IServiceCallback?) { 55 | callbacks.unregister(callback) 56 | } 57 | 58 | fun close() { 59 | callbacks.kill() 60 | } 61 | } -------------------------------------------------------------------------------- /app/src/main/java/io/nekohasekai/sfa/bg/ServiceConnection.kt: -------------------------------------------------------------------------------- 1 | package io.nekohasekai.sfa.bg 2 | 3 | import android.content.ComponentName 4 | import android.content.Context 5 | import android.content.Intent 6 | import android.content.ServiceConnection 7 | import android.os.IBinder 8 | import android.os.RemoteException 9 | import android.util.Log 10 | import androidx.appcompat.app.AppCompatActivity 11 | import io.nekohasekai.sfa.aidl.IService 12 | import io.nekohasekai.sfa.aidl.IServiceCallback 13 | import io.nekohasekai.sfa.constant.Action 14 | import io.nekohasekai.sfa.constant.Alert 15 | import io.nekohasekai.sfa.constant.Status 16 | import io.nekohasekai.sfa.database.Settings 17 | import kotlinx.coroutines.Dispatchers 18 | import kotlinx.coroutines.runBlocking 19 | import kotlinx.coroutines.withContext 20 | 21 | class ServiceConnection( 22 | private val context: Context, 23 | callback: Callback, 24 | private val register: Boolean = true, 25 | ) : ServiceConnection { 26 | 27 | companion object { 28 | private const val TAG = "ServiceConnection" 29 | } 30 | 31 | private val callback = ServiceCallback(callback) 32 | private var service: IService? = null 33 | 34 | val status get() = service?.status?.let { Status.values()[it] } ?: Status.Stopped 35 | 36 | fun connect() { 37 | val intent = runBlocking { 38 | withContext(Dispatchers.IO) { 39 | Intent(context, Settings.serviceClass()).setAction(Action.SERVICE) 40 | } 41 | } 42 | context.bindService(intent, this, AppCompatActivity.BIND_AUTO_CREATE) 43 | Log.d(TAG, "request connect") 44 | } 45 | 46 | fun disconnect() { 47 | try { 48 | context.unbindService(this) 49 | } catch (_: IllegalArgumentException) { 50 | } 51 | Log.d(TAG, "request disconnect") 52 | } 53 | 54 | fun reconnect() { 55 | try { 56 | context.unbindService(this) 57 | } catch (_: IllegalArgumentException) { 58 | } 59 | val intent = runBlocking { 60 | withContext(Dispatchers.IO) { 61 | Intent(context, Settings.serviceClass()).setAction(Action.SERVICE) 62 | } 63 | } 64 | context.bindService(intent, this, AppCompatActivity.BIND_AUTO_CREATE) 65 | Log.d(TAG, "request reconnect") 66 | } 67 | 68 | override fun onServiceConnected(name: ComponentName, binder: IBinder) { 69 | val service = IService.Stub.asInterface(binder) 70 | this.service = service 71 | try { 72 | if (register) service.registerCallback(callback) 73 | callback.onServiceStatusChanged(service.status) 74 | } catch (e: RemoteException) { 75 | Log.e(TAG, "initialize service connection", e) 76 | } 77 | Log.d(TAG, "service connected") 78 | } 79 | 80 | override fun onServiceDisconnected(name: ComponentName?) { 81 | try { 82 | service?.unregisterCallback(callback) 83 | } catch (e: RemoteException) { 84 | Log.e(TAG, "cleanup service connection", e) 85 | } 86 | Log.d(TAG, "service disconnected") 87 | } 88 | 89 | override fun onBindingDied(name: ComponentName?) { 90 | reconnect() 91 | Log.d(TAG, "service dead") 92 | } 93 | 94 | interface Callback { 95 | fun onServiceStatusChanged(status: Status) 96 | fun onServiceAlert(type: Alert, message: String?) {} 97 | } 98 | 99 | class ServiceCallback(private val callback: Callback) : IServiceCallback.Stub() { 100 | override fun onServiceStatusChanged(status: Int) { 101 | callback.onServiceStatusChanged(Status.values()[status]) 102 | } 103 | 104 | override fun onServiceAlert(type: Int, message: String?) { 105 | callback.onServiceAlert(Alert.values()[type], message) 106 | } 107 | } 108 | } -------------------------------------------------------------------------------- /app/src/main/java/io/nekohasekai/sfa/bg/ServiceNotification.kt: -------------------------------------------------------------------------------- 1 | package io.nekohasekai.sfa.bg 2 | 3 | import android.app.NotificationChannel 4 | import android.app.NotificationManager 5 | import android.app.PendingIntent 6 | import android.app.Service 7 | import android.content.BroadcastReceiver 8 | import android.content.Context 9 | import android.content.Intent 10 | import android.content.IntentFilter 11 | import android.os.Build 12 | import androidx.annotation.StringRes 13 | import androidx.core.app.NotificationCompat 14 | import androidx.core.app.ServiceCompat 15 | import androidx.lifecycle.MutableLiveData 16 | import io.nekohasekai.libbox.Libbox 17 | import io.nekohasekai.libbox.StatusMessage 18 | import io.nekohasekai.sfa.Application 19 | import io.nekohasekai.sfa.R 20 | import io.nekohasekai.sfa.constant.Action 21 | import io.nekohasekai.sfa.constant.Status 22 | import io.nekohasekai.sfa.database.Settings 23 | import io.nekohasekai.sfa.ui.MainActivity 24 | import io.nekohasekai.sfa.utils.CommandClient 25 | import kotlinx.coroutines.DelicateCoroutinesApi 26 | import kotlinx.coroutines.Dispatchers 27 | import kotlinx.coroutines.GlobalScope 28 | import kotlinx.coroutines.withContext 29 | 30 | class ServiceNotification( 31 | private val status: MutableLiveData, private val service: Service 32 | ) : BroadcastReceiver(), CommandClient.Handler { 33 | companion object { 34 | private const val notificationId = 1 35 | private const val notificationChannel = "service" 36 | val flags = 37 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0 38 | 39 | fun checkPermission(): Boolean { 40 | if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { 41 | return true 42 | } 43 | return Application.notification.areNotificationsEnabled() 44 | } 45 | } 46 | 47 | @OptIn(DelicateCoroutinesApi::class) 48 | private val commandClient = 49 | CommandClient(GlobalScope, CommandClient.ConnectionType.Status, this) 50 | private var receiverRegistered = false 51 | 52 | private val notificationBuilder by lazy { 53 | NotificationCompat.Builder(service, notificationChannel).setShowWhen(false).setOngoing(true) 54 | .setContentTitle("sing-box").setOnlyAlertOnce(true) 55 | .setSmallIcon(R.drawable.ic_menu) 56 | .setCategory(NotificationCompat.CATEGORY_SERVICE) 57 | .setContentIntent( 58 | PendingIntent.getActivity( 59 | service, 60 | 0, 61 | Intent( 62 | service, 63 | MainActivity::class.java 64 | ).setFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT), 65 | flags 66 | ) 67 | ) 68 | .setPriority(NotificationCompat.PRIORITY_LOW).apply { 69 | addAction( 70 | NotificationCompat.Action.Builder( 71 | 0, service.getText(R.string.stop), PendingIntent.getBroadcast( 72 | service, 73 | 0, 74 | Intent(Action.SERVICE_CLOSE).setPackage(service.packageName), 75 | flags 76 | ) 77 | ).build() 78 | ) 79 | } 80 | } 81 | 82 | fun show(lastProfileName: String, @StringRes contentTextId: Int) { 83 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { 84 | Application.notification.createNotificationChannel( 85 | NotificationChannel( 86 | notificationChannel, "Service Notifications", NotificationManager.IMPORTANCE_LOW 87 | ) 88 | ) 89 | } 90 | service.startForeground( 91 | notificationId, notificationBuilder 92 | .setContentTitle(lastProfileName.takeIf { it.isNotBlank() } ?: "sing-box") 93 | .setContentText(service.getString(contentTextId)).build() 94 | ) 95 | } 96 | 97 | suspend fun start() { 98 | if (Settings.dynamicNotification && checkPermission()) { 99 | commandClient.connect() 100 | withContext(Dispatchers.Main) { 101 | registerReceiver() 102 | } 103 | } 104 | } 105 | 106 | private fun registerReceiver() { 107 | service.registerReceiver(this, IntentFilter().apply { 108 | addAction(Intent.ACTION_SCREEN_ON) 109 | addAction(Intent.ACTION_SCREEN_OFF) 110 | }) 111 | receiverRegistered = true 112 | } 113 | 114 | override fun updateStatus(status: StatusMessage) { 115 | val content = 116 | Libbox.formatBytes(status.uplink) + "/s ↑\t" + Libbox.formatBytes(status.downlink) + "/s ↓" 117 | Application.notificationManager.notify( 118 | notificationId, 119 | notificationBuilder.setContentText(content).build() 120 | ) 121 | } 122 | 123 | override fun onReceive(context: Context, intent: Intent) { 124 | when (intent.action) { 125 | Intent.ACTION_SCREEN_ON -> { 126 | commandClient.connect() 127 | } 128 | 129 | Intent.ACTION_SCREEN_OFF -> { 130 | commandClient.disconnect() 131 | } 132 | } 133 | } 134 | 135 | fun close() { 136 | commandClient.disconnect() 137 | ServiceCompat.stopForeground(service, ServiceCompat.STOP_FOREGROUND_REMOVE) 138 | if (receiverRegistered) { 139 | service.unregisterReceiver(this) 140 | receiverRegistered = false 141 | } 142 | } 143 | } -------------------------------------------------------------------------------- /app/src/main/java/io/nekohasekai/sfa/bg/TileService.kt: -------------------------------------------------------------------------------- 1 | package io.nekohasekai.sfa.bg 2 | 3 | import android.service.quicksettings.Tile 4 | import android.service.quicksettings.TileService 5 | import androidx.annotation.RequiresApi 6 | import io.nekohasekai.sfa.constant.Status 7 | 8 | @RequiresApi(24) 9 | class TileService : TileService(), ServiceConnection.Callback { 10 | 11 | private val connection = ServiceConnection(this, this) 12 | 13 | override fun onServiceStatusChanged(status: Status) { 14 | qsTile?.apply { 15 | state = when (status) { 16 | Status.Started -> Tile.STATE_ACTIVE 17 | Status.Stopped -> Tile.STATE_INACTIVE 18 | else -> Tile.STATE_UNAVAILABLE 19 | } 20 | updateTile() 21 | } 22 | } 23 | 24 | override fun onStartListening() { 25 | super.onStartListening() 26 | connection.connect() 27 | } 28 | 29 | override fun onStopListening() { 30 | connection.disconnect() 31 | super.onStopListening() 32 | } 33 | 34 | override fun onClick() { 35 | when (connection.status) { 36 | Status.Stopped -> { 37 | BoxService.start() 38 | } 39 | 40 | Status.Started -> { 41 | BoxService.stop() 42 | } 43 | 44 | else -> {} 45 | } 46 | } 47 | 48 | } -------------------------------------------------------------------------------- /app/src/main/java/io/nekohasekai/sfa/bg/UpdateProfileWork.kt: -------------------------------------------------------------------------------- 1 | package io.nekohasekai.sfa.bg 2 | 3 | import android.content.Context 4 | import android.util.Log 5 | import androidx.work.BackoffPolicy 6 | import androidx.work.CoroutineWorker 7 | import androidx.work.ExistingPeriodicWorkPolicy 8 | import androidx.work.PeriodicWorkRequest 9 | import androidx.work.WorkManager 10 | import androidx.work.WorkerParameters 11 | import io.nekohasekai.libbox.Libbox 12 | import io.nekohasekai.sfa.Application 13 | import io.nekohasekai.sfa.database.ProfileManager 14 | import io.nekohasekai.sfa.database.Settings 15 | import io.nekohasekai.sfa.database.TypedProfile 16 | import io.nekohasekai.sfa.utils.HTTPClient 17 | import java.io.File 18 | import java.util.Date 19 | import java.util.concurrent.TimeUnit 20 | 21 | class UpdateProfileWork { 22 | 23 | companion object { 24 | private const val WORK_NAME = "UpdateProfile" 25 | private const val TAG = "UpdateProfileWork" 26 | 27 | suspend fun reconfigureUpdater() { 28 | runCatching { 29 | reconfigureUpdater0() 30 | }.onFailure { 31 | Log.e(TAG, "reconfigureUpdater", it) 32 | } 33 | } 34 | 35 | private suspend fun reconfigureUpdater0() { 36 | val remoteProfiles = ProfileManager.list() 37 | .filter { it.typed.type == TypedProfile.Type.Remote && it.typed.autoUpdate } 38 | if (remoteProfiles.isEmpty()) { 39 | WorkManager.getInstance(Application.application).cancelUniqueWork(WORK_NAME) 40 | return 41 | } 42 | 43 | var minDelay = 44 | remoteProfiles.minByOrNull { it.typed.autoUpdateInterval }!!.typed.autoUpdateInterval.toLong() 45 | val nowSeconds = System.currentTimeMillis() / 1000L 46 | val minInitDelay = 47 | remoteProfiles.minOf { (it.typed.autoUpdateInterval * 60) - (nowSeconds - (it.typed.lastUpdated.time / 1000L)) } 48 | if (minDelay < 15) minDelay = 15 49 | WorkManager.getInstance(Application.application).enqueueUniquePeriodicWork( 50 | WORK_NAME, 51 | ExistingPeriodicWorkPolicy.UPDATE, 52 | PeriodicWorkRequest.Builder(UpdateTask::class.java, minDelay, TimeUnit.MINUTES) 53 | .apply { 54 | if (minInitDelay > 0) setInitialDelay(minInitDelay, TimeUnit.SECONDS) 55 | setBackoffCriteria(BackoffPolicy.LINEAR, 15, TimeUnit.MINUTES) 56 | } 57 | .build() 58 | ) 59 | } 60 | 61 | } 62 | 63 | class UpdateTask( 64 | appContext: Context, params: WorkerParameters 65 | ) : CoroutineWorker(appContext, params) { 66 | override suspend fun doWork(): Result { 67 | var selectedProfileUpdated = false 68 | val remoteProfiles = ProfileManager.list() 69 | .filter { it.typed.type == TypedProfile.Type.Remote && it.typed.autoUpdate } 70 | if (remoteProfiles.isEmpty()) return Result.success() 71 | var success = true 72 | val selectedProfile = Settings.selectedProfile 73 | for (profile in remoteProfiles) { 74 | val lastSeconds = 75 | (System.currentTimeMillis() - profile.typed.lastUpdated.time) / 1000L 76 | if (lastSeconds < profile.typed.autoUpdateInterval * 60) { 77 | continue 78 | } 79 | try { 80 | val content = HTTPClient().use { it.getString(profile.typed.remoteURL) } 81 | Libbox.checkConfig(content) 82 | val file = File(profile.typed.path) 83 | if (file.readText() != content) { 84 | File(profile.typed.path).writeText(content) 85 | if (profile.id == selectedProfile) { 86 | selectedProfileUpdated = true 87 | } 88 | } 89 | profile.typed.lastUpdated = Date() 90 | ProfileManager.update(profile) 91 | } catch (e: Exception) { 92 | Log.e(TAG, "update profile ${profile.name}", e) 93 | success = false 94 | } 95 | } 96 | if (selectedProfileUpdated) { 97 | runCatching { 98 | Libbox.newStandaloneCommandClient().serviceReload() 99 | } 100 | } 101 | return if (success) { 102 | Result.success() 103 | } else { 104 | Result.retry() 105 | } 106 | } 107 | 108 | } 109 | 110 | 111 | } -------------------------------------------------------------------------------- /app/src/main/java/io/nekohasekai/sfa/constant/Action.kt: -------------------------------------------------------------------------------- 1 | package io.nekohasekai.sfa.constant 2 | 3 | object Action { 4 | const val SERVICE = "io.nekohasekai.sfa.SERVICE" 5 | const val SERVICE_CLOSE = "io.nekohasekai.sfa.SERVICE_CLOSE" 6 | const val OPEN_URL = "io.nekohasekai.sfa.SERVICE_OPEN_URL" 7 | } -------------------------------------------------------------------------------- /app/src/main/java/io/nekohasekai/sfa/constant/Alert.kt: -------------------------------------------------------------------------------- 1 | package io.nekohasekai.sfa.constant 2 | 3 | enum class Alert { 4 | RequestVPNPermission, 5 | RequestNotificationPermission, 6 | RequestLocationPermission, 7 | EmptyConfiguration, 8 | StartCommandServer, 9 | CreateService, 10 | StartService 11 | } -------------------------------------------------------------------------------- /app/src/main/java/io/nekohasekai/sfa/constant/Bugs.kt: -------------------------------------------------------------------------------- 1 | package io.nekohasekai.sfa.constant 2 | 3 | import android.os.Build 4 | import io.nekohasekai.sfa.BuildConfig 5 | 6 | object Bugs { 7 | 8 | // TODO: remove launch after fixed 9 | // https://github.com/golang/go/issues/68760 10 | val fixAndroidStack = BuildConfig.DEBUG || 11 | Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && Build.VERSION.SDK_INT <= Build.VERSION_CODES.N_MR1 || 12 | Build.VERSION.SDK_INT >= Build.VERSION_CODES.P 13 | 14 | } -------------------------------------------------------------------------------- /app/src/main/java/io/nekohasekai/sfa/constant/EnabledType.kt: -------------------------------------------------------------------------------- 1 | package io.nekohasekai.sfa.constant 2 | 3 | import android.content.Context 4 | import io.nekohasekai.sfa.R 5 | 6 | enum class EnabledType(val boolValue: Boolean) { 7 | Enabled(true), Disabled(false); 8 | 9 | fun getString(context: Context): String { 10 | return when (this) { 11 | Enabled -> context.getString(R.string.enabled) 12 | Disabled -> context.getString(R.string.disabled) 13 | } 14 | } 15 | 16 | 17 | companion object { 18 | fun from(value: Boolean): EnabledType { 19 | return if (value) Enabled else Disabled 20 | } 21 | 22 | fun valueOf(context: Context, value: String): EnabledType { 23 | return when (value) { 24 | context.getString(R.string.enabled) -> Enabled 25 | context.getString(R.string.disabled) -> Disabled 26 | else -> Disabled 27 | } 28 | } 29 | } 30 | } -------------------------------------------------------------------------------- /app/src/main/java/io/nekohasekai/sfa/constant/Path.kt: -------------------------------------------------------------------------------- 1 | package io.nekohasekai.sfa.constant 2 | 3 | object Path { 4 | const val SETTINGS_DATABASE_PATH = "settings.db" 5 | const val PROFILES_DATABASE_PATH = "profiles.db" 6 | } -------------------------------------------------------------------------------- /app/src/main/java/io/nekohasekai/sfa/constant/PerAppProxyUpdateType.kt: -------------------------------------------------------------------------------- 1 | package io.nekohasekai.sfa.constant 2 | 3 | import android.content.Context 4 | import io.nekohasekai.sfa.R 5 | import io.nekohasekai.sfa.database.Settings 6 | 7 | enum class PerAppProxyUpdateType { 8 | Disabled, Select, Deselect; 9 | 10 | fun value() = when (this) { 11 | Disabled -> Settings.PER_APP_PROXY_DISABLED 12 | Select -> Settings.PER_APP_PROXY_INCLUDE 13 | Deselect -> Settings.PER_APP_PROXY_EXCLUDE 14 | } 15 | 16 | fun getString(context: Context): String { 17 | return when (this) { 18 | Disabled -> context.getString(R.string.disabled) 19 | Select -> context.getString(R.string.action_select) 20 | Deselect -> context.getString(R.string.action_deselect) 21 | } 22 | } 23 | 24 | companion object { 25 | fun valueOf(value: Int): PerAppProxyUpdateType = when (value) { 26 | Settings.PER_APP_PROXY_DISABLED -> Disabled 27 | Settings.PER_APP_PROXY_INCLUDE -> Select 28 | Settings.PER_APP_PROXY_EXCLUDE -> Deselect 29 | else -> throw IllegalArgumentException() 30 | } 31 | 32 | fun valueOf(context: Context, value: String): PerAppProxyUpdateType { 33 | return when (value) { 34 | context.getString(R.string.disabled) -> Disabled 35 | context.getString(R.string.action_select) -> Select 36 | context.getString(R.string.action_deselect) -> Deselect 37 | else -> Disabled 38 | } 39 | } 40 | } 41 | } -------------------------------------------------------------------------------- /app/src/main/java/io/nekohasekai/sfa/constant/ServiceMode.kt: -------------------------------------------------------------------------------- 1 | package io.nekohasekai.sfa.constant 2 | 3 | object ServiceMode { 4 | const val NORMAL = "normal" 5 | const val VPN = "vpn" 6 | } -------------------------------------------------------------------------------- /app/src/main/java/io/nekohasekai/sfa/constant/SettingsKey.kt: -------------------------------------------------------------------------------- 1 | package io.nekohasekai.sfa.constant 2 | 3 | object SettingsKey { 4 | 5 | const val SELECTED_PROFILE = "selected_profile" 6 | const val SERVICE_MODE = "service_mode" 7 | const val CHECK_UPDATE_ENABLED = "check_update_enabled" 8 | const val DISABLE_MEMORY_LIMIT = "disable_memory_limit" 9 | const val DYNAMIC_NOTIFICATION = "dynamic_notification" 10 | 11 | const val PER_APP_PROXY_ENABLED = "per_app_proxy_enabled" 12 | const val PER_APP_PROXY_MODE = "per_app_proxy_mode" 13 | const val PER_APP_PROXY_LIST = "per_app_proxy_list" 14 | const val PER_APP_PROXY_UPDATE_ON_CHANGE = "per_app_proxy_update_on_change" 15 | 16 | const val SYSTEM_PROXY_ENABLED = "system_proxy_enabled" 17 | 18 | // cache 19 | 20 | const val STARTED_BY_USER = "started_by_user" 21 | 22 | } -------------------------------------------------------------------------------- /app/src/main/java/io/nekohasekai/sfa/constant/Status.kt: -------------------------------------------------------------------------------- 1 | package io.nekohasekai.sfa.constant 2 | 3 | enum class Status { 4 | Stopped, 5 | Starting, 6 | Started, 7 | Stopping, 8 | } -------------------------------------------------------------------------------- /app/src/main/java/io/nekohasekai/sfa/database/Profile.kt: -------------------------------------------------------------------------------- 1 | package io.nekohasekai.sfa.database 2 | 3 | import android.os.Parcelable 4 | import androidx.room.Delete 5 | import androidx.room.Entity 6 | import androidx.room.Insert 7 | import androidx.room.PrimaryKey 8 | import androidx.room.Query 9 | import androidx.room.TypeConverters 10 | import androidx.room.Update 11 | import kotlinx.parcelize.Parcelize 12 | 13 | @Entity( 14 | tableName = "profiles", 15 | ) 16 | @TypeConverters(TypedProfile.Convertor::class) 17 | @Parcelize 18 | class Profile( 19 | @PrimaryKey(autoGenerate = true) var id: Long = 0L, 20 | var userOrder: Long = 0L, 21 | var name: String = "", 22 | var typed: TypedProfile = TypedProfile() 23 | ) : Parcelable { 24 | 25 | @androidx.room.Dao 26 | interface Dao { 27 | 28 | @Insert 29 | fun insert(profile: Profile): Long 30 | 31 | @Update 32 | fun update(profile: Profile): Int 33 | 34 | @Update 35 | fun update(profile: List): Int 36 | 37 | @Delete 38 | fun delete(profile: Profile): Int 39 | 40 | @Delete 41 | fun delete(profile: List): Int 42 | 43 | @Query("SELECT * FROM profiles WHERE id = :profileId") 44 | fun get(profileId: Long): Profile? 45 | 46 | @Query("select * from profiles order by userOrder asc") 47 | fun list(): List 48 | 49 | @Query("DELETE FROM profiles") 50 | fun clear() 51 | 52 | @Query("SELECT MAX(userOrder) + 1 FROM profiles") 53 | fun nextOrder(): Long? 54 | 55 | @Query("SELECT MAX(id) + 1 FROM profiles") 56 | fun nextFileID(): Long? 57 | 58 | } 59 | 60 | } 61 | 62 | -------------------------------------------------------------------------------- /app/src/main/java/io/nekohasekai/sfa/database/ProfileDatabase.kt: -------------------------------------------------------------------------------- 1 | package io.nekohasekai.sfa.database 2 | 3 | import androidx.room.Database 4 | import androidx.room.RoomDatabase 5 | 6 | @Database( 7 | entities = [Profile::class], version = 1 8 | ) 9 | abstract class ProfileDatabase : RoomDatabase() { 10 | 11 | abstract fun profileDao(): Profile.Dao 12 | 13 | } -------------------------------------------------------------------------------- /app/src/main/java/io/nekohasekai/sfa/database/ProfileManager.kt: -------------------------------------------------------------------------------- 1 | package io.nekohasekai.sfa.database 2 | 3 | import androidx.room.Room 4 | import io.nekohasekai.sfa.Application 5 | import io.nekohasekai.sfa.constant.Path 6 | import kotlinx.coroutines.DelicateCoroutinesApi 7 | import kotlinx.coroutines.GlobalScope 8 | import kotlinx.coroutines.launch 9 | 10 | @Suppress("RedundantSuspendModifier") 11 | object ProfileManager { 12 | 13 | private val callbacks = mutableListOf<() -> Unit>() 14 | 15 | fun registerCallback(callback: () -> Unit) { 16 | callbacks.add(callback) 17 | } 18 | 19 | fun unregisterCallback(callback: () -> Unit) { 20 | callbacks.remove(callback) 21 | } 22 | 23 | @OptIn(DelicateCoroutinesApi::class) 24 | private val instance by lazy { 25 | Application.application.getDatabasePath(Path.PROFILES_DATABASE_PATH).parentFile?.mkdirs() 26 | Room 27 | .databaseBuilder( 28 | Application.application, 29 | ProfileDatabase::class.java, 30 | Path.PROFILES_DATABASE_PATH 31 | ) 32 | .fallbackToDestructiveMigration() 33 | .enableMultiInstanceInvalidation() 34 | .setQueryExecutor { GlobalScope.launch { it.run() } } 35 | .build() 36 | } 37 | 38 | suspend fun nextOrder(): Long { 39 | return instance.profileDao().nextOrder() ?: 0 40 | } 41 | 42 | suspend fun nextFileID(): Long { 43 | return instance.profileDao().nextFileID() ?: 1 44 | } 45 | 46 | 47 | suspend fun get(id: Long): Profile? { 48 | return instance.profileDao().get(id) 49 | } 50 | 51 | suspend fun create(profile: Profile): Profile { 52 | profile.id = instance.profileDao().insert(profile) 53 | for (callback in callbacks.toList()) { 54 | callback() 55 | } 56 | return profile 57 | } 58 | 59 | suspend fun update(profile: Profile): Int { 60 | try { 61 | return instance.profileDao().update(profile) 62 | } finally { 63 | for (callback in callbacks.toList()) { 64 | callback() 65 | } 66 | } 67 | } 68 | 69 | suspend fun update(profiles: List): Int { 70 | try { 71 | return instance.profileDao().update(profiles) 72 | } finally { 73 | for (callback in callbacks.toList()) { 74 | callback() 75 | } 76 | } 77 | } 78 | 79 | suspend fun delete(profile: Profile): Int { 80 | try { 81 | return instance.profileDao().delete(profile) 82 | } finally { 83 | for (callback in callbacks.toList()) { 84 | callback() 85 | } 86 | } 87 | } 88 | 89 | suspend fun delete(profiles: List): Int { 90 | try { 91 | return instance.profileDao().delete(profiles) 92 | } finally { 93 | for (callback in callbacks.toList()) { 94 | callback() 95 | } 96 | } 97 | } 98 | 99 | suspend fun list(): List { 100 | return instance.profileDao().list() 101 | } 102 | 103 | } -------------------------------------------------------------------------------- /app/src/main/java/io/nekohasekai/sfa/database/Settings.kt: -------------------------------------------------------------------------------- 1 | package io.nekohasekai.sfa.database 2 | 3 | import androidx.room.Room 4 | import io.nekohasekai.sfa.Application 5 | import io.nekohasekai.sfa.bg.ProxyService 6 | import io.nekohasekai.sfa.bg.VPNService 7 | import io.nekohasekai.sfa.constant.Path 8 | import io.nekohasekai.sfa.constant.ServiceMode 9 | import io.nekohasekai.sfa.constant.SettingsKey 10 | import io.nekohasekai.sfa.database.preference.KeyValueDatabase 11 | import io.nekohasekai.sfa.database.preference.RoomPreferenceDataStore 12 | import io.nekohasekai.sfa.ktx.boolean 13 | import io.nekohasekai.sfa.ktx.int 14 | import io.nekohasekai.sfa.ktx.long 15 | import io.nekohasekai.sfa.ktx.string 16 | import io.nekohasekai.sfa.ktx.stringSet 17 | import kotlinx.coroutines.DelicateCoroutinesApi 18 | import kotlinx.coroutines.GlobalScope 19 | import kotlinx.coroutines.launch 20 | import org.json.JSONObject 21 | import java.io.File 22 | 23 | object Settings { 24 | 25 | @OptIn(DelicateCoroutinesApi::class) 26 | private val instance by lazy { 27 | Application.application.getDatabasePath(Path.SETTINGS_DATABASE_PATH).parentFile?.mkdirs() 28 | Room.databaseBuilder( 29 | Application.application, 30 | KeyValueDatabase::class.java, 31 | Path.SETTINGS_DATABASE_PATH 32 | ).allowMainThreadQueries() 33 | .fallbackToDestructiveMigration() 34 | .enableMultiInstanceInvalidation() 35 | .setQueryExecutor { GlobalScope.launch { it.run() } } 36 | .build() 37 | } 38 | val dataStore = RoomPreferenceDataStore(instance.keyValuePairDao()) 39 | var selectedProfile by dataStore.long(SettingsKey.SELECTED_PROFILE) { -1L } 40 | var serviceMode by dataStore.string(SettingsKey.SERVICE_MODE) { ServiceMode.NORMAL } 41 | var startedByUser by dataStore.boolean(SettingsKey.STARTED_BY_USER) 42 | 43 | var checkUpdateEnabled by dataStore.boolean(SettingsKey.CHECK_UPDATE_ENABLED) { true } 44 | var disableMemoryLimit by dataStore.boolean(SettingsKey.DISABLE_MEMORY_LIMIT) 45 | var dynamicNotification by dataStore.boolean(SettingsKey.DYNAMIC_NOTIFICATION) { true } 46 | 47 | 48 | const val PER_APP_PROXY_DISABLED = 0 49 | const val PER_APP_PROXY_EXCLUDE = 1 50 | const val PER_APP_PROXY_INCLUDE = 2 51 | 52 | var perAppProxyEnabled by dataStore.boolean(SettingsKey.PER_APP_PROXY_ENABLED) { false } 53 | var perAppProxyMode by dataStore.int(SettingsKey.PER_APP_PROXY_MODE) { PER_APP_PROXY_EXCLUDE } 54 | var perAppProxyList by dataStore.stringSet(SettingsKey.PER_APP_PROXY_LIST) { emptySet() } 55 | var perAppProxyUpdateOnChange by dataStore.int(SettingsKey.PER_APP_PROXY_UPDATE_ON_CHANGE) { PER_APP_PROXY_DISABLED } 56 | 57 | var systemProxyEnabled by dataStore.boolean(SettingsKey.SYSTEM_PROXY_ENABLED) { true } 58 | 59 | fun serviceClass(): Class<*> { 60 | return when (serviceMode) { 61 | ServiceMode.VPN -> VPNService::class.java 62 | else -> ProxyService::class.java 63 | } 64 | } 65 | 66 | suspend fun rebuildServiceMode(): Boolean { 67 | var newMode = ServiceMode.NORMAL 68 | try { 69 | if (needVPNService()) { 70 | newMode = ServiceMode.VPN 71 | } 72 | } catch (_: Exception) { 73 | } 74 | if (serviceMode == newMode) { 75 | return false 76 | } 77 | serviceMode = newMode 78 | return true 79 | } 80 | 81 | private suspend fun needVPNService(): Boolean { 82 | val selectedProfileId = selectedProfile 83 | if (selectedProfileId == -1L) return false 84 | val profile = ProfileManager.get(selectedProfile) ?: return false 85 | val content = JSONObject(File(profile.typed.path).readText()) 86 | val inbounds = content.getJSONArray("inbounds") 87 | for (index in 0 until inbounds.length()) { 88 | val inbound = inbounds.getJSONObject(index) 89 | if (inbound.getString("type") == "tun") { 90 | return true 91 | } 92 | } 93 | return false 94 | } 95 | 96 | } -------------------------------------------------------------------------------- /app/src/main/java/io/nekohasekai/sfa/database/TypedProfile.kt: -------------------------------------------------------------------------------- 1 | package io.nekohasekai.sfa.database 2 | 3 | import android.content.Context 4 | import android.os.Parcel 5 | import android.os.Parcelable 6 | import androidx.room.TypeConverter 7 | import io.nekohasekai.sfa.R 8 | import io.nekohasekai.sfa.database.TypedProfile.Type.values 9 | import io.nekohasekai.sfa.ktx.marshall 10 | import io.nekohasekai.sfa.ktx.unmarshall 11 | import java.util.Date 12 | 13 | class TypedProfile() : Parcelable { 14 | 15 | enum class Type { 16 | Local, Remote; 17 | 18 | fun getString(context: Context): String { 19 | return when (this) { 20 | Local -> context.getString(R.string.profile_type_local) 21 | Remote -> context.getString(R.string.profile_type_remote) 22 | } 23 | } 24 | 25 | companion object { 26 | fun valueOf(value: Int): Type { 27 | for (it in values()) { 28 | if (it.ordinal == value) { 29 | return it 30 | } 31 | } 32 | return Local 33 | } 34 | } 35 | } 36 | 37 | var path = "" 38 | var type = Type.Local 39 | var remoteURL: String = "" 40 | var lastUpdated: Date = Date(0) 41 | var autoUpdate: Boolean = false 42 | var autoUpdateInterval = 60 43 | 44 | constructor(reader: Parcel) : this() { 45 | val version = reader.readInt() 46 | path = reader.readString() ?: "" 47 | type = Type.valueOf(reader.readInt()) 48 | remoteURL = reader.readString() ?: "" 49 | autoUpdate = reader.readInt() == 1 50 | lastUpdated = Date(reader.readLong()) 51 | if (version >= 1) { 52 | autoUpdateInterval = reader.readInt() 53 | } 54 | } 55 | 56 | override fun writeToParcel(writer: Parcel, flags: Int) { 57 | writer.writeInt(1) 58 | writer.writeString(path) 59 | writer.writeInt(type.ordinal) 60 | writer.writeString(remoteURL) 61 | writer.writeInt(if (autoUpdate) 1 else 0) 62 | writer.writeLong(lastUpdated.time) 63 | writer.writeInt(autoUpdateInterval) 64 | } 65 | 66 | override fun describeContents(): Int { 67 | return 0 68 | } 69 | 70 | companion object CREATOR : Parcelable.Creator { 71 | override fun createFromParcel(parcel: Parcel): TypedProfile { 72 | return TypedProfile(parcel) 73 | } 74 | 75 | override fun newArray(size: Int): Array { 76 | return arrayOfNulls(size) 77 | } 78 | } 79 | 80 | class Convertor { 81 | 82 | @TypeConverter 83 | fun marshall(profile: TypedProfile) = profile.marshall() 84 | 85 | @TypeConverter 86 | fun unmarshall(content: ByteArray) = 87 | content.unmarshall(::TypedProfile) 88 | 89 | } 90 | 91 | } -------------------------------------------------------------------------------- /app/src/main/java/io/nekohasekai/sfa/database/preference/KeyValueDatabase.kt: -------------------------------------------------------------------------------- 1 | package io.nekohasekai.sfa.database.preference 2 | 3 | import androidx.room.Database 4 | import androidx.room.RoomDatabase 5 | 6 | @Database( 7 | entities = [KeyValueEntity::class], version = 1 8 | ) 9 | abstract class KeyValueDatabase : RoomDatabase() { 10 | 11 | abstract fun keyValuePairDao(): KeyValueEntity.Dao 12 | 13 | } 14 | -------------------------------------------------------------------------------- /app/src/main/java/io/nekohasekai/sfa/database/preference/KeyValueEntity.kt: -------------------------------------------------------------------------------- 1 | package io.nekohasekai.sfa.database.preference 2 | 3 | import android.os.Parcel 4 | import android.os.Parcelable 5 | import androidx.room.Entity 6 | import androidx.room.Ignore 7 | import androidx.room.Insert 8 | import androidx.room.OnConflictStrategy 9 | import androidx.room.PrimaryKey 10 | import androidx.room.Query 11 | import java.io.ByteArrayOutputStream 12 | import java.nio.ByteBuffer 13 | 14 | @Entity 15 | class KeyValueEntity() : Parcelable { 16 | companion object { 17 | const val TYPE_UNINITIALIZED = 0 18 | const val TYPE_BOOLEAN = 1 19 | const val TYPE_FLOAT = 2 20 | const val TYPE_LONG = 3 21 | const val TYPE_STRING = 4 22 | const val TYPE_STRING_SET = 5 23 | 24 | @JvmField 25 | val CREATOR = object : Parcelable.Creator { 26 | override fun createFromParcel(parcel: Parcel): KeyValueEntity { 27 | return KeyValueEntity(parcel) 28 | } 29 | 30 | override fun newArray(size: Int): Array { 31 | return arrayOfNulls(size) 32 | } 33 | } 34 | } 35 | 36 | @androidx.room.Dao 37 | interface Dao { 38 | 39 | @Query("SELECT * FROM KeyValueEntity") 40 | fun all(): List 41 | 42 | @Query("SELECT * FROM KeyValueEntity WHERE `key` = :key") 43 | operator fun get(key: String): KeyValueEntity? 44 | 45 | @Insert(onConflict = OnConflictStrategy.REPLACE) 46 | fun put(value: KeyValueEntity): Long 47 | 48 | @Query("DELETE FROM KeyValueEntity WHERE `key` = :key") 49 | fun delete(key: String): Int 50 | 51 | @Query("DELETE FROM KeyValueEntity") 52 | fun reset(): Int 53 | 54 | @Insert 55 | fun insert(list: List) 56 | } 57 | 58 | @PrimaryKey 59 | var key: String = "" 60 | var valueType: Int = TYPE_UNINITIALIZED 61 | var value: ByteArray = ByteArray(0) 62 | 63 | val boolean: Boolean? 64 | get() = if (valueType == TYPE_BOOLEAN) ByteBuffer.wrap(value).get() != 0.toByte() else null 65 | val float: Float? 66 | get() = if (valueType == TYPE_FLOAT) ByteBuffer.wrap(value).float else null 67 | 68 | val long: Long 69 | get() = ByteBuffer.wrap(value).long 70 | 71 | val string: String? 72 | get() = if (valueType == TYPE_STRING) String(value) else null 73 | val stringSet: Set? 74 | get() = if (valueType == TYPE_STRING_SET) { 75 | val buffer = ByteBuffer.wrap(value) 76 | val result = HashSet() 77 | while (buffer.hasRemaining()) { 78 | val chArr = ByteArray(buffer.int) 79 | buffer.get(chArr) 80 | result.add(String(chArr)) 81 | } 82 | result 83 | } else null 84 | 85 | @Ignore 86 | constructor(key: String) : this() { 87 | this.key = key 88 | } 89 | 90 | // putting null requires using DataStore 91 | fun put(value: Boolean): KeyValueEntity { 92 | valueType = TYPE_BOOLEAN 93 | this.value = ByteBuffer.allocate(1).put((if (value) 1 else 0).toByte()).array() 94 | return this 95 | } 96 | 97 | fun put(value: Float): KeyValueEntity { 98 | valueType = TYPE_FLOAT 99 | this.value = ByteBuffer.allocate(4).putFloat(value).array() 100 | return this 101 | } 102 | 103 | fun put(value: Long): KeyValueEntity { 104 | valueType = TYPE_LONG 105 | this.value = ByteBuffer.allocate(8).putLong(value).array() 106 | return this 107 | } 108 | 109 | fun put(value: String): KeyValueEntity { 110 | valueType = TYPE_STRING 111 | this.value = value.toByteArray() 112 | return this 113 | } 114 | 115 | fun put(value: Set): KeyValueEntity { 116 | valueType = TYPE_STRING_SET 117 | val stream = ByteArrayOutputStream() 118 | val intBuffer = ByteBuffer.allocate(4) 119 | for (v in value) { 120 | intBuffer.rewind() 121 | stream.write(intBuffer.putInt(v.length).array()) 122 | stream.write(v.toByteArray()) 123 | } 124 | this.value = stream.toByteArray() 125 | return this 126 | } 127 | 128 | @Suppress("IMPLICIT_CAST_TO_ANY") 129 | override fun toString(): String { 130 | return when (valueType) { 131 | TYPE_BOOLEAN -> boolean 132 | TYPE_FLOAT -> float 133 | TYPE_LONG -> long 134 | TYPE_STRING -> string 135 | TYPE_STRING_SET -> stringSet 136 | else -> null 137 | }?.toString() ?: "null" 138 | } 139 | 140 | constructor(parcel: Parcel) : this() { 141 | key = parcel.readString()!! 142 | valueType = parcel.readInt() 143 | value = parcel.createByteArray()!! 144 | } 145 | 146 | override fun writeToParcel(parcel: Parcel, flags: Int) { 147 | parcel.writeString(key) 148 | parcel.writeInt(valueType) 149 | parcel.writeByteArray(value) 150 | } 151 | 152 | override fun describeContents(): Int { 153 | return 0 154 | } 155 | 156 | } 157 | -------------------------------------------------------------------------------- /app/src/main/java/io/nekohasekai/sfa/database/preference/OnPreferenceDataStoreChangeListener.kt: -------------------------------------------------------------------------------- 1 | package io.nekohasekai.sfa.database.preference 2 | 3 | import androidx.preference.PreferenceDataStore 4 | 5 | interface OnPreferenceDataStoreChangeListener { 6 | fun onPreferenceDataStoreChanged(store: PreferenceDataStore, key: String) 7 | } 8 | -------------------------------------------------------------------------------- /app/src/main/java/io/nekohasekai/sfa/database/preference/RoomPreferenceDataStore.kt: -------------------------------------------------------------------------------- 1 | package io.nekohasekai.sfa.database.preference 2 | 3 | import androidx.preference.PreferenceDataStore 4 | 5 | @Suppress("MemberVisibilityCanBePrivate", "unused") 6 | open class RoomPreferenceDataStore(private val kvPairDao: KeyValueEntity.Dao) : 7 | PreferenceDataStore() { 8 | 9 | fun getBoolean(key: String) = kvPairDao[key]?.boolean 10 | fun getFloat(key: String) = kvPairDao[key]?.float 11 | fun getInt(key: String) = kvPairDao[key]?.long?.toInt() 12 | fun getLong(key: String) = kvPairDao[key]?.long 13 | fun getString(key: String) = kvPairDao[key]?.string 14 | fun getStringSet(key: String) = kvPairDao[key]?.stringSet 15 | fun reset() = kvPairDao.reset() 16 | 17 | override fun getBoolean(key: String, defValue: Boolean) = getBoolean(key) ?: defValue 18 | override fun getFloat(key: String, defValue: Float) = getFloat(key) ?: defValue 19 | override fun getInt(key: String, defValue: Int) = getInt(key) ?: defValue 20 | override fun getLong(key: String, defValue: Long) = getLong(key) ?: defValue 21 | override fun getString(key: String, defValue: String?) = getString(key) ?: defValue 22 | override fun getStringSet(key: String, defValue: MutableSet?) = 23 | getStringSet(key) ?: defValue 24 | 25 | fun putBoolean(key: String, value: Boolean?) = 26 | if (value == null) remove(key) else putBoolean(key, value) 27 | 28 | fun putFloat(key: String, value: Float?) = 29 | if (value == null) remove(key) else putFloat(key, value) 30 | 31 | fun putInt(key: String, value: Int?) = 32 | if (value == null) remove(key) else putLong(key, value.toLong()) 33 | 34 | fun putLong(key: String, value: Long?) = if (value == null) remove(key) else putLong(key, value) 35 | override fun putBoolean(key: String, value: Boolean) { 36 | kvPairDao.put(KeyValueEntity(key).put(value)) 37 | fireChangeListener(key) 38 | } 39 | 40 | override fun putFloat(key: String, value: Float) { 41 | kvPairDao.put(KeyValueEntity(key).put(value)) 42 | fireChangeListener(key) 43 | } 44 | 45 | override fun putInt(key: String, value: Int) { 46 | kvPairDao.put(KeyValueEntity(key).put(value.toLong())) 47 | fireChangeListener(key) 48 | } 49 | 50 | override fun putLong(key: String, value: Long) { 51 | kvPairDao.put(KeyValueEntity(key).put(value)) 52 | fireChangeListener(key) 53 | } 54 | 55 | override fun putString(key: String, value: String?) = if (value == null) remove(key) else { 56 | kvPairDao.put(KeyValueEntity(key).put(value)) 57 | fireChangeListener(key) 58 | } 59 | 60 | override fun putStringSet(key: String, values: MutableSet?) = 61 | if (values == null) remove(key) else { 62 | kvPairDao.put(KeyValueEntity(key).put(values)) 63 | fireChangeListener(key) 64 | } 65 | 66 | fun remove(key: String) { 67 | kvPairDao.delete(key) 68 | fireChangeListener(key) 69 | } 70 | 71 | private val listeners = HashSet() 72 | private fun fireChangeListener(key: String) { 73 | val listeners = synchronized(listeners) { 74 | listeners.toList() 75 | } 76 | listeners.forEach { it.onPreferenceDataStoreChanged(this, key) } 77 | } 78 | 79 | fun registerChangeListener(listener: OnPreferenceDataStoreChangeListener) { 80 | synchronized(listeners) { 81 | listeners.add(listener) 82 | } 83 | } 84 | 85 | fun unregisterChangeListener(listener: OnPreferenceDataStoreChangeListener) { 86 | synchronized(listeners) { 87 | listeners.remove(listener) 88 | } 89 | } 90 | } -------------------------------------------------------------------------------- /app/src/main/java/io/nekohasekai/sfa/ktx/Browsers.kt: -------------------------------------------------------------------------------- 1 | package io.nekohasekai.sfa.ktx 2 | 3 | import android.content.Context 4 | import android.net.Uri 5 | import androidx.browser.customtabs.CustomTabColorSchemeParams 6 | import androidx.browser.customtabs.CustomTabsIntent 7 | import com.google.android.material.elevation.SurfaceColors 8 | 9 | fun Context.launchCustomTab(link: String) { 10 | val color = SurfaceColors.SURFACE_2.getColor(this) 11 | CustomTabsIntent.Builder().apply { 12 | setColorScheme(CustomTabsIntent.COLOR_SCHEME_SYSTEM) 13 | setColorSchemeParams( 14 | CustomTabsIntent.COLOR_SCHEME_LIGHT, 15 | CustomTabColorSchemeParams.Builder().apply { 16 | setToolbarColor(color) 17 | }.build() 18 | ) 19 | setColorSchemeParams( 20 | CustomTabsIntent.COLOR_SCHEME_DARK, 21 | CustomTabColorSchemeParams.Builder().apply { 22 | setToolbarColor(color) 23 | }.build() 24 | ) 25 | }.build().launchUrl(this, Uri.parse(link)) 26 | } -------------------------------------------------------------------------------- /app/src/main/java/io/nekohasekai/sfa/ktx/Clips.kt: -------------------------------------------------------------------------------- 1 | package io.nekohasekai.sfa.ktx 2 | 3 | import android.content.ClipData 4 | import io.nekohasekai.sfa.Application 5 | 6 | var clipboardText: String? 7 | get() = Application.clipboard.primaryClip?.getItemAt(0)?.text?.toString() 8 | set(plainText) { 9 | if (plainText != null) { 10 | Application.clipboard.setPrimaryClip(ClipData.newPlainText(null, plainText)) 11 | } 12 | } -------------------------------------------------------------------------------- /app/src/main/java/io/nekohasekai/sfa/ktx/Colors.kt: -------------------------------------------------------------------------------- 1 | package io.nekohasekai.sfa.ktx 2 | 3 | import android.content.Context 4 | import android.graphics.Color 5 | import android.os.Build 6 | import android.util.TypedValue 7 | import androidx.annotation.AttrRes 8 | import androidx.annotation.ColorInt 9 | import androidx.core.content.ContextCompat 10 | import com.google.android.material.color.MaterialColors 11 | 12 | 13 | @ColorInt 14 | fun Context.getAttrColor( 15 | @AttrRes attrColor: Int, 16 | typedValue: TypedValue = TypedValue(), 17 | resolveRefs: Boolean = true 18 | ): Int { 19 | theme.resolveAttribute(attrColor, typedValue, resolveRefs) 20 | return typedValue.data 21 | } 22 | 23 | @ColorInt 24 | fun colorForURLTestDelay(context: Context, urlTestDelay: Int): Int { 25 | if (urlTestDelay <= 0) { 26 | return Color.GRAY 27 | } 28 | val colorRes = 29 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && context.resources.configuration.isNightModeActive) { 30 | if (urlTestDelay <= 800) { 31 | android.R.color.holo_green_dark 32 | } else if (urlTestDelay <= 1500) { 33 | android.R.color.holo_orange_dark 34 | } else { 35 | android.R.color.holo_red_dark 36 | } 37 | } else { 38 | if (urlTestDelay <= 800) { 39 | android.R.color.holo_green_light 40 | } else if (urlTestDelay <= 1500) { 41 | android.R.color.holo_orange_light 42 | } else { 43 | android.R.color.holo_red_light 44 | } 45 | } 46 | return MaterialColors.harmonizeWithPrimary(context, ContextCompat.getColor(context, colorRes)) 47 | } -------------------------------------------------------------------------------- /app/src/main/java/io/nekohasekai/sfa/ktx/Context.kt: -------------------------------------------------------------------------------- 1 | package io.nekohasekai.sfa.ktx 2 | 3 | import android.content.Context 4 | import android.content.pm.PackageManager 5 | import androidx.core.content.ContextCompat 6 | 7 | fun Context.hasPermission(permission: String): Boolean { 8 | return ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED 9 | } 10 | -------------------------------------------------------------------------------- /app/src/main/java/io/nekohasekai/sfa/ktx/Continuations.kt: -------------------------------------------------------------------------------- 1 | package io.nekohasekai.sfa.ktx 2 | 3 | import kotlin.coroutines.Continuation 4 | 5 | 6 | fun Continuation.tryResume(value: T) { 7 | try { 8 | resumeWith(Result.success(value)) 9 | } catch (ignored: IllegalStateException) { 10 | } 11 | } 12 | 13 | fun Continuation.tryResumeWithException(exception: Throwable) { 14 | try { 15 | resumeWith(Result.failure(exception)) 16 | } catch (ignored: IllegalStateException) { 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /app/src/main/java/io/nekohasekai/sfa/ktx/Dialogs.kt: -------------------------------------------------------------------------------- 1 | package io.nekohasekai.sfa.ktx 2 | 3 | import android.content.Context 4 | import androidx.annotation.StringRes 5 | import com.google.android.material.dialog.MaterialAlertDialogBuilder 6 | import io.nekohasekai.sfa.R 7 | 8 | fun Context.errorDialogBuilder(@StringRes messageId: Int): MaterialAlertDialogBuilder { 9 | return MaterialAlertDialogBuilder(this) 10 | .setTitle(R.string.error_title) 11 | .setMessage(messageId) 12 | .setPositiveButton(android.R.string.ok, null) 13 | } 14 | 15 | fun Context.errorDialogBuilder(message: String): MaterialAlertDialogBuilder { 16 | return MaterialAlertDialogBuilder(this) 17 | .setTitle(R.string.error_title) 18 | .setMessage(message) 19 | .setPositiveButton(android.R.string.ok, null) 20 | } 21 | 22 | fun Context.errorDialogBuilder(exception: Throwable): MaterialAlertDialogBuilder { 23 | return errorDialogBuilder(exception.localizedMessage ?: exception.toString()) 24 | } -------------------------------------------------------------------------------- /app/src/main/java/io/nekohasekai/sfa/ktx/Dimens.kt: -------------------------------------------------------------------------------- 1 | package io.nekohasekai.sfa.ktx 2 | 3 | import android.content.res.Resources 4 | import kotlin.math.ceil 5 | 6 | private val density = Resources.getSystem().displayMetrics.density 7 | 8 | fun dp2pxf(dpValue: Int): Float { 9 | return density * dpValue 10 | } 11 | 12 | fun dp2px(dpValue: Int): Int { 13 | return ceil(dp2pxf(dpValue)).toInt() 14 | } -------------------------------------------------------------------------------- /app/src/main/java/io/nekohasekai/sfa/ktx/Inputs.kt: -------------------------------------------------------------------------------- 1 | package io.nekohasekai.sfa.ktx 2 | 3 | import androidx.annotation.ArrayRes 4 | import androidx.core.widget.addTextChangedListener 5 | import com.google.android.material.textfield.MaterialAutoCompleteTextView 6 | import com.google.android.material.textfield.TextInputLayout 7 | import io.nekohasekai.sfa.R 8 | 9 | var TextInputLayout.text: String 10 | get() = editText?.text?.toString() ?: "" 11 | set(value) { 12 | editText?.setText(value) 13 | } 14 | 15 | var TextInputLayout.error: String 16 | get() = editText?.error?.toString() ?: "" 17 | set(value) { 18 | editText?.error = value 19 | } 20 | 21 | 22 | fun TextInputLayout.setSimpleItems(@ArrayRes redId: Int) { 23 | (editText as? MaterialAutoCompleteTextView)?.setSimpleItems(redId) 24 | } 25 | 26 | fun TextInputLayout.removeErrorIfNotEmpty() { 27 | addOnEditTextAttachedListener { 28 | editText?.addTextChangedListener { 29 | if (text.isNotBlank()) { 30 | error = null 31 | } 32 | } 33 | } 34 | } 35 | 36 | fun TextInputLayout.showErrorIfEmpty(): Boolean { 37 | if (text.isBlank()) { 38 | error = context.getString(R.string.profile_input_required) 39 | return true 40 | } 41 | return false 42 | } 43 | 44 | 45 | fun TextInputLayout.addTextChangedListener(listener: (String) -> Unit) { 46 | addOnEditTextAttachedListener { 47 | editText?.addTextChangedListener { 48 | listener(it?.toString() ?: "") 49 | } 50 | } 51 | } -------------------------------------------------------------------------------- /app/src/main/java/io/nekohasekai/sfa/ktx/Intents.kt: -------------------------------------------------------------------------------- 1 | package io.nekohasekai.sfa.ktx 2 | 3 | import android.app.Activity 4 | import android.content.ActivityNotFoundException 5 | import androidx.activity.result.ActivityResultLauncher 6 | import com.google.android.material.dialog.MaterialAlertDialogBuilder 7 | import io.nekohasekai.sfa.R 8 | 9 | fun Activity.startFilesForResult( 10 | launcher: ActivityResultLauncher, input: String 11 | ) { 12 | try { 13 | return launcher.launch(input) 14 | } catch (_: ActivityNotFoundException) { 15 | } catch (_: SecurityException) { 16 | } 17 | val builder = MaterialAlertDialogBuilder(this) 18 | builder.setPositiveButton(resources.getString(android.R.string.ok), null) 19 | builder.setMessage(R.string.file_manager_missing) 20 | builder.show() 21 | } -------------------------------------------------------------------------------- /app/src/main/java/io/nekohasekai/sfa/ktx/Preferences.kt: -------------------------------------------------------------------------------- 1 | package io.nekohasekai.sfa.ktx 2 | 3 | import androidx.preference.PreferenceDataStore 4 | import kotlin.reflect.KProperty 5 | 6 | fun PreferenceDataStore.string( 7 | name: String, 8 | defaultValue: () -> String = { "" }, 9 | ) = PreferenceProxy(name, defaultValue, ::getString, ::putString) 10 | 11 | fun PreferenceDataStore.stringNotBlack( 12 | name: String, 13 | defaultValue: () -> String = { "" }, 14 | ) = PreferenceProxy(name, defaultValue, { key, default -> 15 | getString(key, default)?.takeIf { it.isNotBlank() } ?: default 16 | }, { key, value -> 17 | putString(key, value.takeIf { it.isNotBlank() } ?: defaultValue()) 18 | }) 19 | 20 | fun PreferenceDataStore.boolean( 21 | name: String, 22 | defaultValue: () -> Boolean = { false }, 23 | ) = PreferenceProxy(name, defaultValue, ::getBoolean, ::putBoolean) 24 | 25 | fun PreferenceDataStore.int( 26 | name: String, 27 | defaultValue: () -> Int = { 0 }, 28 | ) = PreferenceProxy(name, defaultValue, ::getInt, ::putInt) 29 | 30 | fun PreferenceDataStore.stringToInt( 31 | name: String, 32 | defaultValue: () -> Int = { 0 }, 33 | ) = PreferenceProxy(name, defaultValue, { key, default -> 34 | getString(key, "$default")?.toIntOrNull() ?: default 35 | }, { key, value -> putString(key, "$value") }) 36 | 37 | fun PreferenceDataStore.stringToIntIfExists( 38 | name: String, 39 | defaultValue: () -> Int = { 0 }, 40 | ) = PreferenceProxy(name, defaultValue, { key, default -> 41 | getString(key, "$default")?.toIntOrNull() ?: default 42 | }, { key, value -> putString(key, value.takeIf { it > 0 }?.toString() ?: "") }) 43 | 44 | fun PreferenceDataStore.long( 45 | name: String, 46 | defaultValue: () -> Long = { 0L }, 47 | ) = PreferenceProxy(name, defaultValue, ::getLong, ::putLong) 48 | 49 | fun PreferenceDataStore.stringToLong( 50 | name: String, 51 | defaultValue: () -> Long = { 0L }, 52 | ) = PreferenceProxy(name, defaultValue, { key, default -> 53 | getString(key, "$default")?.toLongOrNull() ?: default 54 | }, { key, value -> putString(key, "$value") }) 55 | 56 | fun PreferenceDataStore.stringSet( 57 | name: String, 58 | defaultValue: () -> Set = { emptySet() } 59 | ) = PreferenceProxy(name, defaultValue, ::getStringSet, ::putStringSet) 60 | 61 | class PreferenceProxy( 62 | val name: String, 63 | val defaultValue: () -> T, 64 | val getter: (String, T) -> T?, 65 | val setter: (String, value: T) -> Unit, 66 | ) { 67 | 68 | operator fun setValue(thisObj: Any?, property: KProperty<*>, value: T) = setter(name, value) 69 | operator fun getValue(thisObj: Any?, property: KProperty<*>) = getter(name, defaultValue())!! 70 | 71 | } -------------------------------------------------------------------------------- /app/src/main/java/io/nekohasekai/sfa/ktx/Room.kt: -------------------------------------------------------------------------------- 1 | package io.nekohasekai.sfa.ktx 2 | 3 | import android.os.Parcel 4 | import android.os.Parcelable 5 | 6 | fun Parcelable.marshall(): ByteArray { 7 | val parcel = Parcel.obtain() 8 | writeToParcel(parcel, 0) 9 | val content = parcel.marshall() 10 | parcel.recycle() 11 | return content 12 | } 13 | 14 | fun ByteArray.unmarshall(constructor: (Parcel) -> T): T { 15 | val parcel = Parcel.obtain() 16 | parcel.unmarshall(this, 0, size) 17 | parcel.setDataPosition(0) // This is extremely important! 18 | val result = constructor(parcel) 19 | parcel.recycle() 20 | return result 21 | } -------------------------------------------------------------------------------- /app/src/main/java/io/nekohasekai/sfa/ktx/Shares.kt: -------------------------------------------------------------------------------- 1 | package io.nekohasekai.sfa.ktx 2 | 3 | import android.content.Context 4 | import android.content.Intent 5 | import android.graphics.Bitmap 6 | import android.graphics.Color 7 | import androidx.core.content.FileProvider 8 | import androidx.fragment.app.FragmentActivity 9 | import com.google.android.material.R 10 | import com.google.zxing.BarcodeFormat 11 | import com.google.zxing.qrcode.QRCodeWriter 12 | import io.nekohasekai.libbox.Libbox 13 | import io.nekohasekai.libbox.ProfileContent 14 | import io.nekohasekai.sfa.database.Profile 15 | import io.nekohasekai.sfa.database.TypedProfile 16 | import io.nekohasekai.sfa.ui.shared.QRCodeDialog 17 | import kotlinx.coroutines.Dispatchers 18 | import kotlinx.coroutines.withContext 19 | import java.io.File 20 | 21 | suspend fun Context.shareProfile(profile: Profile) { 22 | val content = ProfileContent() 23 | content.name = profile.name 24 | when (profile.typed.type) { 25 | TypedProfile.Type.Local -> { 26 | content.type = io.nekohasekai.libbox.Libbox.ProfileTypeLocal 27 | } 28 | 29 | TypedProfile.Type.Remote -> { 30 | content.type = io.nekohasekai.libbox.Libbox.ProfileTypeRemote 31 | } 32 | } 33 | content.config = File(profile.typed.path).readText() 34 | content.remotePath = profile.typed.remoteURL 35 | content.autoUpdate = profile.typed.autoUpdate 36 | content.autoUpdateInterval = profile.typed.autoUpdateInterval 37 | content.lastUpdated = profile.typed.lastUpdated.time 38 | 39 | val configDirectory = File(cacheDir, "share").also { it.mkdirs() } 40 | val profileFile = File(configDirectory, "${profile.name}.bpf") 41 | profileFile.writeBytes(content.encode()) 42 | val uri = FileProvider.getUriForFile(this, "$packageName.cache", profileFile) 43 | withContext(Dispatchers.Main) { 44 | startActivity( 45 | Intent.createChooser( 46 | Intent(Intent.ACTION_SEND).setType("application/octet-stream") 47 | .setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) 48 | .putExtra(Intent.EXTRA_STREAM, uri), 49 | getString(R.string.abc_shareactionprovider_share_with) 50 | ) 51 | ) 52 | } 53 | } 54 | 55 | fun FragmentActivity.shareProfileURL(profile: Profile) { 56 | val link = Libbox.generateRemoteProfileImportLink( 57 | profile.name, 58 | profile.typed.remoteURL 59 | ) 60 | val imageSize = dp2px(256) 61 | val color = getAttrColor(com.google.android.material.R.attr.colorPrimary) 62 | val image = QRCodeWriter().encode(link, BarcodeFormat.QR_CODE, imageSize, imageSize, null) 63 | val imageWidth = image.width 64 | val imageHeight = image.height 65 | val imageArray = IntArray(imageWidth * imageHeight) 66 | for (y in 0 until imageHeight) { 67 | val offset = y * imageWidth 68 | for (x in 0 until imageWidth) { 69 | imageArray[offset + x] = if (image.get(x, y)) color else Color.TRANSPARENT 70 | 71 | } 72 | } 73 | val bitmap = Bitmap.createBitmap(imageWidth, imageHeight, Bitmap.Config.ARGB_8888) 74 | bitmap.setPixels(imageArray, 0, imageSize, 0, 0, imageWidth, imageHeight) 75 | val dialog = QRCodeDialog(bitmap) 76 | dialog.show(supportFragmentManager, "share-profile-url") 77 | } -------------------------------------------------------------------------------- /app/src/main/java/io/nekohasekai/sfa/ktx/Wrappers.kt: -------------------------------------------------------------------------------- 1 | package io.nekohasekai.sfa.ktx 2 | 3 | import android.net.IpPrefix 4 | import android.os.Build 5 | import androidx.annotation.RequiresApi 6 | import io.nekohasekai.libbox.RoutePrefix 7 | import io.nekohasekai.libbox.StringBox 8 | import io.nekohasekai.libbox.StringIterator 9 | import java.net.InetAddress 10 | 11 | val StringBox?.unwrap: String 12 | get() { 13 | if (this == null) return "" 14 | return value 15 | } 16 | 17 | fun Iterable.toStringIterator(): StringIterator { 18 | return object : StringIterator { 19 | val iterator = iterator() 20 | 21 | override fun len(): Int { 22 | // not used by core 23 | return 0 24 | } 25 | 26 | override fun hasNext(): Boolean { 27 | return iterator.hasNext() 28 | } 29 | 30 | override fun next(): String { 31 | return iterator.next() 32 | } 33 | } 34 | } 35 | 36 | fun StringIterator.toList(): List { 37 | return mutableListOf().apply { 38 | while (hasNext()) { 39 | add(next()) 40 | } 41 | } 42 | } 43 | 44 | @RequiresApi(Build.VERSION_CODES.TIRAMISU) 45 | fun RoutePrefix.toIpPrefix() = IpPrefix(InetAddress.getByName(address()), prefix()) -------------------------------------------------------------------------------- /app/src/main/java/io/nekohasekai/sfa/ui/ShortcutActivity.kt: -------------------------------------------------------------------------------- 1 | package io.nekohasekai.sfa.ui 2 | 3 | import android.app.Activity 4 | import android.content.Intent 5 | import android.content.pm.ShortcutManager 6 | import android.os.Build 7 | import android.os.Bundle 8 | import androidx.core.content.getSystemService 9 | import androidx.core.content.pm.ShortcutInfoCompat 10 | import androidx.core.content.pm.ShortcutManagerCompat 11 | import androidx.core.graphics.drawable.IconCompat 12 | import io.nekohasekai.sfa.R 13 | import io.nekohasekai.sfa.bg.BoxService 14 | import io.nekohasekai.sfa.bg.ServiceConnection 15 | import io.nekohasekai.sfa.constant.Status 16 | 17 | class ShortcutActivity : Activity(), ServiceConnection.Callback { 18 | 19 | private val connection = ServiceConnection(this, this, false) 20 | 21 | override fun onCreate(savedInstanceState: Bundle?) { 22 | super.onCreate(savedInstanceState) 23 | if (intent.action == Intent.ACTION_CREATE_SHORTCUT) { 24 | setResult( 25 | RESULT_OK, ShortcutManagerCompat.createShortcutResultIntent( 26 | this, 27 | ShortcutInfoCompat.Builder(this, "toggle") 28 | .setIntent( 29 | Intent( 30 | this, 31 | ShortcutActivity::class.java 32 | ).setAction(Intent.ACTION_MAIN) 33 | ) 34 | .setIcon( 35 | IconCompat.createWithResource( 36 | this, 37 | R.mipmap.ic_launcher 38 | ) 39 | ) 40 | .setShortLabel(getString(R.string.quick_toggle)) 41 | .build() 42 | ) 43 | ) 44 | finish() 45 | } else { 46 | connection.connect() 47 | if (Build.VERSION.SDK_INT >= 25) { 48 | getSystemService()?.reportShortcutUsed("toggle") 49 | } 50 | } 51 | } 52 | 53 | override fun onServiceStatusChanged(status: Status) { 54 | when (status) { 55 | Status.Started -> BoxService.stop() 56 | Status.Stopped -> BoxService.start() 57 | else -> {} 58 | } 59 | finish() 60 | } 61 | 62 | override fun onDestroy() { 63 | connection.disconnect() 64 | super.onDestroy() 65 | } 66 | 67 | } -------------------------------------------------------------------------------- /app/src/main/java/io/nekohasekai/sfa/ui/dashboard/Groups.kt: -------------------------------------------------------------------------------- 1 | package io.nekohasekai.sfa.ui.dashboard 2 | 3 | import io.nekohasekai.libbox.OutboundGroup 4 | import io.nekohasekai.libbox.OutboundGroupItem 5 | import io.nekohasekai.libbox.OutboundGroupItemIterator 6 | 7 | data class Group( 8 | val tag: String, 9 | val type: String, 10 | val selectable: Boolean, 11 | var selected: String, 12 | var isExpand: Boolean, 13 | var items: List, 14 | ) { 15 | constructor(item: OutboundGroup) : this( 16 | item.tag, 17 | item.type, 18 | item.selectable, 19 | item.selected, 20 | item.isExpand, 21 | item.items.toList().map { GroupItem(it) }, 22 | ) 23 | } 24 | 25 | data class GroupItem( 26 | val tag: String, 27 | val type: String, 28 | val urlTestTime: Long, 29 | val urlTestDelay: Int, 30 | ) { 31 | constructor(item: OutboundGroupItem) : this( 32 | item.tag, 33 | item.type, 34 | item.urlTestTime, 35 | item.urlTestDelay, 36 | ) 37 | } 38 | 39 | internal fun OutboundGroupItemIterator.toList(): List { 40 | val list = mutableListOf() 41 | while (hasNext()) { 42 | list.add(next()) 43 | } 44 | return list 45 | } -------------------------------------------------------------------------------- /app/src/main/java/io/nekohasekai/sfa/ui/debug/DebugActivity.kt: -------------------------------------------------------------------------------- 1 | package io.nekohasekai.sfa.ui.debug 2 | 3 | import android.content.Intent 4 | import android.os.Bundle 5 | import io.nekohasekai.sfa.R 6 | import io.nekohasekai.sfa.databinding.ActivityDebugBinding 7 | import io.nekohasekai.sfa.ui.shared.AbstractActivity 8 | 9 | class DebugActivity : AbstractActivity() { 10 | 11 | override fun onCreate(savedInstanceState: Bundle?) { 12 | super.onCreate(savedInstanceState) 13 | 14 | setTitle(R.string.title_debug) 15 | binding.scanVPNButton.setOnClickListener { 16 | startActivity(Intent(this, VPNScanActivity::class.java)) 17 | } 18 | } 19 | } -------------------------------------------------------------------------------- /app/src/main/java/io/nekohasekai/sfa/ui/profile/EditProfileContentActivity.kt: -------------------------------------------------------------------------------- 1 | package io.nekohasekai.sfa.ui.profile 2 | 3 | import android.os.Bundle 4 | import android.view.Menu 5 | import android.view.MenuItem 6 | import androidx.core.view.isInvisible 7 | import androidx.core.view.isVisible 8 | import androidx.core.widget.addTextChangedListener 9 | import androidx.lifecycle.lifecycleScope 10 | import com.blacksquircle.ui.language.json.JsonLanguage 11 | import io.nekohasekai.libbox.Libbox 12 | import io.nekohasekai.sfa.R 13 | import io.nekohasekai.sfa.database.Profile 14 | import io.nekohasekai.sfa.database.ProfileManager 15 | import io.nekohasekai.sfa.databinding.ActivityEditProfileContentBinding 16 | import io.nekohasekai.sfa.ktx.errorDialogBuilder 17 | import io.nekohasekai.sfa.ktx.unwrap 18 | import io.nekohasekai.sfa.ui.shared.AbstractActivity 19 | import kotlinx.coroutines.Dispatchers 20 | import kotlinx.coroutines.delay 21 | import kotlinx.coroutines.launch 22 | import kotlinx.coroutines.withContext 23 | import java.io.File 24 | 25 | class EditProfileContentActivity : AbstractActivity() { 26 | 27 | private var profile: Profile? = null 28 | override fun onCreate(savedInstanceState: Bundle?) { 29 | super.onCreate(savedInstanceState) 30 | 31 | setTitle(R.string.title_edit_configuration) 32 | binding.editor.language = JsonLanguage() 33 | loadConfiguration() 34 | } 35 | 36 | private fun loadConfiguration() { 37 | lifecycleScope.launch(Dispatchers.IO) { 38 | runCatching { 39 | loadConfiguration0() 40 | }.onFailure { 41 | withContext(Dispatchers.Main) { 42 | errorDialogBuilder(it) 43 | .setPositiveButton(R.string.ok) { _, _ -> finish() } 44 | .show() 45 | } 46 | } 47 | } 48 | } 49 | 50 | override fun onCreateOptionsMenu(menu: Menu?): Boolean { 51 | menuInflater.inflate(R.menu.edit_configutation_menu, menu) 52 | return true 53 | } 54 | 55 | override fun onOptionsItemSelected(item: MenuItem): Boolean { 56 | when (item.itemId) { 57 | R.id.action_undo -> { 58 | if (binding.editor.canUndo()) binding.editor.undo() 59 | return true 60 | } 61 | 62 | R.id.action_redo -> { 63 | if (binding.editor.canRedo()) binding.editor.redo() 64 | return true 65 | } 66 | 67 | R.id.action_check -> { 68 | binding.progressView.isVisible = true 69 | lifecycleScope.launch(Dispatchers.IO) { 70 | runCatching { 71 | Libbox.checkConfig(binding.editor.text.toString()) 72 | }.onFailure { 73 | withContext(Dispatchers.Main) { 74 | errorDialogBuilder(it).show() 75 | } 76 | } 77 | withContext(Dispatchers.Main) { 78 | delay(200) 79 | binding.progressView.isInvisible = true 80 | } 81 | } 82 | return true 83 | } 84 | 85 | R.id.action_format -> { 86 | lifecycleScope.launch(Dispatchers.IO) { 87 | runCatching { 88 | val content = Libbox.formatConfig(binding.editor.text.toString()).unwrap 89 | if (binding.editor.text.toString() != content) { 90 | withContext(Dispatchers.Main) { 91 | binding.editor.setTextContent(content) 92 | } 93 | } 94 | }.onFailure { 95 | withContext(Dispatchers.Main) { 96 | errorDialogBuilder(it).show() 97 | } 98 | } 99 | } 100 | return true 101 | } 102 | } 103 | return super.onOptionsItemSelected(item) 104 | } 105 | 106 | private suspend fun loadConfiguration0() { 107 | delay(200L) 108 | 109 | val profileId = intent.getLongExtra("profile_id", -1L) 110 | if (profileId == -1L) error("invalid arguments") 111 | val profile = ProfileManager.get(profileId) ?: error("invalid arguments") 112 | this.profile = profile 113 | val content = File(profile.typed.path).readText() 114 | withContext(Dispatchers.Main) { 115 | binding.editor.setTextContent(content) 116 | binding.editor.addTextChangedListener { 117 | binding.progressView.isVisible = true 118 | val newContent = it.toString() 119 | lifecycleScope.launch(Dispatchers.IO) { 120 | runCatching { 121 | File(profile.typed.path).writeText(newContent) 122 | }.onFailure { 123 | withContext(Dispatchers.Main) { 124 | errorDialogBuilder(it) 125 | .setPositiveButton(android.R.string.ok) { _, _ -> finish() } 126 | .show() 127 | } 128 | } 129 | withContext(Dispatchers.Main) { 130 | delay(200L) 131 | binding.progressView.isInvisible = true 132 | } 133 | } 134 | } 135 | binding.progressView.isInvisible = true 136 | } 137 | } 138 | 139 | } -------------------------------------------------------------------------------- /app/src/main/java/io/nekohasekai/sfa/ui/profile/ZxingQRCodeAnalyzer.kt: -------------------------------------------------------------------------------- 1 | package io.nekohasekai.sfa.ui.profile 2 | 3 | import android.util.Log 4 | import androidx.camera.core.ImageAnalysis 5 | import androidx.camera.core.ImageProxy 6 | import com.google.zxing.BinaryBitmap 7 | import com.google.zxing.NotFoundException 8 | import com.google.zxing.RGBLuminanceSource 9 | import com.google.zxing.common.GlobalHistogramBinarizer 10 | import com.google.zxing.qrcode.QRCodeReader 11 | 12 | class ZxingQRCodeAnalyzer( 13 | private val onSuccess: ((String) -> Unit), 14 | private val onFailure: ((Exception) -> Unit), 15 | ) : ImageAnalysis.Analyzer { 16 | 17 | private val qrCodeReader = QRCodeReader() 18 | override fun analyze(image: ImageProxy) { 19 | try { 20 | val bitmap = image.toBitmap() 21 | val intArray = IntArray(bitmap.getWidth() * bitmap.getHeight()) 22 | bitmap.getPixels( 23 | intArray, 24 | 0, 25 | bitmap.getWidth(), 26 | 0, 27 | 0, 28 | bitmap.getWidth(), 29 | bitmap.getHeight() 30 | ) 31 | val source = RGBLuminanceSource(bitmap.getWidth(), bitmap.getHeight(), intArray) 32 | val result = try { 33 | qrCodeReader.decode(BinaryBitmap(GlobalHistogramBinarizer(source))) 34 | } catch (e: NotFoundException) { 35 | try { 36 | qrCodeReader.decode(BinaryBitmap(GlobalHistogramBinarizer(source.invert()))) 37 | } catch (ignore: NotFoundException) { 38 | return 39 | } 40 | } 41 | Log.d("ZxingQRCodeAnalyzer", "barcode decode success: ${result.text}") 42 | onSuccess(result.text) 43 | } catch (e: Exception) { 44 | onFailure(e) 45 | } finally { 46 | image.close() 47 | } 48 | } 49 | } -------------------------------------------------------------------------------- /app/src/main/java/io/nekohasekai/sfa/ui/profileoverride/ProfileOverrideActivity.kt: -------------------------------------------------------------------------------- 1 | package io.nekohasekai.sfa.ui.profileoverride 2 | 3 | import android.content.Intent 4 | import android.os.Bundle 5 | import androidx.lifecycle.lifecycleScope 6 | import io.nekohasekai.sfa.R 7 | import io.nekohasekai.sfa.constant.PerAppProxyUpdateType 8 | import io.nekohasekai.sfa.database.Settings 9 | import io.nekohasekai.sfa.databinding.ActivityConfigOverrideBinding 10 | import io.nekohasekai.sfa.ktx.addTextChangedListener 11 | import io.nekohasekai.sfa.ktx.setSimpleItems 12 | import io.nekohasekai.sfa.ktx.text 13 | import io.nekohasekai.sfa.ui.shared.AbstractActivity 14 | import kotlinx.coroutines.Dispatchers 15 | import kotlinx.coroutines.launch 16 | import kotlinx.coroutines.withContext 17 | 18 | class ProfileOverrideActivity : 19 | AbstractActivity() { 20 | 21 | override fun onCreate(savedInstanceState: Bundle?) { 22 | super.onCreate(savedInstanceState) 23 | 24 | setTitle(R.string.title_profile_override) 25 | binding.switchPerAppProxy.isChecked = Settings.perAppProxyEnabled 26 | binding.switchPerAppProxy.setOnCheckedChangeListener { _, isChecked -> 27 | Settings.perAppProxyEnabled = isChecked 28 | binding.perAppProxyUpdateOnChange.isEnabled = binding.switchPerAppProxy.isChecked 29 | binding.configureAppListButton.isEnabled = isChecked 30 | } 31 | binding.perAppProxyUpdateOnChange.isEnabled = binding.switchPerAppProxy.isChecked 32 | binding.configureAppListButton.isEnabled = binding.switchPerAppProxy.isChecked 33 | 34 | binding.perAppProxyUpdateOnChange.addTextChangedListener { 35 | lifecycleScope.launch(Dispatchers.IO) { 36 | Settings.perAppProxyUpdateOnChange = 37 | PerAppProxyUpdateType.valueOf(this@ProfileOverrideActivity, it).value() 38 | } 39 | } 40 | 41 | binding.configureAppListButton.setOnClickListener { 42 | startActivity(Intent(this, PerAppProxyActivity::class.java)) 43 | } 44 | lifecycleScope.launch(Dispatchers.IO) { 45 | reloadSettings() 46 | } 47 | } 48 | 49 | private suspend fun reloadSettings() { 50 | val perAppUpdateOnChange = Settings.perAppProxyUpdateOnChange 51 | withContext(Dispatchers.Main) { 52 | binding.perAppProxyUpdateOnChange.text = 53 | PerAppProxyUpdateType.valueOf(perAppUpdateOnChange) 54 | .getString(this@ProfileOverrideActivity) 55 | binding.perAppProxyUpdateOnChange.setSimpleItems(R.array.per_app_proxy_update_on_change_value) 56 | } 57 | } 58 | } -------------------------------------------------------------------------------- /app/src/main/java/io/nekohasekai/sfa/ui/shared/AbstractActivity.kt: -------------------------------------------------------------------------------- 1 | package io.nekohasekai.sfa.ui.shared 2 | 3 | import android.content.res.Configuration 4 | import android.os.Build 5 | import android.os.Bundle 6 | import android.view.LayoutInflater 7 | import android.view.MenuItem 8 | import android.view.WindowManager 9 | import androidx.appcompat.app.AppCompatActivity 10 | import androidx.appcompat.content.res.AppCompatResources 11 | import androidx.core.view.WindowCompat 12 | import androidx.viewbinding.ViewBinding 13 | import com.google.android.material.appbar.MaterialToolbar 14 | import com.google.android.material.color.DynamicColors 15 | import io.nekohasekai.sfa.R 16 | import io.nekohasekai.sfa.ktx.getAttrColor 17 | import io.nekohasekai.sfa.ui.MainActivity 18 | import io.nekohasekai.sfa.utils.MIUIUtils 19 | import java.lang.reflect.ParameterizedType 20 | 21 | abstract class AbstractActivity : AppCompatActivity() { 22 | 23 | private var _binding: Binding? = null 24 | internal val binding get() = _binding!! 25 | 26 | override fun onCreate(savedInstanceState: Bundle?) { 27 | super.onCreate(savedInstanceState) 28 | 29 | DynamicColors.applyToActivityIfAvailable(this) 30 | 31 | // Set light navigation bar for Android 8.0 32 | if (Build.VERSION.SDK_INT == Build.VERSION_CODES.O) { 33 | val nightFlag = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK 34 | if (nightFlag != Configuration.UI_MODE_NIGHT_YES) { 35 | val insetsController = WindowCompat.getInsetsController( 36 | window, 37 | window.decorView 38 | ) 39 | insetsController.isAppearanceLightNavigationBars = true 40 | } 41 | } 42 | 43 | _binding = createBindingInstance(layoutInflater).also { 44 | setContentView(it.root) 45 | } 46 | 47 | findViewById(R.id.toolbar)?.also { 48 | setSupportActionBar(it) 49 | } 50 | 51 | // MIUI overrides colorSurfaceContainer to colorSurface without below flags 52 | @Suppress("DEPRECATION") if (MIUIUtils.isMIUI) { 53 | window.addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS) 54 | window.addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION) 55 | } 56 | 57 | if (this !is MainActivity) { 58 | supportActionBar?.setHomeAsUpIndicator(AppCompatResources.getDrawable( 59 | this@AbstractActivity, R.drawable.ic_arrow_back_24 60 | )!!.apply { 61 | setTint(getAttrColor(com.google.android.material.R.attr.colorOnSurface)) 62 | }) 63 | supportActionBar?.setDisplayHomeAsUpEnabled(true) 64 | } 65 | } 66 | 67 | override fun onOptionsItemSelected(item: MenuItem): Boolean { 68 | when (item.itemId) { 69 | android.R.id.home -> { 70 | onBackPressedDispatcher.onBackPressed() 71 | return true 72 | } 73 | } 74 | return super.onOptionsItemSelected(item) 75 | } 76 | 77 | @Suppress("UNCHECKED_CAST") 78 | private fun createBindingInstance( 79 | inflater: LayoutInflater, 80 | ): Binding { 81 | val vbType = (javaClass.genericSuperclass as ParameterizedType).actualTypeArguments[0] 82 | val vbClass = vbType as Class 83 | val method = vbClass.getMethod("inflate", LayoutInflater::class.java) 84 | return method.invoke(null, inflater) as Binding 85 | } 86 | 87 | } -------------------------------------------------------------------------------- /app/src/main/java/io/nekohasekai/sfa/ui/shared/QRCodeDialog.kt: -------------------------------------------------------------------------------- 1 | package io.nekohasekai.sfa.ui.shared 2 | 3 | import android.graphics.Bitmap 4 | import android.os.Bundle 5 | import android.view.LayoutInflater 6 | import android.view.View 7 | import android.view.ViewGroup 8 | import com.google.android.material.bottomsheet.BottomSheetBehavior 9 | import com.google.android.material.bottomsheet.BottomSheetDialogFragment 10 | import io.nekohasekai.sfa.databinding.FragmentQrcodeDialogBinding 11 | 12 | class QRCodeDialog(private val bitmap: Bitmap) : 13 | BottomSheetDialogFragment() { 14 | override fun onCreateView( 15 | inflater: LayoutInflater, 16 | container: ViewGroup?, 17 | savedInstanceState: Bundle? 18 | ): View { 19 | val binding = FragmentQrcodeDialogBinding.inflate(inflater, container, false) 20 | val behavior = BottomSheetBehavior.from(binding.qrcodeLayout) 21 | behavior.state = BottomSheetBehavior.STATE_EXPANDED 22 | binding.qrCode.setImageBitmap(bitmap) 23 | return binding.root 24 | } 25 | 26 | } -------------------------------------------------------------------------------- /app/src/main/java/io/nekohasekai/sfa/utils/ColorUtils.kt: -------------------------------------------------------------------------------- 1 | package io.nekohasekai.sfa.utils 2 | 3 | import android.content.Context 4 | import android.graphics.Color 5 | import android.graphics.Typeface 6 | import android.text.ParcelableSpan 7 | import android.text.Spannable 8 | import android.text.SpannableString 9 | import android.text.style.ForegroundColorSpan 10 | import android.text.style.StyleSpan 11 | import android.text.style.UnderlineSpan 12 | import androidx.core.content.ContextCompat 13 | import io.nekohasekai.sfa.R 14 | import java.util.Stack 15 | 16 | object ColorUtils { 17 | 18 | private val ansiRegex by lazy { Regex("\u001B\\[[;\\d]*m") } 19 | 20 | fun ansiEscapeToSpannable(context: Context, text: String): Spannable { 21 | val spannable = SpannableString(text.replace(ansiRegex, "")) 22 | val stack = Stack() 23 | val spans = mutableListOf() 24 | val matches = ansiRegex.findAll(text) 25 | var offset = 0 26 | 27 | matches.forEach { result -> 28 | val stringCode = result.value 29 | val start = result.range.last 30 | val end = result.range.last + 1 31 | val ansiInstruction = AnsiInstruction(context, stringCode) 32 | offset += stringCode.length 33 | if (ansiInstruction.decorationCode == "0" && stack.isNotEmpty()) { 34 | spans.add(stack.pop().copy(end = end - offset)) 35 | } else { 36 | val span = AnsiSpan( 37 | AnsiInstruction(context, stringCode), 38 | start - if (offset > start) start else offset - 1, 39 | 0 40 | ) 41 | stack.push(span) 42 | } 43 | } 44 | 45 | spans.forEach { ansiSpan -> 46 | ansiSpan.instruction.spans.forEach { 47 | spannable.setSpan( 48 | it, 49 | ansiSpan.start, 50 | ansiSpan.end, 51 | Spannable.SPAN_EXCLUSIVE_INCLUSIVE 52 | ) 53 | } 54 | } 55 | 56 | return spannable 57 | } 58 | 59 | private data class AnsiSpan( 60 | val instruction: AnsiInstruction, val start: Int, val end: Int 61 | ) 62 | 63 | private class AnsiInstruction(context: Context, code: String) { 64 | 65 | val spans: List by lazy { 66 | listOfNotNull( 67 | getSpan(colorCode, context), getSpan(decorationCode, context) 68 | ) 69 | } 70 | 71 | var colorCode: String? = null 72 | private set 73 | 74 | var decorationCode: String? = null 75 | private set 76 | 77 | init { 78 | val colorCodes = code.substringAfter('[').substringBefore('m').split(';') 79 | 80 | when (colorCodes.size) { 81 | 3 -> { 82 | colorCode = colorCodes[1] 83 | decorationCode = colorCodes[2] 84 | } 85 | 86 | 2 -> { 87 | colorCode = colorCodes[0] 88 | decorationCode = colorCodes[1] 89 | } 90 | 91 | 1 -> decorationCode = colorCodes[0] 92 | } 93 | } 94 | } 95 | 96 | private fun getSpan(code: String?, context: Context): ParcelableSpan? = when (code) { 97 | "0", null -> null 98 | "1" -> StyleSpan(Typeface.NORMAL) 99 | "3" -> StyleSpan(Typeface.ITALIC) 100 | "4" -> UnderlineSpan() 101 | "30" -> ForegroundColorSpan(Color.BLACK) 102 | "31" -> ForegroundColorSpan(ContextCompat.getColor(context, R.color.log_red)) 103 | "32" -> ForegroundColorSpan(ContextCompat.getColor(context, R.color.log_green)) 104 | "33" -> ForegroundColorSpan(ContextCompat.getColor(context, R.color.log_yellow)) 105 | "34" -> ForegroundColorSpan(ContextCompat.getColor(context, R.color.log_blue)) 106 | "35" -> ForegroundColorSpan(ContextCompat.getColor(context, R.color.log_purple)) 107 | "36" -> ForegroundColorSpan(ContextCompat.getColor(context, R.color.log_blue_light)) 108 | "37" -> ForegroundColorSpan(ContextCompat.getColor(context, R.color.log_white)) 109 | else -> { 110 | var codeInt = code.toIntOrNull() 111 | if (codeInt != null) { 112 | codeInt %= 125 113 | val row = codeInt / 36 114 | val column = codeInt % 36 115 | ForegroundColorSpan(Color.rgb(row * 51, column / 6 * 51, column % 6 * 51)) 116 | } else { 117 | null 118 | } 119 | } 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /app/src/main/java/io/nekohasekai/sfa/utils/CommandClient.kt: -------------------------------------------------------------------------------- 1 | package io.nekohasekai.sfa.utils 2 | 3 | import go.Seq 4 | import io.nekohasekai.libbox.CommandClient 5 | import io.nekohasekai.libbox.CommandClientHandler 6 | import io.nekohasekai.libbox.CommandClientOptions 7 | import io.nekohasekai.libbox.Connections 8 | import io.nekohasekai.libbox.Libbox 9 | import io.nekohasekai.libbox.OutboundGroup 10 | import io.nekohasekai.libbox.OutboundGroupIterator 11 | import io.nekohasekai.libbox.StatusMessage 12 | import io.nekohasekai.libbox.StringIterator 13 | import io.nekohasekai.sfa.ktx.toList 14 | import kotlinx.coroutines.CoroutineScope 15 | import kotlinx.coroutines.Dispatchers 16 | import kotlinx.coroutines.delay 17 | import kotlinx.coroutines.isActive 18 | import kotlinx.coroutines.launch 19 | 20 | open class CommandClient( 21 | private val scope: CoroutineScope, 22 | private val connectionType: ConnectionType, 23 | private val handler: Handler, 24 | ) { 25 | 26 | enum class ConnectionType { 27 | Status, Groups, Log, ClashMode 28 | } 29 | 30 | interface Handler { 31 | 32 | fun onConnected() {} 33 | fun onDisconnected() {} 34 | 35 | fun updateStatus(status: StatusMessage) {} 36 | 37 | fun clearLogs() {} 38 | fun appendLogs(message: List) {} 39 | 40 | fun updateGroups(newGroups: MutableList) {} 41 | 42 | fun initializeClashMode(modeList: List, currentMode: String) {} 43 | fun updateClashMode(newMode: String) {} 44 | 45 | } 46 | 47 | private var commandClient: CommandClient? = null 48 | private val clientHandler = ClientHandler() 49 | fun connect() { 50 | disconnect() 51 | val options = CommandClientOptions() 52 | options.command = when (connectionType) { 53 | ConnectionType.Status -> Libbox.CommandStatus 54 | ConnectionType.Groups -> Libbox.CommandGroup 55 | ConnectionType.Log -> Libbox.CommandLog 56 | ConnectionType.ClashMode -> Libbox.CommandClashMode 57 | } 58 | options.statusInterval = 1 * 1000 * 1000 * 1000 59 | val commandClient = CommandClient(clientHandler, options) 60 | scope.launch(Dispatchers.IO) { 61 | for (i in 1..10) { 62 | delay(100 + i.toLong() * 50) 63 | try { 64 | commandClient.connect() 65 | } catch (ignored: Exception) { 66 | continue 67 | } 68 | if (!isActive) { 69 | runCatching { 70 | commandClient.disconnect() 71 | } 72 | return@launch 73 | } 74 | this@CommandClient.commandClient = commandClient 75 | return@launch 76 | } 77 | runCatching { 78 | commandClient.disconnect() 79 | } 80 | } 81 | } 82 | 83 | fun disconnect() { 84 | commandClient?.apply { 85 | runCatching { 86 | disconnect() 87 | } 88 | Seq.destroyRef(refnum) 89 | } 90 | commandClient = null 91 | } 92 | 93 | private inner class ClientHandler : CommandClientHandler { 94 | 95 | override fun connected() { 96 | handler.onConnected() 97 | } 98 | 99 | override fun disconnected(message: String?) { 100 | handler.onDisconnected() 101 | } 102 | 103 | override fun writeGroups(message: OutboundGroupIterator?) { 104 | if (message == null) { 105 | return 106 | } 107 | val groups = mutableListOf() 108 | while (message.hasNext()) { 109 | groups.add(message.next()) 110 | } 111 | handler.updateGroups(groups) 112 | } 113 | 114 | override fun clearLogs() { 115 | handler.clearLogs() 116 | } 117 | 118 | override fun writeLogs(messageList: StringIterator?) { 119 | if (messageList == null) { 120 | return 121 | } 122 | handler.appendLogs(messageList.toList()) 123 | } 124 | 125 | override fun writeStatus(message: StatusMessage) { 126 | handler.updateStatus(message) 127 | } 128 | 129 | override fun initializeClashMode(modeList: StringIterator, currentMode: String) { 130 | handler.initializeClashMode(modeList.toList(), currentMode) 131 | } 132 | 133 | override fun updateClashMode(newMode: String) { 134 | handler.updateClashMode(newMode) 135 | } 136 | 137 | override fun writeConnections(message: Connections?) { 138 | } 139 | } 140 | 141 | } -------------------------------------------------------------------------------- /app/src/main/java/io/nekohasekai/sfa/utils/HTTPClient.kt: -------------------------------------------------------------------------------- 1 | package io.nekohasekai.sfa.utils 2 | 3 | import io.nekohasekai.libbox.Libbox 4 | import io.nekohasekai.sfa.BuildConfig 5 | import io.nekohasekai.sfa.ktx.unwrap 6 | import java.io.Closeable 7 | import java.util.Locale 8 | 9 | class HTTPClient : Closeable { 10 | 11 | companion object { 12 | val userAgent by lazy { 13 | var userAgent = "SFA/" 14 | userAgent += BuildConfig.VERSION_NAME 15 | userAgent += " (" 16 | userAgent += BuildConfig.VERSION_CODE 17 | userAgent += "; sing-box " 18 | userAgent += Libbox.version() 19 | userAgent += "; language " 20 | userAgent += Locale.getDefault().toLanguageTag().replace("-", "_") 21 | userAgent += ")" 22 | userAgent 23 | } 24 | } 25 | 26 | private val client = Libbox.newHTTPClient() 27 | 28 | init { 29 | client.modernTLS() 30 | } 31 | 32 | fun getString(url: String): String { 33 | val request = client.newRequest() 34 | request.setUserAgent(userAgent) 35 | request.setURL(url) 36 | val response = request.execute() 37 | return response.content.unwrap 38 | } 39 | 40 | override fun close() { 41 | client.close() 42 | } 43 | 44 | 45 | } -------------------------------------------------------------------------------- /app/src/main/java/io/nekohasekai/sfa/utils/MIUIUtils.kt: -------------------------------------------------------------------------------- 1 | package io.nekohasekai.sfa.utils 2 | 3 | import android.annotation.SuppressLint 4 | import android.content.Context 5 | import android.content.Intent 6 | import android.os.Process 7 | 8 | object MIUIUtils { 9 | 10 | val isMIUI by lazy { 11 | !getSystemProperty("ro.miui.ui.version.name").isNullOrBlank() 12 | } 13 | 14 | @SuppressLint("PrivateApi") 15 | fun getSystemProperty(key: String?): String? { 16 | try { 17 | return Class.forName("android.os.SystemProperties").getMethod("get", String::class.java) 18 | .invoke(null, key) as String 19 | } catch (ignored: Exception) { 20 | } 21 | return null 22 | } 23 | 24 | fun openPermissionSettings(context: Context) { 25 | val intent = Intent("miui.intent.action.APP_PERM_EDITOR") 26 | intent.putExtra("extra_package_uid", Process.myUid()) 27 | intent.putExtra("extra_pkgname", context.packageName) 28 | context.startActivity(intent) 29 | } 30 | 31 | } -------------------------------------------------------------------------------- /app/src/main/java/io/nekohasekai/sfa/vendor/VendorInterface.kt: -------------------------------------------------------------------------------- 1 | package io.nekohasekai.sfa.vendor 2 | 3 | import android.app.Activity 4 | import androidx.camera.core.ImageAnalysis 5 | 6 | interface VendorInterface { 7 | fun checkUpdateAvailable(): Boolean 8 | fun checkUpdate(activity: Activity, byUser: Boolean) 9 | fun createQRCodeAnalyzer( 10 | onSuccess: (String) -> Unit, 11 | onFailure: (Exception) -> Unit 12 | ): ImageAnalysis.Analyzer? 13 | } -------------------------------------------------------------------------------- /app/src/main/play/release-notes/en-US/beta.txt: -------------------------------------------------------------------------------- 1 | Fixes and improvements -------------------------------------------------------------------------------- /app/src/main/res/drawable/bg_rounded_rectangle.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/bg_rounded_rectangle_active.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_arrow_back_24.xml: -------------------------------------------------------------------------------- 1 | 8 | 11 | 12 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_baseline_create_new_folder_24.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_baseline_file_open_24.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_dashboard_black_24dp.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_delete_24.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_edit_24.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_electric_bolt_24.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_expand_less_24.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_expand_more_24.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_find_in_page_24.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_insert_drive_file_24.xml: -------------------------------------------------------------------------------- 1 | 8 | 11 | 12 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_ios_share_24.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 6 | 11 | 12 | 13 | 17 | 18 | 19 | 20 | 21 | 25 | 26 | 27 | 31 | 35 | 39 | 43 | 47 | 51 | 55 | 56 | 57 | 58 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_menu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SagerNet/sing-box-for-android/320170a1077ea5c93872b3e055b96b8836615ef0/app/src/main/res/drawable/ic_menu.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_message_24.xml: -------------------------------------------------------------------------------- 1 | 8 | 11 | 12 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_more_vert_24.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_note_add_24.xml: -------------------------------------------------------------------------------- 1 | 8 | 11 | 12 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_play_arrow_24.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_qr_code_2_24.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_settings_24.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_stop_24.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_update_24.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_config_override.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | 9 | 13 | 14 | 21 | 22 | 27 | 28 | 36 | 37 | 40 | 41 | 47 | 48 | 49 | 50 | 51 | 56 | 57 | 64 | 65 | 71 | 72 | 73 | 74 | 75 | 81 | 82 |