feat: 源码提交
Build Windows / build (push) Has been cancelled

This commit is contained in:
2025-10-19 23:30:54 -07:00
parent 54e7df78db
commit 75d4c48e41
6076 changed files with 64655 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
# Remember to never publicly share your keystore.
# See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app
key.properties
**/*.keystore
**/*.jks
/app/libs/*
!/app/libs/.gitkeep
+11
View File
@@ -0,0 +1,11 @@
gradle-wrapper.jar
.gradle
captures/
gradlew
gradlew.bat
local.properties
GeneratedPluginRegistrant.java
key.properties
**.keystore
**.jks
+158
View File
@@ -0,0 +1,158 @@
plugins {
id "com.android.application"
id "kotlin-android"
id "dev.flutter.flutter-gradle-plugin"
}
def localPropertiesFile = new File(rootProject.projectDir, "local.properties")
def properties = new Properties()
assert localPropertiesFile.exists()
localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) }
def flutterRoot = properties.getProperty("flutter.sdk")
assert flutterRoot != null, "flutter.sdk not set in local.properties"
boolean hasKeyStore = false
def keystoreProperties = new Properties()
def keystorePropertiesFile = rootProject.file('key.properties')
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
hasKeyStore = true
} else {
println "+++"
println "No keystore defined. The app will not be signed."
println "Create a android/key.properties file with the following properties:"
println "storePassword"
println "keyPassword"
println "keyAlias"
println "storeFile"
println "+++"
}
def flutterVersionCode = properties.getProperty('flutter.versionCode')?: '1'
def flutterVersionName = properties.getProperty('flutter.versionName') ?: '1.0'
android {
namespace 'com.hiddify.hiddify'
testNamespace "test.com.hiddify.hiddify"
compileSdk 36
ndkVersion "26.1.10909125"
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '17'
}
sourceSets {
main.java.srcDirs += 'src/main/kotlin'
}
defaultConfig {
applicationId "app.brAccelerator.com"
minSdkVersion flutter.minSdkVersion
targetSdkVersion 36
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
multiDexEnabled true
manifestPlaceholders = [
'android.permission.ACCESS_NETWORK_STATE': true
]
android.defaultConfig.manifestPlaceholders += [
'android:screenOrientation': "portrait"
]
}
splits {
abi {
enable true
reset()
include "armeabi-v7a", "arm64-v8a"
universalApk true
}
}
if (hasKeyStore) {
signingConfigs {
release {
/* keyAlias keystoreProperties['keyAlias']
keyPassword keystoreProperties['keyPassword']
storeFile file(keystoreProperties['storeFile'])
storePassword keystoreProperties['storePassword']*/
storeFile file("upload-keystore.jks")
storePassword "123456"
keyAlias "upload"
keyPassword "123456"
}
}
}
buildTypes {
release {
if (hasKeyStore) {
signingConfig signingConfigs.release
} else {
signingConfig signingConfigs.debug
}
ndk {
abiFilters "armeabi-v7a", "arm64-v8a"
debugSymbolLevel 'FULL'
}
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
buildFeatures {
viewBinding true
aidl true
}
configurations.all {
resolutionStrategy {
force "org.jetbrains.kotlin:kotlin-stdlib:2.1.0"
force "org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.1.0"
force "org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.1.0"
force "androidx.annotation:annotation-jvm:1.7.0"
}
}
lintOptions {
checkReleaseBuilds false
}
}
android.applicationVariants.all { variant ->
variant.outputs.each { output ->
output.versionCodeOverride = android.defaultConfig.versionCode
}
}
flutter {
source '../..'
}
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar'])
implementation 'com.google.code.gson:gson:2.10.1'
implementation 'androidx.core:core-ktx:1.12.0'
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.6.2'
implementation "org.jetbrains.kotlin:kotlin-stdlib:2.1.0"
implementation "androidx.annotation:annotation:1.7.1"
constraints {
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.1.0") {
because("kotlin-stdlib-jdk7 is now a part of kotlin-stdlib")
}
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.1.0") {
because("kotlin-stdlib-jdk8 is now a part of kotlin-stdlib")
}
}
}
View File
+7
View File
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
+149
View File
@@ -0,0 +1,149 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-feature
android:name="android.software.leanback"
android:required="false" />
<uses-feature
android:name="android.hardware.touchscreen"
android:required="false" />
<uses-feature
android:name="android.hardware.camera"
android:required="false" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- Crisp 聊天所需权限 -->
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission
android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" />
<application
android:name=".Application"
android:banner="@mipmap/ic_banner"
android:icon="@mipmap/ic_launcher"
android:label="BearVPN"
android:roundIcon="@mipmap/ic_launcher_round"
tools:targetApi="31">
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
<activity
android:name=".MainActivity"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:exported="true"
android:hardwareAccelerated="true"
android:launchMode="singleTop"
android:screenOrientation="portrait"
android:theme="@style/LaunchTheme"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme" />
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="sing-box" />
<data android:host="import-remote-profile" />
<data android:scheme="clash" />
<data android:host="install-config" />
<data android:scheme="clashmeta" />
<data android:scheme="hiddify" />
<data android:host="install-sub" />
<data android:scheme="hiddify" />
<data android:host="import" />
</intent-filter>
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES" />
</intent-filter>
</activity>
<activity
android:name=".ShortcutActivity"
android:excludeFromRecents="true"
android:exported="true"
android:label="@string/quick_toggle"
android:launchMode="singleTask"
android:taskAffinity="">
<intent-filter>
<action android:name="android.intent.action.CREATE_SHORTCUT" />
</intent-filter>
</activity>
<service
android:name=".bg.TileService"
android:directBootAware="true"
android:exported="true"
android:icon="@drawable/ic_stat_logo"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
tools:targetApi="n">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
<meta-data
android:name="android.service.quicksettings.TOGGLEABLE_TILE"
android:value="true" />
</service>
<service
android:name=".bg.VPNService"
android:exported="false"
android:foregroundServiceType="specialUse"
android:permission="android.permission.BIND_VPN_SERVICE">
<intent-filter>
<action android:name="android.net.VpnService" />
</intent-filter>
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="vpn" />
</service>
<service
android:name=".bg.ProxyService"
android:exported="false"
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="proxy" />
</service>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
</manifest>
@@ -0,0 +1,9 @@
package com.hiddify.hiddify;
import com.hiddify.hiddify.IServiceCallback;
interface IService {
int getStatus();
void registerCallback(in IServiceCallback callback);
oneway void unregisterCallback(in IServiceCallback callback);
}
@@ -0,0 +1,8 @@
package com.hiddify.hiddify;
interface IServiceCallback {
void onServiceStatusChanged(int status);
void onServiceAlert(int type, String message);
void onServiceWriteLog(String message);
void onServiceResetLogs(in List<String> messages);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

@@ -0,0 +1,60 @@
package com.hiddify.hiddify
import android.util.Log
import com.google.gson.Gson
import com.hiddify.hiddify.utils.CommandClient
import com.hiddify.hiddify.utils.ParsedOutboundGroup
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.EventChannel
import io.nekohasekai.libbox.OutboundGroup
import kotlinx.coroutines.CoroutineScope
class ActiveGroupsChannel(private val scope: CoroutineScope) : FlutterPlugin,
CommandClient.Handler {
companion object {
const val TAG = "A/ActiveGroupsChannel"
const val CHANNEL = "com.baer.app/active-groups"
val gson = Gson()
}
private val client =
CommandClient(scope, CommandClient.ConnectionType.GroupOnly, this)
private var channel: EventChannel? = null
private var event: EventChannel.EventSink? = null
override fun updateGroups(groups: List<OutboundGroup>) {
MainActivity.instance.runOnUiThread {
val parsedGroups = groups.map { group -> ParsedOutboundGroup.fromOutbound(group) }
event?.success(gson.toJson(parsedGroups))
}
}
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
channel = EventChannel(
flutterPluginBinding.binaryMessenger,
CHANNEL
)
channel!!.setStreamHandler(object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
event = events
Log.d(TAG, "connecting active groups command client")
client.connect()
}
override fun onCancel(arguments: Any?) {
event = null
Log.d(TAG, "disconnecting active groups command client")
client.disconnect()
}
})
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
event = null
client.disconnect()
channel?.setStreamHandler(null)
}
}
@@ -0,0 +1,43 @@
package com.hiddify.hiddify
import android.app.Application
import android.app.NotificationManager
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.net.ConnectivityManager
import android.os.PowerManager
import androidx.core.content.getSystemService
import com.hiddify.hiddify.bg.AppChangeReceiver
import go.Seq
import com.hiddify.hiddify.Application as BoxApplication
class Application : Application() {
override fun attachBaseContext(base: Context?) {
super.attachBaseContext(base)
application = this
}
override fun onCreate() {
super.onCreate()
Seq.setContext(this)
registerReceiver(AppChangeReceiver(), IntentFilter().apply {
addAction(Intent.ACTION_PACKAGE_ADDED)
addDataScheme("package")
})
}
companion object {
lateinit var application: BoxApplication
val notification by lazy { application.getSystemService<NotificationManager>()!! }
val connectivity by lazy { application.getSystemService<ConnectivityManager>()!! }
val packageManager by lazy { application.packageManager }
val powerManager by lazy { application.getSystemService<PowerManager>()!! }
val notificationManager by lazy { application.getSystemService<NotificationManager>()!! }
}
}
@@ -0,0 +1,82 @@
package com.hiddify.hiddify
import android.util.Log
import androidx.lifecycle.Observer
import com.hiddify.hiddify.constant.Alert
import com.hiddify.hiddify.constant.Status
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.JSONMethodCodec
class EventHandler : FlutterPlugin {
companion object {
const val TAG = "A/EventHandler"
const val SERVICE_STATUS = "com.baer.app/service.status"
const val SERVICE_ALERTS = "com.baer.app/service.alerts"
}
private var statusChannel: EventChannel? = null
private var alertsChannel: EventChannel? = null
private var statusObserver: Observer<Status>? = null
private var alertsObserver: Observer<ServiceEvent?>? = null
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
statusChannel = EventChannel(flutterPluginBinding.binaryMessenger, SERVICE_STATUS, JSONMethodCodec.INSTANCE)
alertsChannel = EventChannel(flutterPluginBinding.binaryMessenger, SERVICE_ALERTS, JSONMethodCodec.INSTANCE)
statusChannel!!.setStreamHandler(object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
statusObserver = Observer {
Log.d(TAG, "new status: $it")
val map = listOf(
Pair("status", it.name)
)
.toMap()
events?.success(map)
}
MainActivity.instance.serviceStatus.observeForever(statusObserver!!)
}
override fun onCancel(arguments: Any?) {
if (statusObserver != null)
MainActivity.instance.serviceStatus.removeObserver(statusObserver!!)
}
})
alertsChannel!!.setStreamHandler(object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
alertsObserver = Observer {
if (it == null) return@Observer
Log.d(TAG, "new alert: $it")
val map = listOf(
Pair("status", it.status.name),
Pair("alert", it.alert?.name),
Pair("message", it.message)
)
.mapNotNull { p -> p.second?.let { Pair(p.first, p.second) } }
.toMap()
events?.success(map)
}
MainActivity.instance.serviceAlerts.observeForever(alertsObserver!!)
}
override fun onCancel(arguments: Any?) {
if (alertsObserver != null)
MainActivity.instance.serviceAlerts.removeObserver(alertsObserver!!)
}
})
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
if (statusObserver != null)
MainActivity.instance.serviceStatus.removeObserver(statusObserver!!)
statusChannel?.setStreamHandler(null)
if (alertsObserver != null)
MainActivity.instance.serviceAlerts.removeObserver(alertsObserver!!)
alertsChannel?.setStreamHandler(null)
}
}
data class ServiceEvent(val status: Status, val alert: Alert? = null, val message: String? = null)
@@ -0,0 +1,58 @@
package com.hiddify.hiddify
import android.util.Log
import com.google.gson.Gson
import com.hiddify.hiddify.utils.CommandClient
import com.hiddify.hiddify.utils.ParsedOutboundGroup
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.EventChannel
import io.nekohasekai.libbox.OutboundGroup
import kotlinx.coroutines.CoroutineScope
class GroupsChannel(private val scope: CoroutineScope) : FlutterPlugin, CommandClient.Handler {
companion object {
const val TAG = "A/GroupsChannel"
const val CHANNEL = "com.baer.app/groups"
val gson = Gson()
}
private val client =
CommandClient(scope, CommandClient.ConnectionType.Groups, this)
private var channel: EventChannel? = null
private var event: EventChannel.EventSink? = null
override fun updateGroups(groups: List<OutboundGroup>) {
MainActivity.instance.runOnUiThread {
val parsedGroups = groups.map { group -> ParsedOutboundGroup.fromOutbound(group) }
event?.success(gson.toJson(parsedGroups))
}
}
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
channel = EventChannel(
flutterPluginBinding.binaryMessenger,
CHANNEL
)
channel!!.setStreamHandler(object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
event = events
Log.d(TAG, "connecting groups command client")
client.connect()
}
override fun onCancel(arguments: Any?) {
event = null
Log.d(TAG, "disconnecting groups command client")
client.disconnect()
}
})
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
event = null
client.disconnect()
channel?.setStreamHandler(null)
}
}
@@ -0,0 +1,37 @@
package com.hiddify.hiddify
import android.util.Log
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.EventChannel
class LogHandler : FlutterPlugin {
companion object {
const val TAG = "A/LogHandler"
const val SERVICE_LOGS = "com.baer.app/service.logs"
}
private lateinit var logsChannel: EventChannel
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
logsChannel = EventChannel(flutterPluginBinding.binaryMessenger, SERVICE_LOGS)
logsChannel.setStreamHandler(object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
val activity = MainActivity.instance
events?.success(activity.logList)
activity.logCallback = {
events?.success(activity.logList)
}
}
override fun onCancel(arguments: Any?) {
MainActivity.instance.logCallback = null
}
})
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
}
}
@@ -0,0 +1,156 @@
package com.hiddify.hiddify
import android.annotation.SuppressLint
import android.content.Intent
import android.Manifest
import android.content.pm.PackageManager
import android.net.VpnService
import android.os.Build
import android.util.Log
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.lifecycleScope
import com.hiddify.hiddify.bg.ServiceConnection
import com.hiddify.hiddify.bg.ServiceNotification
import com.hiddify.hiddify.constant.Alert
import com.hiddify.hiddify.constant.ServiceMode
import com.hiddify.hiddify.constant.Status
import io.flutter.embedding.android.FlutterFragmentActivity
import io.flutter.embedding.engine.FlutterEngine
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.LinkedList
class MainActivity : FlutterFragmentActivity(), ServiceConnection.Callback {
companion object {
private const val TAG = "ANDROID/MyActivity"
lateinit var instance: MainActivity
const val VPN_PERMISSION_REQUEST_CODE = 1001
const val NOTIFICATION_PERMISSION_REQUEST_CODE = 1010
}
private val connection = ServiceConnection(this, this)
val logList = LinkedList<String>()
var logCallback: ((Boolean) -> Unit)? = null
val serviceStatus = MutableLiveData(Status.Stopped)
val serviceAlerts = MutableLiveData<ServiceEvent?>(null)
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
instance = this
reconnect()
flutterEngine.plugins.add(MethodHandler(lifecycleScope))
flutterEngine.plugins.add(PlatformSettingsHandler())
flutterEngine.plugins.add(EventHandler())
flutterEngine.plugins.add(LogHandler())
flutterEngine.plugins.add(GroupsChannel(lifecycleScope))
flutterEngine.plugins.add(ActiveGroupsChannel(lifecycleScope))
flutterEngine.plugins.add(StatsChannel(lifecycleScope))
}
fun reconnect() {
connection.reconnect()
}
fun startService() {
// 暂时跳过通知权限检查
lifecycleScope.launch(Dispatchers.IO) {
if (Settings.rebuildServiceMode()) {
reconnect()
}
if (Settings.serviceMode == ServiceMode.VPN) {
if (prepare()) {
Log.d(TAG, "VPN permission required")
return@launch
}
}
val intent = Intent(Application.application, Settings.serviceClass())
withContext(Dispatchers.Main) {
ContextCompat.startForegroundService(Application.application, intent)
}
}
}
private suspend fun prepare() = withContext(Dispatchers.Main) {
try {
val intent = VpnService.prepare(this@MainActivity)
if (intent != null) {
startActivityForResult(intent, VPN_PERMISSION_REQUEST_CODE)
true
} else {
false
}
} catch (e: Exception) {
onServiceAlert(Alert.RequestVPNPermission, e.message)
false
}
}
override fun onServiceStatusChanged(status: Status) {
serviceStatus.postValue(status)
}
override fun onServiceAlert(type: Alert, message: String?) {
serviceAlerts.postValue(ServiceEvent(Status.Stopped, type, message))
}
override fun onServiceWriteLog(message: String?) {
if (logList.size > 300) {
logList.removeFirst()
}
logList.addLast(message ?: "")
logCallback?.invoke(false)
}
override fun onServiceResetLogs(messages: MutableList<String>) {
logList.clear()
logList.addAll(messages)
logCallback?.invoke(true)
}
override fun onDestroy() {
connection.disconnect()
super.onDestroy()
}
@SuppressLint("NewApi")
private fun grantNotificationPermission() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
ActivityCompat.requestPermissions(
this,
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
NOTIFICATION_PERMISSION_REQUEST_CODE
)
}
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
if (requestCode == NOTIFICATION_PERMISSION_REQUEST_CODE) {
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
startService()
} else onServiceAlert(Alert.RequestNotificationPermission, null)
}
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == VPN_PERMISSION_REQUEST_CODE) {
if (resultCode == RESULT_OK) startService()
else onServiceAlert(Alert.RequestVPNPermission, null)
} else if (requestCode == NOTIFICATION_PERMISSION_REQUEST_CODE) {
if (resultCode == RESULT_OK) startService()
else onServiceAlert(Alert.RequestNotificationPermission, null)
}
}
}
@@ -0,0 +1,227 @@
package com.hiddify.hiddify
import android.util.Log
import com.hiddify.hiddify.bg.BoxService
import com.hiddify.hiddify.constant.Status
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.nekohasekai.libbox.Libbox
import io.nekohasekai.mobile.Mobile
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.io.File
class MethodHandler(private val scope: CoroutineScope) : FlutterPlugin,
MethodChannel.MethodCallHandler {
private var channel: MethodChannel? = null
companion object {
const val TAG = "A/MethodHandler"
const val channelName = "com.baer.app/method"
enum class Trigger(val method: String) {
Setup("setup"),
ParseConfig("parse_config"),
changeHiddifyOptions("change_hiddify_options"),
GenerateConfig("generate_config"),
Start("start"),
Stop("stop"),
Restart("restart"),
SelectOutbound("select_outbound"),
UrlTest("url_test"),
ClearLogs("clear_logs"),
GenerateWarpConfig("generate_warp_config"),
}
}
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
channel = MethodChannel(
flutterPluginBinding.binaryMessenger,
channelName,
)
channel!!.setMethodCallHandler(this)
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
channel?.setMethodCallHandler(null)
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
Trigger.Setup.method -> {
GlobalScope.launch {
result.runCatching {
val baseDir = Application.application.filesDir
baseDir.mkdirs()
val workingDir = Application.application.getExternalFilesDir(null)
workingDir?.mkdirs()
val tempDir = Application.application.cacheDir
tempDir.mkdirs()
Log.d(TAG, "base dir: ${baseDir.path}")
Log.d(TAG, "working dir: ${workingDir?.path}")
Log.d(TAG, "temp dir: ${tempDir.path}")
Mobile.setup(baseDir.path, workingDir?.path, tempDir.path, false)
Libbox.redirectStderr(File(workingDir, "stderr2.log").path)
success("")
}
}
}
Trigger.ParseConfig.method -> {
scope.launch(Dispatchers.IO) {
result.runCatching {
val args = call.arguments as Map<*, *>
val path = args["path"] as String
val tempPath = args["tempPath"] as String
val debug = args["debug"] as Boolean
val msg = BoxService.parseConfig(path, tempPath, debug)
success(msg)
}
}
}
Trigger.changeHiddifyOptions.method -> {
scope.launch {
result.runCatching {
val args = call.arguments as String
Settings.configOptions = args
success(true)
}
}
}
Trigger.GenerateConfig.method -> {
scope.launch {
result.runCatching {
val args = call.arguments as Map<*, *>
val path = args["path"] as String
val options = Settings.configOptions
if (options.isBlank() || path.isBlank()) {
error("blank properties")
}
val config = BoxService.buildConfig(path, options)
success(config)
}
}
}
Trigger.Start.method -> {
scope.launch {
result.runCatching {
val args = call.arguments as Map<*, *>
Settings.activeConfigPath = args["path"] as String? ?: ""
Settings.activeProfileName = args["name"] as String? ?: ""
val mainActivity = MainActivity.instance
val started = mainActivity.serviceStatus.value == Status.Started
if (started) {
Log.w(TAG, "service is already running")
return@launch success(true)
}
mainActivity.startService()
success(true)
}
}
}
Trigger.Stop.method -> {
scope.launch {
result.runCatching {
val mainActivity = MainActivity.instance
val started = mainActivity.serviceStatus.value == Status.Started
if (!started) {
Log.w(TAG, "service is not running")
return@launch success(true)
}
BoxService.stop()
success(true)
}
}
}
Trigger.Restart.method -> {
scope.launch(Dispatchers.IO) {
result.runCatching {
val args = call.arguments as Map<*, *>
Settings.activeConfigPath = args["path"] as String? ?: ""
Settings.activeProfileName = args["name"] as String? ?: ""
val mainActivity = MainActivity.instance
val started = mainActivity.serviceStatus.value == Status.Started
if (!started) return@launch success(true)
val restart = Settings.rebuildServiceMode()
if (restart) {
mainActivity.reconnect()
BoxService.stop()
delay(1000L)
mainActivity.startService()
return@launch success(true)
}
runCatching {
Libbox.newStandaloneCommandClient().serviceReload()
success(true)
}.onFailure {
error(it)
}
}
}
}
Trigger.SelectOutbound.method -> {
scope.launch {
result.runCatching {
val args = call.arguments as Map<*, *>
Libbox.newStandaloneCommandClient()
.selectOutbound(
args["groupTag"] as String,
args["outboundTag"] as String
)
success(true)
}
}
}
Trigger.UrlTest.method -> {
scope.launch {
result.runCatching {
val args = call.arguments as Map<*, *>
Libbox.newStandaloneCommandClient()
.urlTest(
args["groupTag"] as String
)
success(true)
}
}
}
Trigger.ClearLogs.method -> {
scope.launch {
result.runCatching {
MainActivity.instance.onServiceResetLogs(mutableListOf())
success(true)
}
}
}
Trigger.GenerateWarpConfig.method -> {
scope.launch(Dispatchers.IO) {
result.runCatching {
val args = call.arguments as Map<*, *>
val warpConfig = Mobile.generateWarpConfig(
args["license-key"] as String,
args["previous-account-id"] as String,
args["previous-access-token"] as String,
)
success(warpConfig)
}
}
}
else -> result.notImplemented()
}
}
}
@@ -0,0 +1,189 @@
package com.hiddify.hiddify
import android.Manifest
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Intent
import android.content.pm.ApplicationInfo
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.graphics.Canvas
import android.net.Uri
import android.os.Build
import android.util.Base64
import com.google.gson.Gson
import com.google.gson.annotations.SerializedName
import com.hiddify.hiddify.Application.Companion.packageManager
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.embedding.engine.plugins.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.PluginRegistry
import io.flutter.plugin.common.StandardMethodCodec
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import java.io.ByteArrayOutputStream
class PlatformSettingsHandler : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware,
PluginRegistry.ActivityResultListener {
private var channel: MethodChannel? = null
private var activity: Activity? = null
private lateinit var ignoreRequestResult: MethodChannel.Result
companion object {
const val channelName = "com.baer.app/platform"
const val REQUEST_IGNORE_BATTERY_OPTIMIZATIONS = 44
val gson = Gson()
enum class Trigger(val method: String) {
IsIgnoringBatteryOptimizations("is_ignoring_battery_optimizations"),
RequestIgnoreBatteryOptimizations("request_ignore_battery_optimizations"),
GetInstalledPackages("get_installed_packages"),
GetPackagesIcon("get_package_icon"),
}
}
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
val taskQueue = flutterPluginBinding.binaryMessenger.makeBackgroundTaskQueue()
channel = MethodChannel(
flutterPluginBinding.binaryMessenger,
channelName,
StandardMethodCodec.INSTANCE,
taskQueue
)
channel!!.setMethodCallHandler(this)
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
channel?.setMethodCallHandler(null)
}
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
activity = binding.activity
binding.addActivityResultListener(this)
}
override fun onDetachedFromActivityForConfigChanges() {
activity = null
}
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
activity = binding.activity
binding.addActivityResultListener(this)
}
override fun onDetachedFromActivity() {
activity = null
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
if (requestCode == REQUEST_IGNORE_BATTERY_OPTIMIZATIONS) {
ignoreRequestResult.success(resultCode == Activity.RESULT_OK)
return true
}
return false
}
data class AppItem(
@SerializedName("package-name") val packageName: String,
@SerializedName("name") val name: String,
@SerializedName("is-system-app") val isSystemApp: Boolean
)
@SuppressLint("BatteryLife")
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
Trigger.IsIgnoringBatteryOptimizations.method -> {
result.runCatching {
success(
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
Application.powerManager.isIgnoringBatteryOptimizations(Application.application.packageName)
} else {
true
}
)
}
}
Trigger.RequestIgnoreBatteryOptimizations.method -> {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
return result.success(true)
}
val intent = Intent(
android.provider.Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
Uri.parse("package:${Application.application.packageName}")
)
ignoreRequestResult = result
activity?.startActivityForResult(intent, REQUEST_IGNORE_BATTERY_OPTIMIZATIONS)
}
Trigger.GetInstalledPackages.method -> {
GlobalScope.launch {
result.runCatching {
val flag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
PackageManager.GET_PERMISSIONS or PackageManager.MATCH_UNINSTALLED_PACKAGES
} else {
@Suppress("DEPRECATION")
PackageManager.GET_PERMISSIONS or PackageManager.GET_UNINSTALLED_PACKAGES
}
val installedPackages =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
packageManager.getInstalledPackages(
PackageManager.PackageInfoFlags.of(
flag.toLong()
)
)
} else {
@Suppress("DEPRECATION")
packageManager.getInstalledPackages(flag)
}
val list = mutableListOf<AppItem>()
installedPackages.forEach {
if (it.packageName != Application.application.packageName &&
(it.requestedPermissions?.contains(Manifest.permission.INTERNET) == true
|| it.packageName == "android")
) {
list.add(
AppItem(
it.packageName,
it.applicationInfo?.loadLabel(packageManager)?.toString() ?: "",
(it.applicationInfo?.flags ?: 0) and ApplicationInfo.FLAG_SYSTEM == 1
)
)
}
}
list.sortBy { it.name }
success(gson.toJson(list))
}
}
}
Trigger.GetPackagesIcon.method -> {
result.runCatching {
val args = call.arguments as Map<*, *>
val packageName =
args["packageName"] as String
val drawable = packageManager.getApplicationIcon(packageName)
val bitmap = Bitmap.createBitmap(
drawable.intrinsicWidth,
drawable.intrinsicHeight,
Bitmap.Config.ARGB_8888
)
val canvas = Canvas(bitmap)
drawable.setBounds(0, 0, canvas.width, canvas.height)
drawable.draw(canvas)
val byteArrayOutputStream = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.PNG, 100, byteArrayOutputStream)
val base64: String =
Base64.encodeToString(byteArrayOutputStream.toByteArray(), Base64.NO_WRAP)
success(base64)
}
}
else -> result.notImplemented()
}
}
}
+126
View File
@@ -0,0 +1,126 @@
package com.hiddify.hiddify
import android.content.Context
import android.util.Base64
import com.hiddify.hiddify.bg.ProxyService
import com.hiddify.hiddify.bg.VPNService
import com.hiddify.hiddify.constant.PerAppProxyMode
import com.hiddify.hiddify.constant.ServiceMode
import com.hiddify.hiddify.constant.SettingsKey
import org.json.JSONObject
import java.io.ByteArrayInputStream
import java.io.File
import java.io.ObjectInputStream
object Settings {
private val preferences by lazy {
val context = Application.application.applicationContext
context.getSharedPreferences("FlutterSharedPreferences", Context.MODE_PRIVATE)
}
private const val LIST_IDENTIFIER = "VGhpcyBpcyB0aGUgcHJlZml4IGZvciBhIGxpc3Qu"
var perAppProxyMode: String
get() = preferences.getString(SettingsKey.PER_APP_PROXY_MODE, PerAppProxyMode.OFF)!!
set(value) = preferences.edit().putString(SettingsKey.PER_APP_PROXY_MODE, value).apply()
val perAppProxyEnabled: Boolean
get() = perAppProxyMode != PerAppProxyMode.OFF
val perAppProxyList: List<String>
get() {
val stringValue = if (perAppProxyMode == PerAppProxyMode.INCLUDE) {
preferences.getString(SettingsKey.PER_APP_PROXY_INCLUDE_LIST, "")!!
} else {
preferences.getString(SettingsKey.PER_APP_PROXY_EXCLUDE_LIST, "")!!
}
if (!stringValue.startsWith(LIST_IDENTIFIER)) {
return emptyList()
}
return decodeListString(stringValue.substring(LIST_IDENTIFIER.length))
}
private fun decodeListString(listString: String): List<String> {
val stream = ObjectInputStream(ByteArrayInputStream(Base64.decode(listString, 0)))
return stream.readObject() as List<String>
}
var activeConfigPath: String
get() = preferences.getString(SettingsKey.ACTIVE_CONFIG_PATH, "")!!
set(value) = preferences.edit().putString(SettingsKey.ACTIVE_CONFIG_PATH, value).apply()
var activeProfileName: String
get() = preferences.getString(SettingsKey.ACTIVE_PROFILE_NAME, "")!!
set(value) = preferences.edit().putString(SettingsKey.ACTIVE_PROFILE_NAME, value).apply()
var serviceMode: String
get() = preferences.getString(SettingsKey.SERVICE_MODE, ServiceMode.VPN)!!
set(value) = preferences.edit().putString(SettingsKey.SERVICE_MODE, value).apply()
var configOptions: String
get() = preferences.getString(SettingsKey.CONFIG_OPTIONS, "")!!
set(value) = preferences.edit().putString(SettingsKey.CONFIG_OPTIONS, value).apply()
var debugMode: Boolean
get() = preferences.getBoolean(SettingsKey.DEBUG_MODE, false)
set(value) = preferences.edit().putBoolean(SettingsKey.DEBUG_MODE, value).apply()
var disableMemoryLimit: Boolean
get() = preferences.getBoolean(SettingsKey.DISABLE_MEMORY_LIMIT, false)
set(value) =
preferences.edit().putBoolean(SettingsKey.DISABLE_MEMORY_LIMIT, value).apply()
var dynamicNotification: Boolean
get() = preferences.getBoolean(SettingsKey.DYNAMIC_NOTIFICATION, true)
set(value) =
preferences.edit().putBoolean(SettingsKey.DYNAMIC_NOTIFICATION, value).apply()
var systemProxyEnabled: Boolean
get() = preferences.getBoolean(SettingsKey.SYSTEM_PROXY_ENABLED, true)
set(value) =
preferences.edit().putBoolean(SettingsKey.SYSTEM_PROXY_ENABLED, value).apply()
var startedByUser: Boolean
get() = preferences.getBoolean(SettingsKey.STARTED_BY_USER, false)
set(value) = preferences.edit().putBoolean(SettingsKey.STARTED_BY_USER, value).apply()
fun serviceClass(): Class<*> {
return when (serviceMode) {
ServiceMode.VPN -> VPNService::class.java
else -> ProxyService::class.java
}
}
private var currentServiceMode : String? = null
suspend fun rebuildServiceMode(): Boolean {
var newMode = ServiceMode.NORMAL
try {
if (serviceMode == ServiceMode.VPN) {
newMode = ServiceMode.VPN
}
} catch (_: Exception) {
}
if (currentServiceMode == newMode) {
return false
}
currentServiceMode = newMode
return true
}
private suspend fun needVPNService(): Boolean {
val filePath = activeConfigPath
if (filePath.isBlank()) return false
val content = JSONObject(File(filePath).readText())
val inbounds = content.getJSONArray("inbounds")
for (index in 0 until inbounds.length()) {
val inbound = inbounds.getJSONObject(index)
if (inbound.getString("type") == "tun") {
return true
}
}
return false
}
}
@@ -0,0 +1,67 @@
package com.hiddify.hiddify
import android.app.Activity
import android.content.Intent
import android.content.pm.ShortcutManager
import android.os.Build
import android.os.Bundle
import androidx.core.content.getSystemService
import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat
import com.hiddify.hiddify.bg.BoxService
import com.hiddify.hiddify.bg.ServiceConnection
import com.hiddify.hiddify.constant.Status
class ShortcutActivity : Activity(), ServiceConnection.Callback {
private val connection = ServiceConnection(this, this, false)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (intent.action == Intent.ACTION_CREATE_SHORTCUT) {
setResult(
RESULT_OK, ShortcutManagerCompat.createShortcutResultIntent(
this,
ShortcutInfoCompat.Builder(this, "toggle")
.setIntent(
Intent(
this,
ShortcutActivity::class.java
).setAction(Intent.ACTION_MAIN)
)
.setIcon(
IconCompat.createWithResource(
this,
R.mipmap.ic_launcher
)
)
.setShortLabel(getString(R.string.quick_toggle))
.build()
)
)
finish()
} else {
connection.connect()
if (Build.VERSION.SDK_INT >= 25) {
getSystemService<ShortcutManager>()?.reportShortcutUsed("toggle")
}
}
moveTaskToBack(true)
}
override fun onServiceStatusChanged(status: Status) {
when (status) {
Status.Started -> BoxService.stop()
Status.Stopped -> BoxService.start()
else -> {}
}
finish()
}
override fun onDestroy() {
connection.disconnect()
super.onDestroy()
}
}
@@ -0,0 +1,64 @@
package com.hiddify.hiddify
import android.util.Log
import com.hiddify.hiddify.utils.CommandClient
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.JSONMethodCodec
import io.nekohasekai.libbox.StatusMessage
import kotlinx.coroutines.CoroutineScope
class StatsChannel(private val scope: CoroutineScope) : FlutterPlugin, CommandClient.Handler{
companion object {
const val TAG = "A/StatsChannel"
const val STATS_CHANNEL = "com.baer.app/stats"
}
private val commandClient =
CommandClient(scope, CommandClient.ConnectionType.Status, this)
private var statsChannel: EventChannel? = null
private var statsEvent: EventChannel.EventSink? = null
override fun updateStatus(status: StatusMessage) {
MainActivity.instance.runOnUiThread {
val map = listOf(
Pair("connections-in", status.connectionsIn),
Pair("connections-out", status.connectionsOut),
Pair("uplink", status.uplink),
Pair("downlink", status.downlink),
Pair("uplink-total", status.uplinkTotal),
Pair("downlink-total", status.downlinkTotal)
).associate { it.first to it.second }
statsEvent?.success(map)
}
}
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
statsChannel = EventChannel(
flutterPluginBinding.binaryMessenger,
STATS_CHANNEL,
JSONMethodCodec.INSTANCE
)
statsChannel!!.setStreamHandler(object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
statsEvent = events
Log.d(TAG, "connecting stats command client")
commandClient.connect()
}
override fun onCancel(arguments: Any?) {
statsEvent = null
Log.d(TAG, "disconnecting stats command client")
commandClient.disconnect()
}
})
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
statsEvent = null
commandClient.disconnect()
statsChannel?.setStreamHandler(null)
}
}
@@ -0,0 +1,37 @@
package com.hiddify.hiddify.bg
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.hiddify.hiddify.Settings
class AppChangeReceiver : BroadcastReceiver() {
companion object {
private const val TAG = "A/AppChangeReceiver"
}
override fun onReceive(context: Context, intent: Intent) {
checkUpdate(context, intent)
}
private fun checkUpdate(context: Context, intent: Intent) {
// if (!Settings.perAppProxyEnabled) {
// return
// }
// val perAppProxyUpdateOnChange = Settings.perAppProxyUpdateOnChange
// if (perAppProxyUpdateOnChange == Settings.PER_APP_PROXY_DISABLED) {
// return
// }
// val packageName = intent.dataString?.substringAfter("package:")
// if (packageName.isNullOrBlank()) {
// return
// }
// if ((perAppProxyUpdateOnChange == Settings.PER_APP_PROXY_INCLUDE)) {
// Settings.perAppProxyList = Settings.perAppProxyList + packageName
// } else {
// Settings.perAppProxyList = Settings.perAppProxyList - packageName
// }
}
}
@@ -0,0 +1,30 @@
package com.hiddify.hiddify.bg
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.hiddify.hiddify.Settings
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class BootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
Intent.ACTION_BOOT_COMPLETED, Intent.ACTION_MY_PACKAGE_REPLACED -> {
}
else -> return
}
GlobalScope.launch(Dispatchers.IO) {
if (Settings.startedByUser) {
withContext(Dispatchers.Main) {
BoxService.start()
}
}
}
}
}
@@ -0,0 +1,364 @@
package com.hiddify.hiddify.bg
import android.app.Service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Build
import android.os.IBinder
import android.os.ParcelFileDescriptor
import android.os.PowerManager
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.core.content.ContextCompat
import androidx.lifecycle.MutableLiveData
import com.hiddify.hiddify.Application
import com.hiddify.hiddify.R
import com.hiddify.hiddify.Settings
import com.hiddify.hiddify.constant.Action
import com.hiddify.hiddify.constant.Alert
import com.hiddify.hiddify.constant.Status
import go.Seq
import io.nekohasekai.libbox.BoxService
import io.nekohasekai.libbox.CommandServer
import io.nekohasekai.libbox.CommandServerHandler
import io.nekohasekai.libbox.Libbox
import io.nekohasekai.libbox.PlatformInterface
import io.nekohasekai.libbox.SystemProxyStatus
import io.nekohasekai.mobile.Mobile
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import java.io.File
class BoxService(
private val service: Service,
private val platformInterface: PlatformInterface
) : CommandServerHandler {
companion object {
private const val TAG = "A/BoxService"
private var initializeOnce = false
private lateinit var workingDir: File
private fun initialize() {
if (initializeOnce) return
val baseDir = Application.application.filesDir
baseDir.mkdirs()
workingDir = Application.application.getExternalFilesDir(null) ?: return
workingDir.mkdirs()
val tempDir = Application.application.cacheDir
tempDir.mkdirs()
Log.d(TAG, "base dir: ${baseDir.path}")
Log.d(TAG, "working dir: ${workingDir.path}")
Log.d(TAG, "temp dir: ${tempDir.path}")
Mobile.setup(baseDir.path, workingDir.path, tempDir.path, false)
Libbox.redirectStderr(File(workingDir, "stderr.log").path)
initializeOnce = true
return
}
fun parseConfig(path: String, tempPath: String, debug: Boolean): String {
return try {
Mobile.parse(path, tempPath, debug)
""
} catch (e: Exception) {
Log.w(TAG, e)
e.message ?: "invalid config"
}
}
fun buildConfig(path: String, options: String): String {
return Mobile.buildConfig(path, options)
}
fun start() {
val intent = runBlocking {
withContext(Dispatchers.IO) {
Intent(Application.application, Settings.serviceClass())
}
}
ContextCompat.startForegroundService(Application.application, intent)
}
fun stop() {
Application.application.sendBroadcast(
Intent(Action.SERVICE_CLOSE).setPackage(
Application.application.packageName
)
)
}
fun reload() {
Application.application.sendBroadcast(
Intent(Action.SERVICE_RELOAD).setPackage(
Application.application.packageName
)
)
}
}
var fileDescriptor: ParcelFileDescriptor? = null
private val status = MutableLiveData(Status.Stopped)
private val binder = ServiceBinder(status)
private val notification = ServiceNotification(status, service)
private var boxService: BoxService? = null
private var commandServer: CommandServer? = null
private var receiverRegistered = false
private val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
Action.SERVICE_CLOSE -> {
stopService()
}
Action.SERVICE_RELOAD -> {
serviceReload()
}
PowerManager.ACTION_DEVICE_IDLE_MODE_CHANGED -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
serviceUpdateIdleMode()
}
}
}
}
}
private fun startCommandServer() {
val commandServer =
CommandServer(this, 300)
commandServer.start()
this.commandServer = commandServer
}
private var activeProfileName = ""
private suspend fun startService(delayStart: Boolean = false) {
try {
Log.d(TAG, "starting service")
// 暂时禁用通知显示
// withContext(Dispatchers.Main) {
// notification.show(activeProfileName, R.string.status_starting)
// }
val selectedConfigPath = Settings.activeConfigPath
if (selectedConfigPath.isBlank()) {
stopAndAlert(Alert.EmptyConfiguration)
return
}
activeProfileName = Settings.activeProfileName
val configOptions = Settings.configOptions
if (configOptions.isBlank()) {
stopAndAlert(Alert.EmptyConfiguration)
return
}
val content = try {
Mobile.buildConfig(selectedConfigPath, configOptions)
} catch (e: Exception) {
Log.w(TAG, e)
stopAndAlert(Alert.EmptyConfiguration)
return
}
if (Settings.debugMode) {
File(workingDir, "current-config.json").writeText(content)
}
withContext(Dispatchers.Main) {
notification.show(activeProfileName, R.string.status_starting)
binder.broadcast {
it.onServiceResetLogs(listOf())
}
}
DefaultNetworkMonitor.start()
Libbox.registerLocalDNSTransport(LocalResolver)
Libbox.setMemoryLimit(!Settings.disableMemoryLimit)
val newService = try {
Libbox.newService(content, platformInterface)
} catch (e: Exception) {
stopAndAlert(Alert.CreateService, e.message)
return
}
if (delayStart) {
delay(1000L)
}
newService.start()
boxService = newService
commandServer?.setService(boxService)
status.postValue(Status.Started)
withContext(Dispatchers.Main) {
notification.show(activeProfileName, R.string.status_started)
}
notification.start()
} catch (e: Exception) {
stopAndAlert(Alert.StartService, e.message)
return
}
}
override fun serviceReload() {
notification.close()
status.postValue(Status.Starting)
val pfd = fileDescriptor
if (pfd != null) {
pfd.close()
fileDescriptor = null
}
commandServer?.setService(null)
boxService?.apply {
runCatching {
close()
}.onFailure {
writeLog("service: error when closing: $it")
}
Seq.destroyRef(refnum)
}
boxService = null
runBlocking {
startService(true)
}
}
override fun getSystemProxyStatus(): SystemProxyStatus {
val status = SystemProxyStatus()
if (service is VPNService) {
status.available = service.systemProxyAvailable
status.enabled = service.systemProxyEnabled
}
return status
}
override fun setSystemProxyEnabled(isEnabled: Boolean) {
serviceReload()
}
@RequiresApi(Build.VERSION_CODES.M)
private fun serviceUpdateIdleMode() {
if (Application.powerManager.isDeviceIdleMode) {
boxService?.pause()
} else {
boxService?.wake()
}
}
private fun stopService() {
if (status.value != Status.Started) return
status.value = Status.Stopping
if (receiverRegistered) {
service.unregisterReceiver(receiver)
receiverRegistered = false
}
notification.close()
GlobalScope.launch(Dispatchers.IO) {
val pfd = fileDescriptor
if (pfd != null) {
pfd.close()
fileDescriptor = null
}
commandServer?.setService(null)
boxService?.apply {
runCatching {
close()
}.onFailure {
writeLog("service: error when closing: $it")
}
Seq.destroyRef(refnum)
}
boxService = null
Libbox.registerLocalDNSTransport(null)
DefaultNetworkMonitor.stop()
commandServer?.apply {
close()
Seq.destroyRef(refnum)
}
commandServer = null
Settings.startedByUser = false
withContext(Dispatchers.Main) {
status.value = Status.Stopped
service.stopSelf()
}
}
}
override fun postServiceClose() {
// Not used on Android
}
private suspend fun stopAndAlert(type: Alert, message: String? = null) {
Settings.startedByUser = false
withContext(Dispatchers.Main) {
if (receiverRegistered) {
service.unregisterReceiver(receiver)
receiverRegistered = false
}
notification.close()
binder.broadcast { callback ->
callback.onServiceAlert(type.ordinal, message)
}
status.value = Status.Stopped
}
}
fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (status.value != Status.Stopped) return Service.START_NOT_STICKY
status.value = Status.Starting
if (!receiverRegistered) {
ContextCompat.registerReceiver(service, receiver, IntentFilter().apply {
addAction(Action.SERVICE_CLOSE)
addAction(Action.SERVICE_RELOAD)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
addAction(PowerManager.ACTION_DEVICE_IDLE_MODE_CHANGED)
}
}, ContextCompat.RECEIVER_NOT_EXPORTED)
receiverRegistered = true
}
GlobalScope.launch(Dispatchers.IO) {
Settings.startedByUser = true
initialize()
try {
startCommandServer()
} catch (e: Exception) {
stopAndAlert(Alert.StartCommandServer, e.message)
return@launch
}
startService()
}
return Service.START_NOT_STICKY
}
fun onBind(intent: Intent): IBinder {
return binder
}
fun onDestroy() {
binder.close()
}
fun onRevoke() {
stopService()
}
fun writeLog(message: String) {
binder.broadcast {
it.onServiceWriteLog(message)
}
}
}
@@ -0,0 +1,180 @@
package com.hiddify.hiddify.bg
import android.annotation.TargetApi
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.os.Build
import android.os.Handler
import android.os.Looper
import com.hiddify.hiddify.Application
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.channels.actor
import kotlinx.coroutines.runBlocking
import java.net.UnknownHostException
object DefaultNetworkListener {
private sealed class NetworkMessage {
class Start(val key: Any, val listener: (Network?) -> Unit) : NetworkMessage()
class Get : NetworkMessage() {
val response = CompletableDeferred<Network>()
}
class Stop(val key: Any) : NetworkMessage()
class Put(val network: Network) : NetworkMessage()
class Update(val network: Network) : NetworkMessage()
class Lost(val network: Network) : NetworkMessage()
}
private val networkActor = GlobalScope.actor<NetworkMessage>(Dispatchers.Unconfined) {
val listeners = mutableMapOf<Any, (Network?) -> Unit>()
var network: Network? = null
val pendingRequests = arrayListOf<NetworkMessage.Get>()
for (message in channel) when (message) {
is NetworkMessage.Start -> {
if (listeners.isEmpty()) register()
listeners[message.key] = message.listener
if (network != null) message.listener(network)
}
is NetworkMessage.Get -> {
check(listeners.isNotEmpty()) { "Getting network without any listeners is not supported" }
if (network == null) pendingRequests += message else message.response.complete(
network
)
}
is NetworkMessage.Stop -> if (listeners.isNotEmpty() && // was not empty
listeners.remove(message.key) != null && listeners.isEmpty()
) {
network = null
unregister()
}
is NetworkMessage.Put -> {
network = message.network
pendingRequests.forEach { it.response.complete(message.network) }
pendingRequests.clear()
listeners.values.forEach { it(network) }
}
is NetworkMessage.Update -> if (network == message.network) listeners.values.forEach {
it(
network
)
}
is NetworkMessage.Lost -> if (network == message.network) {
network = null
listeners.values.forEach { it(null) }
}
}
}
suspend fun start(key: Any, listener: (Network?) -> Unit) = networkActor.send(
NetworkMessage.Start(
key,
listener
)
)
suspend fun get() = if (fallback) @TargetApi(23) {
Application.connectivity.activeNetwork
?: throw UnknownHostException() // failed to listen, return current if available
} else NetworkMessage.Get().run {
networkActor.send(this)
response.await()
}
suspend fun stop(key: Any) = networkActor.send(NetworkMessage.Stop(key))
// NB: this runs in ConnectivityThread, and this behavior cannot be changed until API 26
private object Callback : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) = runBlocking {
networkActor.send(
NetworkMessage.Put(
network
)
)
}
override fun onCapabilitiesChanged(
network: Network,
networkCapabilities: NetworkCapabilities
) {
// it's a good idea to refresh capabilities
runBlocking { networkActor.send(NetworkMessage.Update(network)) }
}
override fun onLost(network: Network) = runBlocking {
networkActor.send(
NetworkMessage.Lost(
network
)
)
}
}
private var fallback = false
private val request = NetworkRequest.Builder().apply {
addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED)
if (Build.VERSION.SDK_INT == 23) { // workarounds for OEM bugs
removeCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
removeCapability(NetworkCapabilities.NET_CAPABILITY_CAPTIVE_PORTAL)
}
}.build()
private val mainHandler = Handler(Looper.getMainLooper())
/**
* Unfortunately registerDefaultNetworkCallback is going to return VPN interface since Android P DP1:
* https://android.googlesource.com/platform/frameworks/base/+/dda156ab0c5d66ad82bdcf76cda07cbc0a9c8a2e
*
* This makes doing a requestNetwork with REQUEST necessary so that we don't get ALL possible networks that
* satisfies default network capabilities but only THE default network. Unfortunately, we need to have
* android.permission.CHANGE_NETWORK_STATE to be able to call requestNetwork.
*
* Source: https://android.googlesource.com/platform/frameworks/base/+/2df4c7d/services/core/java/com/android/server/ConnectivityService.java#887
*/
private fun register() {
when (Build.VERSION.SDK_INT) {
in 31..Int.MAX_VALUE -> @TargetApi(31) {
Application.connectivity.registerBestMatchingNetworkCallback(
request,
Callback,
mainHandler
)
}
in 28 until 31 -> @TargetApi(28) { // we want REQUEST here instead of LISTEN
Application.connectivity.requestNetwork(request, Callback, mainHandler)
}
in 26 until 28 -> @TargetApi(26) {
Application.connectivity.registerDefaultNetworkCallback(Callback, mainHandler)
}
in 24 until 26 -> @TargetApi(24) {
Application.connectivity.registerDefaultNetworkCallback(Callback)
}
else -> try {
fallback = false
Application.connectivity.requestNetwork(request, Callback)
} catch (e: RuntimeException) {
fallback =
true // known bug on API 23: https://stackoverflow.com/a/33509180/2245107
}
}
}
private fun unregister() {
runCatching {
Application.connectivity.unregisterNetworkCallback(Callback)
}
}
}
@@ -0,0 +1,67 @@
package com.hiddify.hiddify.bg
import android.net.Network
import android.os.Build
import com.hiddify.hiddify.Application
import io.nekohasekai.libbox.InterfaceUpdateListener
import java.net.NetworkInterface
object DefaultNetworkMonitor {
var defaultNetwork: Network? = null
private var listener: InterfaceUpdateListener? = null
suspend fun start() {
DefaultNetworkListener.start(this) {
defaultNetwork = it
checkDefaultInterfaceUpdate(it)
}
defaultNetwork = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
Application.connectivity.activeNetwork
} else {
DefaultNetworkListener.get()
}
}
suspend fun stop() {
DefaultNetworkListener.stop(this)
}
suspend fun require(): Network {
val network = defaultNetwork
if (network != null) {
return network
}
return DefaultNetworkListener.get()
}
fun setListener(listener: InterfaceUpdateListener?) {
this.listener = listener
checkDefaultInterfaceUpdate(defaultNetwork)
}
private fun checkDefaultInterfaceUpdate(
newNetwork: Network?
) {
val listener = listener ?: return
if (newNetwork != null) {
val interfaceName =
(Application.connectivity.getLinkProperties(newNetwork) ?: return).interfaceName
for (times in 0 until 10) {
var interfaceIndex: Int
try {
interfaceIndex = NetworkInterface.getByName(interfaceName).index
} catch (e: Exception) {
Thread.sleep(100)
continue
}
listener.updateDefaultInterface(interfaceName, interfaceIndex)
}
} else {
listener.updateDefaultInterface("", -1)
}
}
}
@@ -0,0 +1,134 @@
package com.hiddify.hiddify.bg
import android.net.DnsResolver
import android.os.Build
import android.os.CancellationSignal
import android.system.ErrnoException
import androidx.annotation.RequiresApi
import com.hiddify.hiddify.ktx.tryResumeWithException
import io.nekohasekai.libbox.ExchangeContext
import io.nekohasekai.libbox.LocalDNSTransport
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asExecutor
import kotlinx.coroutines.runBlocking
import java.net.InetAddress
import java.net.UnknownHostException
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
object LocalResolver : LocalDNSTransport {
private const val RCODE_NXDOMAIN = 3
override fun raw(): Boolean {
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
}
@RequiresApi(Build.VERSION_CODES.Q)
override fun exchange(ctx: ExchangeContext, message: ByteArray) {
return runBlocking {
val defaultNetwork = DefaultNetworkMonitor.require()
suspendCoroutine { continuation ->
val signal = CancellationSignal()
ctx.onCancel(signal::cancel)
val callback = object : DnsResolver.Callback<ByteArray> {
override fun onAnswer(answer: ByteArray, rcode: Int) {
if (rcode == 0) {
ctx.rawSuccess(answer)
} else {
ctx.errorCode(rcode)
}
continuation.resume(Unit)
}
override fun onError(error: DnsResolver.DnsException) {
when (val cause = error.cause) {
is ErrnoException -> {
ctx.errnoCode(cause.errno)
continuation.resume(Unit)
return
}
}
continuation.tryResumeWithException(error)
}
}
DnsResolver.getInstance().rawQuery(
defaultNetwork,
message,
DnsResolver.FLAG_NO_RETRY,
Dispatchers.IO.asExecutor(),
signal,
callback
)
}
}
}
override fun lookup(ctx: ExchangeContext, network: String, domain: String) {
return runBlocking {
val defaultNetwork = DefaultNetworkMonitor.require()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
suspendCoroutine { continuation ->
val signal = CancellationSignal()
ctx.onCancel(signal::cancel)
val callback = object : DnsResolver.Callback<Collection<InetAddress>> {
@Suppress("ThrowableNotThrown")
override fun onAnswer(answer: Collection<InetAddress>, rcode: Int) {
if (rcode == 0) {
ctx.success((answer as Collection<InetAddress?>).mapNotNull { it?.hostAddress }
.joinToString("\n"))
} else {
ctx.errorCode(rcode)
}
continuation.resume(Unit)
}
override fun onError(error: DnsResolver.DnsException) {
when (val cause = error.cause) {
is ErrnoException -> {
ctx.errnoCode(cause.errno)
continuation.resume(Unit)
return
}
}
continuation.tryResumeWithException(error)
}
}
val type = when {
network.endsWith("4") -> DnsResolver.TYPE_A
network.endsWith("6") -> DnsResolver.TYPE_AAAA
else -> null
}
if (type != null) {
DnsResolver.getInstance().query(
defaultNetwork,
domain,
type,
DnsResolver.FLAG_NO_RETRY,
Dispatchers.IO.asExecutor(),
signal,
callback
)
} else {
DnsResolver.getInstance().query(
defaultNetwork,
domain,
DnsResolver.FLAG_NO_RETRY,
Dispatchers.IO.asExecutor(),
signal,
callback
)
}
}
} else {
val answer = try {
defaultNetwork.getAllByName(domain)
} catch (e: UnknownHostException) {
ctx.errorCode(RCODE_NXDOMAIN)
return@runBlocking
}
ctx.success(answer.mapNotNull { it.hostAddress }.joinToString("\n"))
}
}
}
}
@@ -0,0 +1,156 @@
package com.hiddify.hiddify.bg
import android.content.pm.PackageManager
import android.os.Build
import android.os.Process
import androidx.annotation.RequiresApi
import com.hiddify.hiddify.Application
import io.nekohasekai.libbox.InterfaceUpdateListener
import io.nekohasekai.libbox.NetworkInterfaceIterator
import io.nekohasekai.libbox.PlatformInterface
import io.nekohasekai.libbox.StringIterator
import io.nekohasekai.libbox.TunOptions
import io.nekohasekai.libbox.WIFIState
import java.net.Inet6Address
import java.net.InetSocketAddress
import java.net.InterfaceAddress
import java.net.NetworkInterface
import java.util.Enumeration
import io.nekohasekai.libbox.NetworkInterface as LibboxNetworkInterface
interface PlatformInterfaceWrapper : PlatformInterface {
override fun usePlatformAutoDetectInterfaceControl(): Boolean {
return true
}
override fun autoDetectInterfaceControl(fd: Int) {
}
override fun openTun(options: TunOptions): Int {
error("invalid argument")
}
override fun useProcFS(): Boolean {
return Build.VERSION.SDK_INT < Build.VERSION_CODES.Q
}
@RequiresApi(Build.VERSION_CODES.Q)
override fun findConnectionOwner(
ipProtocol: Int,
sourceAddress: String,
sourcePort: Int,
destinationAddress: String,
destinationPort: Int
): Int {
val uid = Application.connectivity.getConnectionOwnerUid(
ipProtocol,
InetSocketAddress(sourceAddress, sourcePort),
InetSocketAddress(destinationAddress, destinationPort)
)
if (uid == Process.INVALID_UID) error("android: connection owner not found")
return uid
}
override fun packageNameByUid(uid: Int): String {
val packages = Application.packageManager.getPackagesForUid(uid)
if (packages.isNullOrEmpty()) error("android: package not found")
return packages[0]
}
@Suppress("DEPRECATION")
override fun uidByPackageName(packageName: String): Int {
return try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
Application.packageManager.getPackageUid(
packageName, PackageManager.PackageInfoFlags.of(0)
)
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
Application.packageManager.getPackageUid(packageName, 0)
} else {
Application.packageManager.getApplicationInfo(packageName, 0).uid
}
} catch (e: PackageManager.NameNotFoundException) {
error("android: package not found")
}
}
override fun usePlatformDefaultInterfaceMonitor(): Boolean {
return true
}
override fun startDefaultInterfaceMonitor(listener: InterfaceUpdateListener) {
DefaultNetworkMonitor.setListener(listener)
}
override fun closeDefaultInterfaceMonitor(listener: InterfaceUpdateListener) {
DefaultNetworkMonitor.setListener(null)
}
override fun usePlatformInterfaceGetter(): Boolean {
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.R
}
override fun getInterfaces(): NetworkInterfaceIterator {
return InterfaceArray(NetworkInterface.getNetworkInterfaces())
}
override fun underNetworkExtension(): Boolean {
return false
}
override fun includeAllNetworks(): Boolean {
return false
}
override fun clearDNSCache() {
}
override fun readWIFIState(): WIFIState? {
return null
}
private class InterfaceArray(private val iterator: Enumeration<NetworkInterface>) :
NetworkInterfaceIterator {
override fun hasNext(): Boolean {
return iterator.hasMoreElements()
}
override fun next(): LibboxNetworkInterface {
val element = iterator.nextElement()
return LibboxNetworkInterface().apply {
name = element.name
index = element.index
runCatching {
mtu = element.mtu
}
addresses =
StringArray(
element.interfaceAddresses.mapTo(mutableListOf()) { it.toPrefix() }
.iterator()
)
}
}
private fun InterfaceAddress.toPrefix(): String {
return if (address is Inet6Address) {
"${Inet6Address.getByAddress(address.address).hostAddress}/${networkPrefixLength}"
} else {
"${address.hostAddress}/${networkPrefixLength}"
}
}
}
private class StringArray(private val iterator: Iterator<String>) : StringIterator {
override fun hasNext(): Boolean {
return iterator.hasNext()
}
override fun next(): String {
return iterator.next()
}
}
}
@@ -0,0 +1,17 @@
package com.hiddify.hiddify.bg
import android.app.Service
import android.content.Intent
class ProxyService : Service(), PlatformInterfaceWrapper {
private val service = BoxService(this, this)
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int) =
service.onStartCommand(intent, flags, startId)
override fun onBind(intent: Intent) = service.onBind(intent)
override fun onDestroy() = service.onDestroy()
override fun writeLog(message: String) = service.writeLog(message)
}
@@ -0,0 +1,59 @@
package com.hiddify.hiddify.bg
import android.os.RemoteCallbackList
import androidx.lifecycle.MutableLiveData
import com.hiddify.hiddify.IService
import com.hiddify.hiddify.IServiceCallback
import com.hiddify.hiddify.constant.Status
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
class ServiceBinder(private val status: MutableLiveData<Status>) : IService.Stub() {
private val callbacks = RemoteCallbackList<IServiceCallback>()
private val broadcastLock = Mutex()
init {
status.observeForever {
broadcast { callback ->
callback.onServiceStatusChanged(it.ordinal)
}
}
}
fun broadcast(work: (IServiceCallback) -> Unit) {
GlobalScope.launch(Dispatchers.Main) {
broadcastLock.withLock {
val count = callbacks.beginBroadcast()
try {
repeat(count) {
try {
work(callbacks.getBroadcastItem(it))
} catch (_: Exception) {
}
}
} finally {
callbacks.finishBroadcast()
}
}
}
}
override fun getStatus(): Int {
return (status.value ?: Status.Stopped).ordinal
}
override fun registerCallback(callback: IServiceCallback) {
callbacks.register(callback)
}
override fun unregisterCallback(callback: IServiceCallback?) {
callbacks.unregister(callback)
}
fun close() {
callbacks.kill()
}
}
@@ -0,0 +1,109 @@
package com.hiddify.hiddify.bg
import com.hiddify.hiddify.IService
import com.hiddify.hiddify.IServiceCallback
import com.hiddify.hiddify.Settings
import com.hiddify.hiddify.constant.Action
import com.hiddify.hiddify.constant.Alert
import com.hiddify.hiddify.constant.Status
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.IBinder
import android.os.RemoteException
import android.util.Log
import androidx.appcompat.app.AppCompatActivity
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
class ServiceConnection(
private val context: Context,
callback: Callback,
private val register: Boolean = true,
) : ServiceConnection {
companion object {
private const val TAG = "ServiceConnection"
}
private val callback = ServiceCallback(callback)
private var service: IService? = null
val status get() = service?.status?.let { Status.values()[it] } ?: Status.Stopped
fun connect() {
val intent = runBlocking {
withContext(Dispatchers.IO) {
Intent(context, Settings.serviceClass()).setAction(Action.SERVICE)
}
}
context.bindService(intent, this, AppCompatActivity.BIND_AUTO_CREATE)
}
fun disconnect() {
try {
context.unbindService(this)
} catch (_: IllegalArgumentException) {
}
}
fun reconnect() {
try {
context.unbindService(this)
} catch (_: IllegalArgumentException) {
}
val intent = runBlocking {
withContext(Dispatchers.IO) {
Intent(context, Settings.serviceClass()).setAction(Action.SERVICE)
}
}
context.bindService(intent, this, AppCompatActivity.BIND_AUTO_CREATE)
}
override fun onServiceConnected(name: ComponentName, binder: IBinder) {
val service = IService.Stub.asInterface(binder)
this.service = service
try {
if (register) service.registerCallback(callback)
callback.onServiceStatusChanged(service.status)
} catch (e: RemoteException) {
Log.e(TAG, "initialize service connection", e)
}
}
override fun onServiceDisconnected(name: ComponentName?) {
try {
service?.unregisterCallback(callback)
} catch (e: RemoteException) {
Log.e(TAG, "cleanup service connection", e)
}
}
override fun onBindingDied(name: ComponentName?) {
reconnect()
}
interface Callback {
fun onServiceStatusChanged(status: Status)
fun onServiceAlert(type: Alert, message: String?) {}
fun onServiceWriteLog(message: String?) {}
fun onServiceResetLogs(messages: MutableList<String>) {}
}
class ServiceCallback(private val callback: Callback) : IServiceCallback.Stub() {
override fun onServiceStatusChanged(status: Int) {
callback.onServiceStatusChanged(Status.values()[status])
}
override fun onServiceAlert(type: Int, message: String?) {
callback.onServiceAlert(Alert.values()[type], message)
}
override fun onServiceWriteLog(message: String?) = callback.onServiceWriteLog(message)
override fun onServiceResetLogs(messages: MutableList<String>) =
callback.onServiceResetLogs(messages)
}
}
@@ -0,0 +1,143 @@
package com.hiddify.hiddify.bg
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Build
import androidx.annotation.StringRes
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import androidx.lifecycle.MutableLiveData
import com.hiddify.hiddify.Application
import com.hiddify.hiddify.MainActivity
import com.hiddify.hiddify.R
import com.hiddify.hiddify.Settings
import com.hiddify.hiddify.constant.Action
import com.hiddify.hiddify.constant.Status
import com.hiddify.hiddify.utils.CommandClient
import io.nekohasekai.libbox.Libbox
import io.nekohasekai.libbox.StatusMessage
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.withContext
class ServiceNotification(private val status: MutableLiveData<Status>, private val service: Service) : BroadcastReceiver(), CommandClient.Handler {
companion object {
private const val notificationId = 1
private const val notificationChannel = "service"
private val flags =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0
fun checkPermission(): Boolean {
// 暂时禁用通知权限检查
return true
}
}
private val commandClient =
CommandClient(GlobalScope, CommandClient.ConnectionType.Status, this)
private var receiverRegistered = false
private val notificationBuilder by lazy {
NotificationCompat.Builder(service, notificationChannel)
.setShowWhen(false)
.setOngoing(true)
.setContentTitle("BearVPN")
.setOnlyAlertOnce(true)
.setSmallIcon(R.drawable.ic_stat_logo)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setContentIntent(
PendingIntent.getActivity(
service,
0,
Intent(
service,
MainActivity::class.java
).setFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT),
flags
)
)
.setPriority(NotificationCompat.PRIORITY_LOW).apply {
addAction(
NotificationCompat.Action.Builder(
0, service.getText(R.string.stop), PendingIntent.getBroadcast(
service,
0,
Intent(Action.SERVICE_CLOSE).setPackage(service.packageName),
flags
)
).build()
)
}
}
fun show(profileName: String, @StringRes contentTextId: Int) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Application.notification.createNotificationChannel(
NotificationChannel(
notificationChannel, "hiddify service", NotificationManager.IMPORTANCE_LOW
)
)
}
service.startForeground(
notificationId, notificationBuilder
.setContentTitle(profileName.takeIf { it.isNotBlank() } ?: "Hiddify")
.setContentText(service.getString(contentTextId)).build()
)
}
suspend fun start() {
if (Settings.dynamicNotification) {
commandClient.connect()
withContext(Dispatchers.Main) {
registerReceiver()
}
}
}
private fun registerReceiver() {
service.registerReceiver(this, IntentFilter().apply {
addAction(Intent.ACTION_SCREEN_ON)
addAction(Intent.ACTION_SCREEN_OFF)
})
receiverRegistered = true
}
override fun updateStatus(status: StatusMessage) {
val content =
Libbox.formatBytes(status.uplink) + "/s ↑\t" + Libbox.formatBytes(status.downlink) + "/s ↓"
Application.notificationManager.notify(
notificationId,
notificationBuilder.setContentText(content).build()
)
}
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
Intent.ACTION_SCREEN_ON -> {
commandClient.connect()
}
Intent.ACTION_SCREEN_OFF -> {
commandClient.disconnect()
}
}
}
fun close() {
commandClient.disconnect()
ServiceCompat.stopForeground(service, ServiceCompat.STOP_FOREGROUND_REMOVE)
if (receiverRegistered) {
service.unregisterReceiver(this)
receiverRegistered = false
}
}
}
@@ -0,0 +1,48 @@
package com.hiddify.hiddify.bg
import android.service.quicksettings.Tile
import android.service.quicksettings.TileService
import androidx.annotation.RequiresApi
import com.hiddify.hiddify.constant.Status
@RequiresApi(24)
class TileService : TileService(), ServiceConnection.Callback {
private val connection = ServiceConnection(this, this)
override fun onServiceStatusChanged(status: Status) {
qsTile?.apply {
state = when (status) {
Status.Started -> Tile.STATE_ACTIVE
Status.Stopped -> Tile.STATE_INACTIVE
else -> Tile.STATE_UNAVAILABLE
}
updateTile()
}
}
override fun onStartListening() {
super.onStartListening()
connection.connect()
}
override fun onStopListening() {
connection.disconnect()
super.onStopListening()
}
override fun onClick() {
when (connection.status) {
Status.Stopped -> {
BoxService.start()
}
Status.Started -> {
BoxService.stop()
}
else -> {}
}
}
}
@@ -0,0 +1,199 @@
package com.hiddify.hiddify.bg
import android.util.Log
import com.hiddify.hiddify.Settings
import android.content.Intent
import android.content.pm.PackageManager.NameNotFoundException
import android.net.ProxyInfo
import android.net.VpnService
import android.os.Build
import android.os.IBinder
import com.hiddify.hiddify.constant.PerAppProxyMode
import com.hiddify.hiddify.ktx.toIpPrefix
import io.nekohasekai.libbox.TunOptions
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
class VPNService : VpnService(), PlatformInterfaceWrapper {
companion object {
private const val TAG = "A/VPNService"
}
private val service = BoxService(this, this)
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int) =
service.onStartCommand(intent, flags, startId)
override fun onBind(intent: Intent): IBinder {
val binder = super.onBind(intent)
if (binder != null) {
return binder
}
return service.onBind(intent)
}
override fun onDestroy() {
service.onDestroy()
}
override fun onRevoke() {
runBlocking {
withContext(Dispatchers.Main) {
service.onRevoke()
}
}
}
override fun autoDetectInterfaceControl(fd: Int) {
protect(fd)
}
var systemProxyAvailable = false
var systemProxyEnabled = false
fun addIncludePackage(builder: Builder, packageName: String) {
if (packageName == this.packageName) {
Log.d("VpnService","Cannot include myself: $packageName")
return
}
try {
Log.d("VpnService","Including $packageName")
builder.addAllowedApplication(packageName)
} catch (e: NameNotFoundException) {
}
}
fun addExcludePackage(builder: Builder, packageName: String) {
try {
Log.d("VpnService","Excluding $packageName")
builder.addDisallowedApplication(packageName)
} catch (e: NameNotFoundException) {
}
}
override fun openTun(options: TunOptions): Int {
if (prepare(this) != null) error("android: missing vpn permission")
val builder = Builder()
.setSession("sing-box")
.setMtu(options.mtu)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
builder.setMetered(false)
}
val inet4Address = options.inet4Address
while (inet4Address.hasNext()) {
val address = inet4Address.next()
builder.addAddress(address.address(), address.prefix())
}
val inet6Address = options.inet6Address
while (inet6Address.hasNext()) {
val address = inet6Address.next()
builder.addAddress(address.address(), address.prefix())
}
if (options.autoRoute) {
builder.addDnsServer(options.dnsServerAddress)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val inet4RouteAddress = options.inet4RouteAddress
if (inet4RouteAddress.hasNext()) {
while (inet4RouteAddress.hasNext()) {
builder.addRoute(inet4RouteAddress.next().toIpPrefix())
}
} else {
builder.addRoute("0.0.0.0", 0)
}
val inet6RouteAddress = options.inet6RouteAddress
if (inet6RouteAddress.hasNext()) {
while (inet6RouteAddress.hasNext()) {
builder.addRoute(inet6RouteAddress.next().toIpPrefix())
}
} else {
builder.addRoute("::", 0)
}
val inet4RouteExcludeAddress = options.inet4RouteExcludeAddress
while (inet4RouteExcludeAddress.hasNext()) {
builder.excludeRoute(inet4RouteExcludeAddress.next().toIpPrefix())
}
val inet6RouteExcludeAddress = options.inet6RouteExcludeAddress
while (inet6RouteExcludeAddress.hasNext()) {
builder.excludeRoute(inet6RouteExcludeAddress.next().toIpPrefix())
}
} else {
val inet4RouteAddress = options.inet4RouteRange
if (inet4RouteAddress.hasNext()) {
while (inet4RouteAddress.hasNext()) {
val address = inet4RouteAddress.next()
builder.addRoute(address.address(), address.prefix())
}
}
val inet6RouteAddress = options.inet6RouteRange
if (inet6RouteAddress.hasNext()) {
while (inet6RouteAddress.hasNext()) {
val address = inet6RouteAddress.next()
builder.addRoute(address.address(), address.prefix())
}
}
}
if (Settings.perAppProxyEnabled) {
val appList = Settings.perAppProxyList
if (Settings.perAppProxyMode == PerAppProxyMode.INCLUDE) {
appList.forEach {
addIncludePackage(builder,it)
}
addIncludePackage(builder,packageName)
} else {
appList.forEach {
addExcludePackage(builder,it)
}
//addExcludePackage(builder,packageName)
}
} else {
val includePackage = options.includePackage
if (includePackage.hasNext()) {
while (includePackage.hasNext()) {
addIncludePackage(builder,includePackage.next())
}
}
val excludePackage = options.excludePackage
if (excludePackage.hasNext()) {
while (excludePackage.hasNext()) {
addExcludePackage(builder,excludePackage.next())
}
}
//addExcludePackage(builder,packageName)
}
}
if (options.isHTTPProxyEnabled && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
systemProxyAvailable = true
systemProxyEnabled = Settings.systemProxyEnabled
if (systemProxyEnabled) builder.setHttpProxy(
ProxyInfo.buildDirectProxy(
options.httpProxyServer, options.httpProxyServerPort
)
)
} else {
systemProxyAvailable = false
systemProxyEnabled = false
}
val pfd =
builder.establish() ?: error("android: the application is not prepared or is revoked")
service.fileDescriptor = pfd
return pfd.fd
}
override fun writeLog(message: String) = service.writeLog(message)
}
@@ -0,0 +1,7 @@
package com.hiddify.hiddify.constant
object Action {
const val SERVICE = "com.baer.app.SERVICE"
const val SERVICE_CLOSE = "com.baer.app.SERVICE_CLOSE"
const val SERVICE_RELOAD = "com.baer.app.sfa.SERVICE_RELOAD"
}
@@ -0,0 +1,10 @@
package com.hiddify.hiddify.constant
enum class Alert {
RequestVPNPermission,
RequestNotificationPermission,
EmptyConfiguration,
StartCommandServer,
CreateService,
StartService
}
@@ -0,0 +1,7 @@
package com.hiddify.hiddify.constant
object PerAppProxyMode {
const val OFF = "off"
const val INCLUDE = "include"
const val EXCLUDE = "exclude"
}
@@ -0,0 +1,6 @@
package com.hiddify.hiddify.constant
object ServiceMode {
const val NORMAL = "proxy"
const val VPN = "vpn"
}
@@ -0,0 +1,24 @@
package com.hiddify.hiddify.constant
object SettingsKey {
private const val KEY_PREFIX = "flutter."
const val SERVICE_MODE = "${KEY_PREFIX}service-mode"
const val ACTIVE_CONFIG_PATH = "${KEY_PREFIX}active_config_path"
const val ACTIVE_PROFILE_NAME = "${KEY_PREFIX}active_profile_name"
const val PER_APP_PROXY_MODE = "${KEY_PREFIX}per_app_proxy_mode"
const val PER_APP_PROXY_INCLUDE_LIST = "${KEY_PREFIX}per_app_proxy_include_list"
const val PER_APP_PROXY_EXCLUDE_LIST = "${KEY_PREFIX}per_app_proxy_exclude_list"
const val DEBUG_MODE = "${KEY_PREFIX}debug_mode"
const val DISABLE_MEMORY_LIMIT = "${KEY_PREFIX}disable_memory_limit"
const val DYNAMIC_NOTIFICATION = "${KEY_PREFIX}dynamic_notification"
const val SYSTEM_PROXY_ENABLED = "${KEY_PREFIX}system_proxy_enabled"
// cache
const val STARTED_BY_USER = "${KEY_PREFIX}started_by_user"
const val CONFIG_OPTIONS = "config_options_json"
}
@@ -0,0 +1,8 @@
package com.hiddify.hiddify.constant
enum class Status {
Stopped,
Starting,
Started,
Stopping,
}
@@ -0,0 +1,18 @@
package com.hiddify.hiddify.ktx
import kotlin.coroutines.Continuation
fun <T> Continuation<T>.tryResume(value: T) {
try {
resumeWith(Result.success(value))
} catch (ignored: IllegalStateException) {
}
}
fun <T> Continuation<T>.tryResumeWithException(exception: Throwable) {
try {
resumeWith(Result.failure(exception))
} catch (ignored: IllegalStateException) {
}
}
@@ -0,0 +1,19 @@
package com.hiddify.hiddify.ktx
import android.net.IpPrefix
import android.os.Build
import androidx.annotation.RequiresApi
import io.nekohasekai.libbox.RoutePrefix
import io.nekohasekai.libbox.StringIterator
import java.net.InetAddress
fun StringIterator.toList(): List<String> {
return mutableListOf<String>().apply {
while (hasNext()) {
add(next())
}
}
}
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
fun RoutePrefix.toIpPrefix() = IpPrefix(InetAddress.getByName(address()), prefix())
@@ -0,0 +1,139 @@
package com.hiddify.hiddify.utils
import go.Seq
import io.nekohasekai.libbox.CommandClient
import io.nekohasekai.libbox.CommandClientHandler
import io.nekohasekai.libbox.CommandClientOptions
import io.nekohasekai.libbox.Libbox
import io.nekohasekai.libbox.OutboundGroup
import io.nekohasekai.libbox.OutboundGroupIterator
import io.nekohasekai.libbox.StatusMessage
import io.nekohasekai.libbox.StringIterator
import com.hiddify.hiddify.ktx.toList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
open class CommandClient(
private val scope: CoroutineScope,
private val connectionType: ConnectionType,
private val handler: Handler
) {
enum class ConnectionType {
Status, Groups, Log, ClashMode, GroupOnly
}
interface Handler {
fun onConnected() {}
fun onDisconnected() {}
fun updateStatus(status: StatusMessage) {}
fun updateGroups(groups: List<OutboundGroup>) {}
fun clearLog() {}
fun appendLog(message: String) {}
fun initializeClashMode(modeList: List<String>, currentMode: String) {}
fun updateClashMode(newMode: String) {}
}
private var commandClient: CommandClient? = null
private val clientHandler = ClientHandler()
fun connect() {
disconnect()
val options = CommandClientOptions()
options.command = when (connectionType) {
ConnectionType.Status -> Libbox.CommandStatus
ConnectionType.Groups -> Libbox.CommandGroup
ConnectionType.Log -> Libbox.CommandLog
ConnectionType.ClashMode -> Libbox.CommandClashMode
ConnectionType.GroupOnly -> Libbox.CommandGroupInfoOnly
}
options.statusInterval = 2 * 1000 * 1000 * 1000
val commandClient = CommandClient(clientHandler, options)
scope.launch(Dispatchers.IO) {
for (i in 1..10) {
delay(100 + i.toLong() * 50)
try {
commandClient.connect()
} catch (ignored: Exception) {
continue
}
if (!isActive) {
runCatching {
commandClient.disconnect()
}
return@launch
}
this@CommandClient.commandClient = commandClient
return@launch
}
runCatching {
commandClient.disconnect()
}
}
}
fun disconnect() {
commandClient?.apply {
runCatching {
disconnect()
}
Seq.destroyRef(refnum)
}
commandClient = null
}
private inner class ClientHandler : CommandClientHandler {
override fun connected() {
handler.onConnected()
}
override fun disconnected(message: String?) {
handler.onDisconnected()
}
override fun writeGroups(message: OutboundGroupIterator?) {
if (message == null) {
return
}
val groups = mutableListOf<OutboundGroup>()
while (message.hasNext()) {
groups.add(message.next())
}
handler.updateGroups(groups)
}
override fun clearLog() {
handler.clearLog()
}
override fun writeLog(message: String?) {
if (message == null) {
return
}
handler.appendLog(message)
}
override fun writeStatus(message: StatusMessage?) {
if (message == null) {
return
}
handler.updateStatus(message)
}
override fun initializeClashMode(modeList: StringIterator, currentMode: String) {
handler.initializeClashMode(modeList.toList(), currentMode)
}
override fun updateClashMode(newMode: String) {
handler.updateClashMode(newMode)
}
}
}
@@ -0,0 +1,31 @@
package com.hiddify.hiddify.utils
import com.google.gson.annotations.SerializedName
import io.nekohasekai.libbox.OutboundGroup
import io.nekohasekai.libbox.OutboundGroupItem
data class ParsedOutboundGroup(
@SerializedName("tag") val tag: String,
@SerializedName("type") val type: String,
@SerializedName("selected") val selected: String,
@SerializedName("items") val items: List<ParsedOutboundGroupItem>
) {
companion object {
fun fromOutbound(group: OutboundGroup): ParsedOutboundGroup {
val outboundItems = group.items
val items = mutableListOf<ParsedOutboundGroupItem>()
while (outboundItems.hasNext()) {
items.add(ParsedOutboundGroupItem(outboundItems.next()))
}
return ParsedOutboundGroup(group.tag, group.type, group.selected, items)
}
}
}
data class ParsedOutboundGroupItem(
@SerializedName("tag") val tag: String,
@SerializedName("type") val type: String,
@SerializedName("url-test-delay") val urlTestDelay: Int,
) {
constructor(item: OutboundGroupItem) : this(item.tag, item.type, item.urlTestDelay)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<bitmap android:gravity="fill" android:src="@drawable/background"/>
</item>
<item>
<bitmap android:gravity="center" android:src="@drawable/splash"/>
</item>
</layer-list>
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<!-- 背景色 -->
<!-- 启动图片 -->
<item>
<bitmap
android:gravity="center"
android:src="@drawable/splash"
android:width="50dp"
android:height="50dp" />
</item>
</layer-list>
Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

+53
View File
@@ -0,0 +1,53 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="640dp"
android:height="360dp"
android:viewportWidth="640"
android:viewportHeight="360">
<group android:scaleX="0.6666667"
android:scaleY="0.6666667"
android:translateX="110"
android:translateY="65">
<group>
<clip-path
android:pathData="M60,115.28h114.5v113.28h-114.5z"/>
<path
android:pathData="M144.37,140.82C144.37,139.77 144.92,138.8 145.83,138.26L169.93,123.95C171.94,122.76 174.49,124.19 174.49,126.51V151.05C174.49,152.7 173.15,154.03 171.48,154.03H147.38C145.71,154.03 144.37,152.7 144.37,151.05V140.82Z"
android:fillColor="#455FE9"/>
<path
android:pathData="M102.18,164.67C102.18,163.62 102.74,162.65 103.64,162.11L127.75,147.8C129.76,146.61 132.31,148.04 132.31,150.36V192.79V195.77V222.6C132.31,224.25 130.96,225.58 129.3,225.58H105.2C103.53,225.58 102.18,224.25 102.18,222.6V195.77V192.79V164.67ZM61.46,188.94C60.56,189.48 60,190.45 60,191.5V222.6C60,224.25 61.35,225.58 63.01,225.58H87.12C88.78,225.58 90.13,224.25 90.13,222.6V177.19C90.13,174.87 87.58,173.44 85.57,174.63L61.46,188.94Z"
android:fillColor="#455FE9"
android:fillType="evenOdd"/>
<path
android:pathData="M90.13,195.77H72.05V219.62H90.13V213.66C90.13,212.01 91.48,210.68 93.14,210.68H99.17C100.83,210.68 102.18,212.01 102.18,213.66V219.62H126.29V195.77H102.18V201.73C102.18,203.38 100.83,204.71 99.17,204.71H93.14C91.48,204.71 90.13,203.38 90.13,201.73V195.77Z"
android:fillColor="#455FE9"
android:fillType="evenOdd"/>
<path
android:pathData="M144.37,162.98V222.6C144.37,224.25 145.71,225.58 147.38,225.58H171.48C173.15,225.58 174.49,224.25 174.49,222.6V162.98C174.49,161.33 173.15,160 171.48,160H147.38C145.71,160 144.37,161.33 144.37,162.98Z"
android:fillColor="#455FE9"/>
</group>
<path
android:pathData="M536.46,237.06C531.61,237.06 528.04,235.92 525.75,233.66C523.5,231.43 522.38,227.88 522.38,223.01C522.38,221.42 523.18,220.63 524.78,220.63C526.38,220.63 527.18,221.42 527.18,223.01C527.18,226.56 527.85,228.99 529.19,230.32C530.56,231.64 532.98,232.3 536.46,232.3H555.46C558.97,232.3 561.4,231.64 562.73,230.32C564.07,228.99 564.74,226.56 564.74,223.01V207.04C562.64,209.68 559.89,211.59 556.49,212.76C553.14,213.93 549.13,214.51 544.47,214.51H542.07C535.2,214.51 530.2,212.97 527.07,209.87C523.94,206.77 522.38,201.86 522.38,195.14V164.44C522.38,162.86 523.18,162.07 524.78,162.07C526.38,162.07 527.18,162.86 527.18,164.44V195.14C527.18,200.58 528.27,204.38 530.45,206.53C532.66,208.68 536.53,209.76 542.07,209.76H544.47C551.3,209.76 556.38,208.55 559.7,206.13C563.06,203.68 564.74,200.01 564.74,195.14V164.44C564.74,162.86 565.54,162.07 567.14,162.07C568.75,162.07 569.55,162.86 569.55,164.44V223.01C569.55,227.88 568.42,231.43 566.17,233.66C563.92,235.92 560.35,237.06 555.46,237.06H536.46Z"
android:fillColor="#495057"/>
<path
android:pathData="M491.05,214.4C489.45,214.4 488.64,213.61 488.64,212.02V166.94H483.61C482,166.94 481.2,166.15 481.2,164.56C481.2,162.97 482,162.18 483.61,162.18H488.64V155.72C488.64,148.93 490.21,143.98 493.34,140.88C496.47,137.79 501.47,136.24 508.34,136.24C510.02,136.24 510.86,137.03 510.86,138.62C510.86,140.2 510.02,141 508.34,141C502.84,141 498.99,142.09 496.77,144.28C494.56,146.47 493.45,150.29 493.45,155.72V162.18H508.45C510.05,162.18 510.86,162.97 510.86,164.56C510.86,166.15 510.05,166.94 508.45,166.94H493.45V212.02C493.45,213.61 492.65,214.4 491.05,214.4Z"
android:fillColor="#495057"/>
<path
android:pathData="M463.89,151.53C461.6,151.53 460.46,150.36 460.46,148.02V145.7C460.46,143.43 461.6,142.3 463.89,142.3H466.24C468.49,142.3 469.62,143.43 469.62,145.7V148.02C469.62,150.36 468.49,151.53 466.24,151.53H463.89ZM465.1,214.4C463.49,214.4 462.69,213.61 462.69,212.02V164.56C462.69,162.97 463.49,162.18 465.1,162.18C466.7,162.18 467.5,162.97 467.5,164.56V212.02C467.5,213.61 466.7,214.4 465.1,214.4Z"
android:fillColor="#495057"/>
<path
android:pathData="M413.71,214.4C406.73,214.4 401.63,212.84 398.43,209.7C395.26,206.53 393.68,201.49 393.68,194.58V182C393.68,175.09 395.26,170.07 398.43,166.94C401.63,163.77 406.73,162.18 413.71,162.18H438.33V138.62C438.33,137.03 439.13,136.24 440.73,136.24C442.34,136.24 443.14,137.03 443.14,138.62V212.02C443.14,213.61 442.34,214.4 440.73,214.4C439.13,214.4 438.33,213.61 438.33,212.02V205.79C436.73,208.63 434.28,210.78 431,212.25C427.76,213.68 423.45,214.4 418.06,214.4H413.71ZM413.71,209.64H418.06C424.32,209.64 429.27,208.63 432.89,206.59C436.52,204.51 438.33,201.41 438.33,197.3V166.94H413.71C408.1,166.94 404.15,168.07 401.86,170.34C399.61,172.56 398.48,176.45 398.48,182V194.58C398.48,200.13 399.61,204.04 401.86,206.3C404.15,208.53 408.1,209.64 413.71,209.64Z"
android:fillColor="#495057"/>
<path
android:pathData="M345.84,214.4C338.86,214.4 333.76,212.84 330.56,209.7C327.39,206.53 325.81,201.49 325.81,194.58V182C325.81,175.09 327.39,170.07 330.56,166.94C333.76,163.77 338.86,162.18 345.84,162.18H370.46V138.62C370.46,137.03 371.26,136.24 372.86,136.24C374.47,136.24 375.27,137.03 375.27,138.62V212.02C375.27,213.61 374.47,214.4 372.86,214.4C371.26,214.4 370.46,213.61 370.46,212.02V205.79C368.86,208.63 366.41,210.78 363.13,212.25C359.89,213.68 355.58,214.4 350.19,214.4H345.84ZM345.84,209.64H350.19C356.45,209.64 361.39,208.63 365.02,206.59C368.65,204.51 370.46,201.41 370.46,197.3V166.94H345.84C340.23,166.94 336.28,168.07 333.99,170.34C331.74,172.56 330.61,176.45 330.61,182V194.58C330.61,200.13 331.74,204.04 333.99,206.3C336.28,208.53 340.23,209.64 345.84,209.64Z"
android:fillColor="#495057"/>
<path
android:pathData="M302.77,151.53C300.48,151.53 299.34,150.36 299.34,148.02V145.7C299.34,143.43 300.48,142.3 302.77,142.3H305.12C307.37,142.3 308.5,143.43 308.5,145.7V148.02C308.5,150.36 307.37,151.53 305.12,151.53H302.77ZM303.97,214.4C302.37,214.4 301.57,213.61 301.57,212.02V164.56C301.57,162.97 302.37,162.18 303.97,162.18C305.58,162.18 306.38,162.97 306.38,164.56V212.02C306.38,213.61 305.58,214.4 303.97,214.4Z"
android:fillColor="#495057"/>
<path
android:pathData="M225.37,214.4C224.57,214.4 223.96,214.21 223.54,213.84C223.16,213.42 222.97,212.82 222.97,212.02V145.07C222.97,144.28 223.16,143.7 223.54,143.32C223.96,142.9 224.57,142.7 225.37,142.7C226.17,142.7 226.76,142.9 227.15,143.32C227.57,143.7 227.77,144.28 227.77,145.07V175.66H276.21V145.07C276.21,144.28 276.4,143.7 276.78,143.32C277.2,142.9 277.81,142.7 278.61,142.7C279.41,142.7 280,142.9 280.39,143.32C280.8,143.7 281.02,144.28 281.02,145.07V212.02C281.02,212.82 280.8,213.42 280.39,213.84C280,214.21 279.41,214.4 278.61,214.4C277.81,214.4 277.2,214.21 276.78,213.84C276.4,213.42 276.21,212.82 276.21,212.02V180.42H227.77V212.02C227.77,212.82 227.57,213.42 227.15,213.84C226.76,214.21 226.17,214.4 225.37,214.4Z"
android:fillColor="#495057"/>
</group>
</vector>
@@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="2048dp"
android:height="2048dp"
android:viewportWidth="2048"
android:viewportHeight="2048">
<group>
<!-- Filled rectangle -->
<path
android:pathData="M0,0 L2048,0 L2048,2048 L0,2048 Z"
android:fillColor="#FFF0F3FA" />
</group>
</vector>
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<bitmap android:gravity="fill" android:src="@drawable/background"/>
</item>
<item>
<bitmap
android:gravity="center"
android:src="@drawable/splash"
android:width="50dp"
android:height="50dp" />
</item>
</layer-list>
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_banner_background"/>
<foreground android:drawable="@drawable/ic_banner_foreground"/>
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1008 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 794 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 636 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

+21
View File
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
<item name="android:windowSplashScreenBackground">#F0F3FA</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/android12splash</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
+22
View File
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
+21
View File
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
<item name="android:windowSplashScreenBackground">#ffffff</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/android12splash</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
</resources>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_banner_background">#F0F3FA</color>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
</resources>
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="stop">Stop</string>
<string name="quick_toggle">Toggle</string>
<string name="status_starting">Service starting…</string>
<string name="status_started">Service started</string>
</resources>
+22
View File
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<shortcuts xmlns:android="http://schemas.android.com/apk/res/android">
<shortcut
android:icon="@mipmap/ic_launcher"
android:shortcutId="toggle"
android:shortcutLongLabel="@string/quick_toggle"
android:shortcutShortLabel="@string/quick_toggle">
<intent
android:action="android.intent.action.MAIN"
android:targetClass="com.hiddify.hiddify.ShortcutActivity"
android:targetPackage="app.myhiddify.com" />
</shortcut>
</shortcuts>
+7
View File
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
+32
View File
@@ -0,0 +1,32 @@
allprojects {
repositories {
google()
mavenCentral()
maven { url "https://jitpack.io" }
}
}
rootProject.buildDir = '../build'
subprojects {
project.buildDir = "${rootProject.buildDir}/${project.name}"
}
subprojects {
project.evaluationDependsOn(':app')
}
tasks.register("clean", Delete) {
delete rootProject.buildDir
}
buildscript {
ext.kotlin_version = '2.1.0'
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.6.0'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
}
}
+4
View File
@@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx4048m -Dfile.encoding=UTF-8
android.useAndroidX=true
android.enableJetifier=true
org.gradle.java.home=/Users/mac/Library/Java/JavaVirtualMachines/ms-17.0.16/Contents/Home
+8
View File
@@ -0,0 +1,8 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
# distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.1-bin.zip
#mirrors.cloud.tencent.com/gradle/gradle-8.10-all.zip
#services.gradle.org/distributions/gradle-8.3-all.zip
distributionUrl=https\://mirrors.cloud.tencent.com/gradle/gradle-8.10-all.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
+25
View File
@@ -0,0 +1,25 @@
pluginManagement {
def flutterSdkPath = {
def properties = new Properties()
file("local.properties").withInputStream { properties.load(it) }
def flutterSdkPath = properties.getProperty("flutter.sdk")
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
return flutterSdkPath
}()
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version "8.6.0" apply false
id "org.jetbrains.kotlin.android" version "2.1.0" apply false
}
include ":app"