update ios project
update ios project
@@ -0,0 +1,13 @@
|
||||
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
|
||||
@@ -0,0 +1,107 @@
|
||||
def localProperties = new Properties()
|
||||
def localPropertiesFile = rootProject.file('local.properties')
|
||||
if (localPropertiesFile.exists()) {
|
||||
localPropertiesFile.withReader('UTF-8') { reader ->
|
||||
localProperties.load(reader)
|
||||
}
|
||||
}
|
||||
|
||||
def flutterRoot = localProperties.getProperty('flutter.sdk')
|
||||
if (flutterRoot == null) {
|
||||
throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.")
|
||||
}
|
||||
|
||||
def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
|
||||
if (flutterVersionCode == null) {
|
||||
flutterVersionCode = '1'
|
||||
}
|
||||
|
||||
def flutterVersionName = localProperties.getProperty('flutter.versionName')
|
||||
if (flutterVersionName == null) {
|
||||
flutterVersionName = '1.0'
|
||||
}
|
||||
|
||||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'kotlin-android'
|
||||
apply plugin: 'kotlin-parcelize'
|
||||
apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"
|
||||
|
||||
android {
|
||||
compileSdkVersion 33
|
||||
ndkVersion flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = '1.8'
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
main.java.srcDirs += 'src/main/kotlin'
|
||||
main.jniLibs.srcDirs = ['libs', 'src/main/jniLibs']
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
applicationId "com.sail_tunnel.sail"
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-build-configuration.
|
||||
minSdkVersion 26
|
||||
//noinspection ExpiredTargetSdkVersion
|
||||
targetSdkVersion 28
|
||||
versionCode flutterVersionCode.toInteger()
|
||||
versionName flutterVersionName
|
||||
// ndk {
|
||||
// abiFilters 'armeabi', 'armeabi-v7a', 'arm64-v8a', 'x86', "x86_64"
|
||||
// }
|
||||
// dexOptions {
|
||||
// incremental true
|
||||
// javaMaxHeapSize "4g"
|
||||
// preDexLibraries true
|
||||
// dexInProcess = true
|
||||
// }
|
||||
ndk {
|
||||
if (!project.hasProperty('target-platform')) {
|
||||
abiFilters 'arm64-v8a', 'armeabi-v7a', 'x86_64'
|
||||
} else {
|
||||
def platforms = project.property('target-platform').split(',')
|
||||
def platformMap = [
|
||||
'android-arm' : 'armeabi-v7a',
|
||||
'android-arm64': 'arm64-v8a',
|
||||
'android-x86' : 'x86',
|
||||
'android-x64' : 'x86_64',
|
||||
]
|
||||
abiFilters = platforms.stream().map({ e ->
|
||||
platformMap.containsKey(e) ? platformMap[e] : e
|
||||
}).toArray()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig signingConfigs.debug
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
source '../..'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
|
||||
implementation "com.jakewharton.timber:timber:5.0.1"
|
||||
implementation 'androidx.work:work-runtime-ktx:2.7.1'
|
||||
implementation 'androidx.preference:preference-ktx:1.2.0'
|
||||
implementation "dnsjava:dnsjava:3.5.1"
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.6.4"
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4"
|
||||
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1"
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"version": 3,
|
||||
"artifactType": {
|
||||
"type": "APK",
|
||||
"kind": "Directory"
|
||||
},
|
||||
"applicationId": "com.sail_tunnel.sail",
|
||||
"variantName": "profile",
|
||||
"elements": [
|
||||
{
|
||||
"type": "SINGLE",
|
||||
"filters": [],
|
||||
"attributes": [],
|
||||
"versionCode": 3,
|
||||
"versionName": "1.0.2",
|
||||
"outputFile": "app-profile.apk"
|
||||
}
|
||||
],
|
||||
"elementType": "File"
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="com.sail_tunnel.sail">
|
||||
<!-- 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.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE"/>
|
||||
</manifest>
|
||||
@@ -0,0 +1,87 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="com.sail_tunnel.sail">
|
||||
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE"/>
|
||||
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
|
||||
<uses-permission android:name="android.permission.READ_PHONE_NUMBERS" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE"></uses-permission>
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK"></uses-permission>
|
||||
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
|
||||
<uses-permission android:name="android.permission.SYSTEM_OVERLAY_WINDOW"/>
|
||||
|
||||
|
||||
<uses-feature
|
||||
android:name="android.software.leanback"
|
||||
android:required="false" />
|
||||
<uses-feature
|
||||
android:name="android.hardware.touchscreen"
|
||||
android:required="false" />
|
||||
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission
|
||||
android:name="android.permission.QUERY_ALL_PACKAGES"
|
||||
/>
|
||||
|
||||
<application
|
||||
android:label="UUVPN"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/app_icon"
|
||||
android:largeHeap="true"
|
||||
android:supportsRtl="true"
|
||||
android:allowBackup="true"
|
||||
android:hardwareAccelerated="false">
|
||||
<!-- android:icon="@mipmap/ic_launcher"-->
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="false"
|
||||
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>
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name=".TunnelService"
|
||||
android:enabled="true"
|
||||
|
||||
android:permission="android.permission.BIND_VPN_SERVICE"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.net.VpnService"/>
|
||||
</intent-filter>
|
||||
</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,54 @@
|
||||
package com.sail_tunnel.sail
|
||||
|
||||
import android.app.*
|
||||
import android.content.*
|
||||
import android.net.ConnectivityManager
|
||||
import android.util.Log
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.core.content.getSystemService
|
||||
import kotlinx.coroutines.DEBUG_PROPERTY_NAME
|
||||
import kotlinx.coroutines.DEBUG_PROPERTY_VALUE_ON
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import kotlin.reflect.KClass
|
||||
import androidx.work.Configuration
|
||||
import com.sail_tunnel.sail.utils.DeviceStorageApp
|
||||
|
||||
object Core : Configuration.Provider {
|
||||
lateinit var app: MainActivity
|
||||
@VisibleForTesting set
|
||||
lateinit var configureIntent: (Context) -> PendingIntent
|
||||
val activity by lazy { app.getSystemService<ActivityManager>()!! }
|
||||
val connectivity by lazy { app.getSystemService<ConnectivityManager>()!! }
|
||||
|
||||
val deviceStorage by lazy { DeviceStorageApp(app) }
|
||||
|
||||
fun init(app: MainActivity, configureClass: KClass<out Any>) {
|
||||
this.app = app
|
||||
this.configureIntent = {
|
||||
PendingIntent.getActivity(it, 0, Intent(it, configureClass.java)
|
||||
.setFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT), PendingIntent.FLAG_IMMUTABLE)
|
||||
}
|
||||
|
||||
// overhead of debug mode is minimal: https://github.com/Kotlin/kotlinx.coroutines/blob/f528898/docs/debugging.md#debug-mode
|
||||
System.setProperty(DEBUG_PROPERTY_NAME, DEBUG_PROPERTY_VALUE_ON)
|
||||
|
||||
Timber.plant(object : Timber.DebugTree() {
|
||||
override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
|
||||
if (t == null) {
|
||||
if (priority != Log.DEBUG || BuildConfig.DEBUG) Log.println(priority, tag, message)
|
||||
} else {
|
||||
if (priority >= Log.WARN || priority == Log.DEBUG) Log.println(priority, tag, message)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override fun getWorkManagerConfiguration() = Configuration.Builder().apply {
|
||||
setDefaultProcessName(app.packageName + ":bg")
|
||||
setMinimumLoggingLevel(if (BuildConfig.DEBUG) Log.VERBOSE else Log.INFO)
|
||||
setExecutor { GlobalScope.launch { it.run() } }
|
||||
setTaskExecutor { GlobalScope.launch { it.run() } }
|
||||
}.build()
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package com.sail_tunnel.sail
|
||||
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.VpnService
|
||||
import android.os.BatteryManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import com.sail_tunnel.sail.services.VpnState
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.android.FlutterView
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.embedding.engine.plugins.util.GeneratedPluginRegister
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
import io.flutter.plugins.GeneratedPluginRegistrant
|
||||
import java.io.File
|
||||
|
||||
class MainActivity: FlutterActivity() {
|
||||
private val channel = "com.vpntunnel.vpnzeus/vpn_manager"
|
||||
|
||||
private fun getServiceIntent(): Intent {
|
||||
return Intent(this, TunnelService::class.java)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
Core.init(this, MainActivity::class)
|
||||
|
||||
// GeneratedPluginRegistrant.registerWith(FlutterEngine(this))
|
||||
// GeneratedPluginRegister.registerGeneratedPlugins(FlutterEngine(this));
|
||||
|
||||
// MethodChannel(FlutterView(this), channel).setMethodCallHandler { call, result ->
|
||||
// if (call.method == "getBatteryLevel") {
|
||||
// val batteryLevel : Int = getBatteryLevel()
|
||||
//
|
||||
// if (batteryLevel != -1) {
|
||||
// result.success(batteryLevel)
|
||||
// } else {
|
||||
// result.error("UNAVAILABLE", "Battery level not available.", null)
|
||||
// }
|
||||
// } else {
|
||||
// Log.e("user_debug", "getBatteryLevel is not method of called")
|
||||
// result.notImplemented()
|
||||
// }
|
||||
// }
|
||||
|
||||
}
|
||||
private fun getBatteryLevel(): Int {
|
||||
val batteryLevel: Int
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP){
|
||||
val batteryManager = getSystemService(Context.BATTERY_SERVICE) as BatteryManager
|
||||
batteryLevel = batteryManager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
|
||||
} else {
|
||||
val intent = ContextWrapper(applicationContext).registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
|
||||
batteryLevel = intent!!.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) * 100 / intent.getIntExtra(BatteryManager.EXTRA_SCALE, -1)
|
||||
}
|
||||
return batteryLevel
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
|
||||
}
|
||||
|
||||
private fun getVPNConnectionStatus(): Boolean? {
|
||||
val context = this@MainActivity
|
||||
val connectivityManager =
|
||||
context.getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
val networks = connectivityManager.allNetworks
|
||||
//val TAG = "getVPNConnectionStatus"
|
||||
//Log.i(TAG, "Network count: " + networks.size);
|
||||
var hasvpn = false
|
||||
for (i in 0 until networks.size) {
|
||||
// connectivityManager.getNetworkCapabilities()
|
||||
val caps: NetworkCapabilities? = connectivityManager.getNetworkCapabilities(networks.get(i))
|
||||
|
||||
hasvpn = caps?.hasTransport(NetworkCapabilities.TRANSPORT_VPN) == true
|
||||
|
||||
// Log.i(TAG, "Network " + i + ": " + networks.get(i).toString())
|
||||
// Log.i(TAG, "VPN transport is: " + caps?.hasTransport(NetworkCapabilities.TRANSPORT_VPN))
|
||||
// Log.i(TAG, "NOT_VPN capability is: " + caps?.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) )
|
||||
}
|
||||
return hasvpn
|
||||
|
||||
|
||||
|
||||
// val activeNetwork = connectivityManager.activeNetwork
|
||||
// val networkCapabilities = connectivityManager.getNetworkCapabilities(activeNetwork)
|
||||
//
|
||||
// return networkCapabilities?.hasTransport(NetworkCapabilities.TRANSPORT_VPN)
|
||||
}
|
||||
|
||||
override fun configureFlutterEngine( flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channel).setMethodCallHandler {
|
||||
call, result ->
|
||||
// This method is invoked on the main thread.
|
||||
if (call.method == "getStatus") {
|
||||
result.success(getVPNConnectionStatus())
|
||||
} else if (call.method == "toggle") {
|
||||
val intent = VpnService.prepare(this)
|
||||
if (intent != null) {
|
||||
startActivityForResult(intent, 0)
|
||||
result.success(false)
|
||||
} else {
|
||||
startService(getServiceIntent())
|
||||
result.success(true)
|
||||
}
|
||||
} else if (call.method == "getTunnelLog") {
|
||||
// val context = Core.deviceStorage
|
||||
// val configRoot = context.noBackupFilesDir
|
||||
// val config = File(configRoot, VpnState.LEAF_LOG_FILE).readText()
|
||||
// result.success(config)
|
||||
} else if (call.method == "getTunnelConfiguration") {
|
||||
val context = Core.deviceStorage
|
||||
val configRoot = context.noBackupFilesDir
|
||||
val config = File(configRoot, VpnState.CONFIG_FILE).readText()
|
||||
val TAGs = "getVPNConnectionStatus"
|
||||
Log.i(TAGs, config);
|
||||
result.success(config)
|
||||
} else if (call.method == "setTunnelConfiguration") {
|
||||
try {
|
||||
val context = Core.deviceStorage
|
||||
val configRoot = context.noBackupFilesDir
|
||||
val TAGs = "setTunnelConfiguration"
|
||||
Log.i(TAGs, call.arguments.toString());
|
||||
File(configRoot, VpnState.CONFIG_FILE).writeText(call.arguments.toString())
|
||||
}catch (e: Exception ){
|
||||
println("setTunnelConfiguration FILE NullPointerException")
|
||||
}
|
||||
|
||||
|
||||
} else if (call.method == "update") {
|
||||
//
|
||||
} else {
|
||||
result.notImplemented()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActivityResult(request: Int, result: Int, data: Intent?) {
|
||||
if (result == RESULT_OK) {
|
||||
startService(getServiceIntent())
|
||||
} else {
|
||||
super.onActivityResult(request, result, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
package com.sail_tunnel.sail
|
||||
|
||||
import android.app.Notification
|
||||
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.graphics.BitmapFactory
|
||||
import android.media.projection.MediaProjection
|
||||
import android.media.projection.MediaProjectionManager
|
||||
import android.net.*
|
||||
import android.os.Build
|
||||
import android.os.ParcelFileDescriptor
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.sail_tunnel.sail.net.DefaultNetworkListener
|
||||
import com.sail_tunnel.sail.net.DnsResolverCompat
|
||||
import com.sail_tunnel.sail.services.LocalDnsWorker
|
||||
import com.sail_tunnel.sail.services.TunnelInstance
|
||||
import com.sail_tunnel.sail.services.VpnState
|
||||
import com.sail_tunnel.sail.utils.readableMessage
|
||||
import kotlinx.coroutines.*
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
|
||||
class TunnelService : VpnService() {
|
||||
companion object {
|
||||
private const val CORE_NAME = "leaf"
|
||||
private const val VPN_MTU = 1500
|
||||
private const val PRIVATE_VLAN4_CLIENT = "198.18.20.20"
|
||||
private const val PRIVATE_VLAN4_ROUTER = "1.1.1.1"
|
||||
}
|
||||
|
||||
inner class NullConnectionException : NullPointerException() {
|
||||
override fun getLocalizedMessage() =
|
||||
"Failed to start VPN service. You might need to reboot your device."
|
||||
}
|
||||
|
||||
private val data = VpnState.Data()
|
||||
|
||||
private fun startRunner() {
|
||||
|
||||
if (Build.VERSION.SDK_INT >= 26) startForegroundService(Intent(this, javaClass))
|
||||
else startService(Intent(this, javaClass))
|
||||
}
|
||||
|
||||
private var pfd: ParcelFileDescriptor? = null
|
||||
private var active = false
|
||||
private var metered = false
|
||||
|
||||
|
||||
var notification: Notification? = null
|
||||
private val mResultData: Intent? = null
|
||||
private val mMediaProjection: MediaProjection? = null
|
||||
private val mMediaProjectionManager: MediaProjectionManager? = null
|
||||
|
||||
|
||||
@Volatile
|
||||
private var underlyingNetwork: Network? = null
|
||||
set(value) {
|
||||
field = value
|
||||
if (active) setUnderlyingNetworks(underlyingNetworks)
|
||||
}
|
||||
private val underlyingNetworks
|
||||
get() =
|
||||
// clearing underlyingNetworks makes Android 9 consider the network to be metered
|
||||
if (Build.VERSION.SDK_INT == 28 && metered) null else underlyingNetwork?.let {
|
||||
arrayOf(
|
||||
it
|
||||
)
|
||||
}
|
||||
|
||||
override fun onRevoke() = stopRunner()
|
||||
|
||||
private fun killProcesses(scope: CoroutineScope) {
|
||||
data.proxy?.run {
|
||||
shutdown(scope)
|
||||
data.proxy = null
|
||||
}
|
||||
data.localDns?.shutdown(scope)
|
||||
data.localDns = null
|
||||
|
||||
active = false
|
||||
scope.launch { DefaultNetworkListener.stop(this) }
|
||||
pfd?.close()
|
||||
pfd = null
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
val data = data
|
||||
if (data.state != VpnState.State.Stopped) return Service.START_NOT_STICKY
|
||||
|
||||
try {
|
||||
data.proxy = TunnelInstance()
|
||||
} catch (e: IllegalArgumentException) {
|
||||
stopRunner(false, e.message)
|
||||
return Service.START_NOT_STICKY
|
||||
}
|
||||
createNotificationChannel()
|
||||
data.changeState(VpnState.State.Connecting)
|
||||
data.connectingJob = GlobalScope.launch(Dispatchers.Main) {
|
||||
try {
|
||||
preInit()
|
||||
|
||||
startVpn()
|
||||
|
||||
data.changeState(VpnState.State.Connected)
|
||||
} catch (_: CancellationException) {
|
||||
// if the job was cancelled, it is canceller's responsibility to call stopRunner
|
||||
} catch (exc: Throwable) {
|
||||
stopRunner(false, exc.readableMessage)
|
||||
} finally {
|
||||
data.connectingJob = null
|
||||
}
|
||||
}
|
||||
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
val CHANNEL_ID: String = "notification_id"
|
||||
val builder = NotificationCompat.Builder(this.applicationContext,CHANNEL_ID) //获取一个Notification构造器
|
||||
val nfIntent = Intent(this, MainActivity::class.java) //点击后跳转的界面,可以设置跳转数据
|
||||
val pendingIntent: PendingIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
PendingIntent.getActivity(this, 0, nfIntent, PendingIntent.FLAG_IMMUTABLE)
|
||||
} else {
|
||||
PendingIntent.getActivity(this, 0, nfIntent, PendingIntent.FLAG_ONE_SHOT)
|
||||
}
|
||||
|
||||
val intentClick = Intent(
|
||||
this,MainActivity::class.java
|
||||
// BroadcastReceiver::class.java
|
||||
)
|
||||
|
||||
|
||||
intentClick.action = "notification_clicked"
|
||||
// intentClick.putExtra(BroadcastReceiver.TYPE, type)
|
||||
val pendingIntentClick =
|
||||
PendingIntent.getBroadcast(this, 0, intentClick, PendingIntent.FLAG_ONE_SHOT)
|
||||
|
||||
|
||||
//.setContentIntent(pendingIntent) // 设置PendingIntent
|
||||
builder.setContentIntent(pendingIntentClick).setLargeIcon(
|
||||
BitmapFactory.decodeResource(
|
||||
this.resources,
|
||||
R.mipmap.app_icon_round
|
||||
)
|
||||
) // 设置下拉列表中的图标(大图标)
|
||||
.setContentTitle("UUVPN") // 设置下拉列表里的标题
|
||||
.setSmallIcon( R.mipmap.app_icon_round) // 设置状态栏内的小图标
|
||||
.setContentText("正在运行...") // 设置上下文内容
|
||||
.setWhen(System.currentTimeMillis()) // 设置该通知发生的时间
|
||||
|
||||
/*以下是对Android 8.0的适配*/
|
||||
//普通notification适配
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
builder.setChannelId("notification_id")
|
||||
}
|
||||
|
||||
|
||||
//前台服务notification适配
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
|
||||
val channel = NotificationChannel(
|
||||
"notification_id",
|
||||
"notification_name",
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
)
|
||||
|
||||
|
||||
// Register the channel with the system
|
||||
val notificationManager: NotificationManager =
|
||||
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
|
||||
}
|
||||
|
||||
notification = builder.build() // 获取构建好的Notification
|
||||
// notification.defaulte = Notification.DEFAULT_SOUND //设置为默认的声音
|
||||
try {
|
||||
startForeground(110, notification)
|
||||
} catch (w: Exception) {
|
||||
w.printStackTrace()
|
||||
}
|
||||
}
|
||||
private suspend fun preInit() = DefaultNetworkListener.start(this) { underlyingNetwork = it }
|
||||
private suspend fun rawResolver(query: ByteArray) =
|
||||
// no need to listen for network here as this is only used for forwarding local DNS queries.
|
||||
// retries should be attempted by client.
|
||||
DnsResolverCompat.resolveRaw(underlyingNetwork ?: throw IOException("no network"), query)
|
||||
|
||||
private suspend fun startVpn() {
|
||||
val builder = Builder()
|
||||
.setConfigureIntent(Core.configureIntent(this))
|
||||
.setSession(CORE_NAME)
|
||||
.setMtu(VPN_MTU)
|
||||
.addAddress(PRIVATE_VLAN4_CLIENT, 24)
|
||||
.addDnsServer(PRIVATE_VLAN4_ROUTER)
|
||||
.addRoute("0.0.0.0", 0)
|
||||
|
||||
active = true // possible race condition here?
|
||||
builder.setUnderlyingNetworks(underlyingNetworks)
|
||||
if (Build.VERSION.SDK_INT >= 29) builder.setMetered(metered)
|
||||
|
||||
this.pfd = builder.establish() ?: throw NullConnectionException()
|
||||
|
||||
val context = Core.deviceStorage
|
||||
val configRoot = context.noBackupFilesDir
|
||||
|
||||
data.localDns = LocalDnsWorker(this::rawResolver).apply { start() }
|
||||
|
||||
val configFile = File(configRoot, VpnState.CONFIG_FILE)
|
||||
val configContent = configFile
|
||||
.readText()
|
||||
.replace("{{leafLogFile}}", File(configRoot, VpnState.LEAF_LOG_FILE).absolutePath)
|
||||
.replace("{{tunFd}}", this.pfd?.fd?.toLong().toString())
|
||||
|
||||
configFile.writeText(configContent)
|
||||
|
||||
data.proxy!!.start(
|
||||
this,
|
||||
File(Core.deviceStorage.noBackupFilesDir, "stat_main"),
|
||||
configFile,
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
private fun stopRunner(restart: Boolean = false, msg: String? = null) {
|
||||
if (data.state == VpnState.State.Stopping) return
|
||||
// channge the state
|
||||
data.changeState(VpnState.State.Stopping)
|
||||
GlobalScope.launch(Dispatchers.Main.immediate) {
|
||||
data.connectingJob?.cancelAndJoin() // ensure stop connecting first
|
||||
// we use a coroutineScope here to allow clean-up in parallel
|
||||
coroutineScope {
|
||||
killProcesses(GlobalScope)
|
||||
}
|
||||
|
||||
// change the state
|
||||
data.changeState(VpnState.State.Stopped, msg)
|
||||
|
||||
// stop the service if nothing has bound to it
|
||||
if (restart) startRunner() else {
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.sail_tunnel.sail.net
|
||||
|
||||
import android.net.LocalSocket
|
||||
import kotlinx.coroutines.*
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
|
||||
abstract class ConcurrentLocalSocketListener(name: String, socketFile: File) : LocalSocketListener(name, socketFile),
|
||||
CoroutineScope {
|
||||
override val coroutineContext = Dispatchers.IO + SupervisorJob() + CoroutineExceptionHandler { _, t -> Timber.w(t) }
|
||||
|
||||
override fun accept(socket: LocalSocket) {
|
||||
launch { super.accept(socket) }
|
||||
}
|
||||
|
||||
override fun shutdown(scope: CoroutineScope) {
|
||||
running = false
|
||||
cancel()
|
||||
super.shutdown(scope)
|
||||
coroutineContext[Job]!!.also { job -> scope.launch { job.join() } }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
package com.sail_tunnel.sail.net
|
||||
|
||||
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 androidx.annotation.RequiresApi
|
||||
import com.sail_tunnel.sail.Core
|
||||
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) {
|
||||
Core.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)
|
||||
}.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) {
|
||||
Core.connectivity.registerBestMatchingNetworkCallback(request, Callback, mainHandler)
|
||||
}
|
||||
in 28 until 31 -> @TargetApi(28) { // we want REQUEST here instead of LISTEN
|
||||
Core.connectivity.requestNetwork(request, Callback, mainHandler)
|
||||
}
|
||||
in 26 until 28 -> @TargetApi(26) {
|
||||
Core.connectivity.registerDefaultNetworkCallback(Callback, mainHandler)
|
||||
}
|
||||
in 24 until 26 -> {
|
||||
Core.connectivity.registerDefaultNetworkCallback(Callback)
|
||||
}
|
||||
else -> try {
|
||||
fallback = false
|
||||
Core.connectivity.requestNetwork(request, Callback)
|
||||
} catch (e: RuntimeException) {
|
||||
fallback = true // known bug on API 23: https://stackoverflow.com/a/33509180/2245107
|
||||
}
|
||||
}
|
||||
}
|
||||
private fun unregister() = Core.connectivity.unregisterNetworkCallback(Callback)
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package com.sail_tunnel.sail.net
|
||||
|
||||
import android.annotation.TargetApi
|
||||
import android.net.DnsResolver
|
||||
import android.net.Network
|
||||
import android.os.Build
|
||||
import android.os.CancellationSignal
|
||||
import androidx.annotation.RequiresApi
|
||||
import com.sail_tunnel.sail.Core
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.asCoroutineDispatcher
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.xbill.DNS.*
|
||||
import java.io.IOException
|
||||
import java.net.Inet4Address
|
||||
import java.net.Inet6Address
|
||||
import java.net.InetAddress
|
||||
import java.util.concurrent.Executor
|
||||
import java.util.concurrent.Executors
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
|
||||
sealed class DnsResolverCompat {
|
||||
companion object : DnsResolverCompat() {
|
||||
private val instance by lazy {
|
||||
when (Build.VERSION.SDK_INT) {
|
||||
in 29..Int.MAX_VALUE -> DnsResolverCompat29
|
||||
in 23 until 29 -> DnsResolverCompat23
|
||||
else -> error("Unsupported API level")
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun resolve(network: Network, host: String) = instance.resolve(network, host)
|
||||
override suspend fun resolveOnActiveNetwork(host: String) = instance.resolveOnActiveNetwork(host)
|
||||
override suspend fun resolveRaw(network: Network, query: ByteArray) = instance.resolveRaw(network, query)
|
||||
override suspend fun resolveRawOnActiveNetwork(query: ByteArray) = instance.resolveRawOnActiveNetwork(query)
|
||||
|
||||
// additional platform-independent DNS helpers
|
||||
|
||||
/**
|
||||
* TTL returned from localResolver is set to 120. Android API does not provide TTL,
|
||||
* so we suppose Android apps should not care about TTL either.
|
||||
*/
|
||||
private const val TTL = 120L
|
||||
|
||||
fun prepareDnsResponse(request: Message) = Message(request.header.id).apply {
|
||||
header.setFlag(Flags.QR.toInt()) // this is a response
|
||||
header.setFlag(Flags.RA.toInt()) // recursion available
|
||||
if (request.header.getFlag(Flags.RD.toInt())) header.setFlag(Flags.RD.toInt())
|
||||
request.question?.also { addRecord(it, Section.QUESTION) }
|
||||
}
|
||||
}
|
||||
|
||||
abstract suspend fun resolve(network: Network, host: String): Array<InetAddress>
|
||||
abstract suspend fun resolveOnActiveNetwork(host: String): Array<InetAddress>
|
||||
abstract suspend fun resolveRaw(network: Network, query: ByteArray): ByteArray
|
||||
abstract suspend fun resolveRawOnActiveNetwork(query: ByteArray): ByteArray
|
||||
|
||||
private object DnsResolverCompat23 : DnsResolverCompat() {
|
||||
/**
|
||||
* This dispatcher is used for noncancellable possibly-forever-blocking operations in network IO.
|
||||
*
|
||||
* See also: https://issuetracker.google.com/issues/133874590
|
||||
*/
|
||||
private val unboundedIO by lazy {
|
||||
if (Core.activity.isLowRamDevice) Dispatchers.IO
|
||||
else Executors.newCachedThreadPool().asCoroutineDispatcher()
|
||||
}
|
||||
|
||||
override suspend fun resolve(network: Network, host: String) =
|
||||
withContext(unboundedIO) { network.getAllByName(host) }
|
||||
override suspend fun resolveOnActiveNetwork(host: String) =
|
||||
withContext(unboundedIO) { InetAddress.getAllByName(host) }
|
||||
|
||||
private suspend fun resolveRaw(query: ByteArray, networkSpecified: Boolean = true,
|
||||
hostResolver: suspend (String) -> Array<InetAddress>): ByteArray {
|
||||
val request = try {
|
||||
Message(query)
|
||||
} catch (e: IOException) {
|
||||
throw UnsupportedOperationException(e) // unrecognized packet
|
||||
}
|
||||
when (val opcode = request.header.opcode) {
|
||||
Opcode.QUERY -> { }
|
||||
else -> throw UnsupportedOperationException("Unsupported opcode $opcode")
|
||||
}
|
||||
val question = request.question
|
||||
val isIpv6 = when (val type = question?.type) {
|
||||
Type.A -> false
|
||||
Type.AAAA -> true
|
||||
Type.PTR -> {
|
||||
/* Android does not provide a PTR lookup API for Network prior to Android 10 */
|
||||
if (networkSpecified) throw IOException(UnsupportedOperationException("Network unspecified"))
|
||||
val ip = try {
|
||||
ReverseMap.fromName(question.name)
|
||||
} catch (e: IOException) {
|
||||
throw UnsupportedOperationException(e) // unrecognized PTR name
|
||||
}
|
||||
val hostname = withContext(unboundedIO) { ip.hostName }.let { hostname ->
|
||||
if (hostname == ip.hostAddress) null else Name.fromString("$hostname.")
|
||||
}
|
||||
return prepareDnsResponse(request).apply {
|
||||
hostname?.let { addRecord(PTRRecord(question.name, DClass.IN, TTL, it), Section.ANSWER) }
|
||||
}.toWire()
|
||||
}
|
||||
else -> throw UnsupportedOperationException("Unsupported query type $type")
|
||||
}
|
||||
val host = question.name.canonicalize().toString(true)
|
||||
return prepareDnsResponse(request).apply {
|
||||
for (address in hostResolver(host).asIterable().run {
|
||||
if (isIpv6) filterIsInstance<Inet6Address>() else filterIsInstance<Inet4Address>()
|
||||
}) addRecord(when (address) {
|
||||
is Inet4Address -> ARecord(question.name, DClass.IN, TTL, address)
|
||||
is Inet6Address -> AAAARecord(question.name, DClass.IN, TTL, address)
|
||||
else -> error("Unsupported address $address")
|
||||
}, Section.ANSWER)
|
||||
}.toWire()
|
||||
}
|
||||
override suspend fun resolveRaw(network: Network, query: ByteArray) =
|
||||
resolveRaw(query) { resolve(network, it) }
|
||||
override suspend fun resolveRawOnActiveNetwork(query: ByteArray) =
|
||||
resolveRaw(query, false, this::resolveOnActiveNetwork)
|
||||
}
|
||||
|
||||
@TargetApi(29)
|
||||
private object DnsResolverCompat29 : DnsResolverCompat(), Executor {
|
||||
/**
|
||||
* This executor will run on its caller directly. On Q beta 3 thru 4, this results in calling in main thread.
|
||||
*/
|
||||
override fun execute(command: Runnable) = command.run()
|
||||
|
||||
private val activeNetwork get() = Core.connectivity.activeNetwork ?: throw IOException("no network")
|
||||
|
||||
override suspend fun resolve(network: Network, host: String): Array<InetAddress> {
|
||||
return suspendCancellableCoroutine { cont ->
|
||||
val signal = CancellationSignal()
|
||||
cont.invokeOnCancellation { signal.cancel() }
|
||||
// retry should be handled by client instead
|
||||
DnsResolver.getInstance().query(network, host, DnsResolver.FLAG_NO_RETRY, this,
|
||||
signal, object : DnsResolver.Callback<Collection<InetAddress>> {
|
||||
override fun onAnswer(answer: Collection<InetAddress>, rcode: Int) =
|
||||
cont.resume(answer.toTypedArray())
|
||||
override fun onError(error: DnsResolver.DnsException) = cont.resumeWithException(
|
||||
IOException(error)
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
override suspend fun resolveOnActiveNetwork(host: String) = resolve(activeNetwork, host)
|
||||
|
||||
override suspend fun resolveRaw(network: Network, query: ByteArray): ByteArray {
|
||||
return suspendCancellableCoroutine { cont ->
|
||||
val signal = CancellationSignal()
|
||||
cont.invokeOnCancellation { signal.cancel() }
|
||||
DnsResolver.getInstance().rawQuery(network, query, DnsResolver.FLAG_NO_RETRY, this,
|
||||
signal, object : DnsResolver.Callback<ByteArray> {
|
||||
override fun onAnswer(answer: ByteArray, rcode: Int) = cont.resume(answer)
|
||||
override fun onError(error: DnsResolver.DnsException) = cont.resumeWithException(
|
||||
IOException(error)
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
override suspend fun resolveRawOnActiveNetwork(query: ByteArray) = resolveRaw(activeNetwork, query)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.sail_tunnel.sail.net
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.net.LocalServerSocket
|
||||
import android.net.LocalSocket
|
||||
import android.net.LocalSocketAddress
|
||||
import android.system.ErrnoException
|
||||
import android.system.Os
|
||||
import android.system.OsConstants
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.channels.onFailure
|
||||
import kotlinx.coroutines.channels.trySendBlocking
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
|
||||
abstract class LocalSocketListener(name: String, socketFile: File) : Thread(name) {
|
||||
private val localSocket = LocalSocket().apply {
|
||||
socketFile.delete() // It's a must-have to close and reuse previous local socket.
|
||||
bind(LocalSocketAddress(socketFile.absolutePath, LocalSocketAddress.Namespace.FILESYSTEM))
|
||||
}
|
||||
private val serverSocket = LocalServerSocket(localSocket.fileDescriptor)
|
||||
private val closeChannel = Channel<Unit>(1)
|
||||
@Volatile
|
||||
protected var running = true
|
||||
|
||||
/**
|
||||
* Inherited class do not need to close input/output streams as they will be closed automatically.
|
||||
*/
|
||||
protected open fun accept(socket: LocalSocket) = socket.use { acceptInternal(socket) }
|
||||
protected abstract fun acceptInternal(socket: LocalSocket)
|
||||
final override fun run() {
|
||||
localSocket.use {
|
||||
while (running) {
|
||||
try {
|
||||
accept(serverSocket.accept())
|
||||
} catch (e: IOException) {
|
||||
if (running) Timber.w(e)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
closeChannel.trySendBlocking(Unit).onFailure { throw it!! }
|
||||
}
|
||||
|
||||
@SuppressLint("NewApi")
|
||||
open fun shutdown(scope: CoroutineScope) {
|
||||
running = false
|
||||
localSocket.fileDescriptor?.apply {
|
||||
// see also: https://issuetracker.google.com/issues/36945762#comment15
|
||||
if (valid()) try {
|
||||
Os.shutdown(this, OsConstants.SHUT_RDWR)
|
||||
} catch (e: ErrnoException) {
|
||||
// suppress fd inactive or already closed
|
||||
if (e.errno != OsConstants.EBADF && e.errno != OsConstants.ENOTCONN) throw e.rethrowAsSocketException()
|
||||
}
|
||||
}
|
||||
scope.launch { closeChannel.receive() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.sail_tunnel.sail.services
|
||||
|
||||
import android.net.LocalSocket
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresApi
|
||||
import com.sail_tunnel.sail.Core
|
||||
import com.sail_tunnel.sail.net.ConcurrentLocalSocketListener
|
||||
import com.sail_tunnel.sail.net.DnsResolverCompat
|
||||
import com.sail_tunnel.sail.utils.readableMessage
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import java.io.DataInputStream
|
||||
import java.io.DataOutputStream
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import org.xbill.DNS.Message
|
||||
import org.xbill.DNS.Rcode
|
||||
|
||||
class LocalDnsWorker(private val resolver: suspend (ByteArray) -> ByteArray) : ConcurrentLocalSocketListener(
|
||||
"LocalDnsThread", File(Core.deviceStorage.noBackupFilesDir, "local_dns_path")
|
||||
), CoroutineScope {
|
||||
override fun acceptInternal(socket: LocalSocket) = error("big no no")
|
||||
override fun accept(socket: LocalSocket) {
|
||||
launch {
|
||||
socket.use {
|
||||
val input = DataInputStream(socket.inputStream)
|
||||
val query = try {
|
||||
ByteArray(input.readUnsignedShort()).also { input.read(it) }
|
||||
} catch (e: IOException) { // connection early close possibly due to resolving timeout
|
||||
return@use Timber.d(e)
|
||||
}
|
||||
try {
|
||||
resolver(query)
|
||||
} catch (e: Exception) {
|
||||
when (e) {
|
||||
is TimeoutCancellationException -> Timber.w("Resolving timed out")
|
||||
is CancellationException -> { } // ignore
|
||||
is IOException -> Timber.d(e)
|
||||
is UnsupportedOperationException -> Timber.w(e.message)
|
||||
else -> Timber.w(e)
|
||||
}
|
||||
try {
|
||||
DnsResolverCompat.prepareDnsResponse(Message(query)).apply {
|
||||
header.rcode = Rcode.SERVFAIL
|
||||
}.toWire()
|
||||
} catch (_: IOException) {
|
||||
byteArrayOf() // return empty if cannot parse packet
|
||||
}
|
||||
}.let { response ->
|
||||
try {
|
||||
val output = DataOutputStream(socket.outputStream)
|
||||
output.writeShort(response.size)
|
||||
output.write(response)
|
||||
} catch (e: IOException) {
|
||||
Timber.d(e.readableMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.sail_tunnel.sail.services
|
||||
|
||||
import android.net.LocalSocket
|
||||
import android.os.SystemClock
|
||||
import com.sail_tunnel.sail.net.LocalSocketListener
|
||||
import com.sail_tunnel.sail.utils.TrafficStats
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
|
||||
class TrafficMonitor(statFile: File) {
|
||||
val thread = object : LocalSocketListener("TrafficMonitor-" + statFile.name, statFile) {
|
||||
private val buffer = ByteArray(16)
|
||||
private val stat = ByteBuffer.wrap(buffer).order(ByteOrder.LITTLE_ENDIAN)
|
||||
override fun acceptInternal(socket: LocalSocket) {
|
||||
when (val read = socket.inputStream.read(buffer)) {
|
||||
-1 -> return
|
||||
16 -> { }
|
||||
else -> throw IOException("Unexpected traffic stat length $read")
|
||||
}
|
||||
val tx = stat.getLong(0)
|
||||
val rx = stat.getLong(8)
|
||||
if (current.txTotal != tx) {
|
||||
current.txTotal = tx
|
||||
dirty = true
|
||||
}
|
||||
if (current.rxTotal != rx) {
|
||||
current.rxTotal = rx
|
||||
dirty = true
|
||||
}
|
||||
}
|
||||
}.apply { start() }
|
||||
|
||||
val current = TrafficStats()
|
||||
var out = TrafficStats()
|
||||
private var timestampLast = 0L
|
||||
private var dirty = false
|
||||
private var persisted: TrafficStats? = null
|
||||
|
||||
fun requestUpdate(): Pair<TrafficStats, Boolean> {
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
val delta = now - timestampLast
|
||||
timestampLast = now
|
||||
var updated = false
|
||||
if (delta != 0L) {
|
||||
if (dirty) {
|
||||
out = current.copy().apply {
|
||||
txRate = (txTotal - out.txTotal) * 1024 / delta
|
||||
rxRate = (rxTotal - out.rxTotal) * 1024 / delta
|
||||
}
|
||||
dirty = false
|
||||
updated = true
|
||||
} else {
|
||||
if (out.txRate != 0L) {
|
||||
out.txRate = 0
|
||||
updated = true
|
||||
}
|
||||
if (out.rxRate != 0L) {
|
||||
out.rxRate = 0
|
||||
updated = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return Pair(out, updated)
|
||||
}
|
||||
|
||||
fun persistStats() {
|
||||
val current = current
|
||||
check(persisted == null || persisted == current) { "Data loss occurred" }
|
||||
persisted = current
|
||||
|
||||
// try {
|
||||
// // profile may have host, etc. modified and thus a re-fetch is necessary (possible race condition)
|
||||
// val profile = ProfileManager.getProfile(id) ?: return
|
||||
// profile.tx += current.txTotal
|
||||
// profile.rx += current.rxTotal
|
||||
// ProfileManager.updateProfile(profile)
|
||||
// } catch (e: IOException) {
|
||||
// if (!DataStore.directBootAware) throw e // we should only reach here because we're in direct boot
|
||||
// val profile = DirectBoot.getDeviceProfile()!!.toList().single { it.id == id }
|
||||
// profile.tx += current.txTotal
|
||||
// profile.rx += current.rxTotal
|
||||
// profile.dirty = true
|
||||
// DirectBoot.update(profile)
|
||||
// DirectBoot.listenForUnlock()
|
||||
// }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package com.sail_tunnel.sail.services
|
||||
|
||||
import android.net.LocalServerSocket
|
||||
import android.net.LocalSocket
|
||||
import android.net.LocalSocketAddress
|
||||
import android.system.Os
|
||||
import com.sail_tunnel.sail.Core
|
||||
import com.sail_tunnel.sail.TunnelService
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import java.nio.ByteBuffer
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
class TunnelInstance() {
|
||||
private var configFile: File? = null
|
||||
var trafficMonitor: TrafficMonitor? = null
|
||||
|
||||
private val rtId: Int = 666
|
||||
|
||||
init {
|
||||
System.loadLibrary("leaf")
|
||||
}
|
||||
|
||||
private external fun leafRun(rtId: Int, configPath: String)
|
||||
private external fun leafReload(rtId: Int)
|
||||
private external fun leafShutdown(rtId: Int)
|
||||
private external fun leafTestConfig(configPath: String)
|
||||
|
||||
/**
|
||||
* Sensitive shadowsocks configuration file requires extra protection. It may be stored in encrypted storage or
|
||||
* device storage, depending on which is currently available.
|
||||
*/
|
||||
fun start(
|
||||
service: TunnelService,
|
||||
stat: File,
|
||||
configFile: File,
|
||||
) {
|
||||
// setup traffic monitor path
|
||||
trafficMonitor = TrafficMonitor(stat)
|
||||
|
||||
Timber.i("start process")
|
||||
|
||||
val context = Core.deviceStorage
|
||||
val configRoot = context.noBackupFilesDir //noBackupFilesDir
|
||||
val protectPath =
|
||||
File.createTempFile("socket_protect", ".sock", configRoot).absolutePath
|
||||
|
||||
Os.setenv("SOCKET_PROTECT_PATH", protectPath, true)
|
||||
|
||||
println("protectPath: $protectPath")
|
||||
|
||||
thread (start = true) {
|
||||
val localSocket = LocalSocket()
|
||||
localSocket.bind(
|
||||
LocalSocketAddress(
|
||||
protectPath,
|
||||
LocalSocketAddress.Namespace.FILESYSTEM
|
||||
)
|
||||
)
|
||||
val socket = LocalServerSocket(localSocket.fileDescriptor)
|
||||
val buffer = ByteBuffer.allocate(4)
|
||||
|
||||
while (true) {
|
||||
val stream = socket.accept()
|
||||
buffer.clear()
|
||||
val n = stream.inputStream.read(buffer.array())
|
||||
|
||||
System.out.write(n)
|
||||
|
||||
if (n == 4) {
|
||||
val fd = buffer.int
|
||||
if (!service.protect(fd)) {
|
||||
println("protect failed")
|
||||
}
|
||||
buffer.clear()
|
||||
buffer.putInt(0)
|
||||
} else {
|
||||
buffer.clear()
|
||||
buffer.putInt(1)
|
||||
}
|
||||
stream.outputStream.write(buffer.array())
|
||||
}
|
||||
}
|
||||
|
||||
// start tunnel program
|
||||
thread(start = true) {
|
||||
leafRun(rtId, configFile.absolutePath)
|
||||
}
|
||||
}
|
||||
|
||||
fun shutdown(scope: CoroutineScope) {
|
||||
trafficMonitor?.apply {
|
||||
thread.shutdown(scope)
|
||||
persistStats() // Make sure update total traffic when stopping the runner
|
||||
}
|
||||
trafficMonitor = null
|
||||
configFile?.delete() // remove old config possibly in device storage
|
||||
configFile = null
|
||||
|
||||
// stop tunnel program
|
||||
thread(start = true, isDaemon = true) {
|
||||
leafShutdown(rtId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.sail_tunnel.sail.services
|
||||
|
||||
import kotlinx.coroutines.Job
|
||||
|
||||
object VpnState {
|
||||
enum class State {
|
||||
/**
|
||||
* Idle state is only used by UI and will never be returned by BaseService.
|
||||
*/
|
||||
Idle,
|
||||
Connecting,
|
||||
Connected,
|
||||
Stopping,
|
||||
Stopped,
|
||||
}
|
||||
|
||||
const val CONFIG_FILE = "leaf.conf"
|
||||
const val LEAF_LOG_FILE = "leaf.log"
|
||||
|
||||
class Data internal constructor() {
|
||||
var state = State.Stopped
|
||||
var proxy: TunnelInstance? = null
|
||||
var localDns: LocalDnsWorker? = null
|
||||
|
||||
var connectingJob: Job? = null
|
||||
|
||||
fun changeState(s: State, msg: String? = null) {
|
||||
if (state == s && msg == null) return
|
||||
state = s
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.sail_tunnel.sail.utils
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
|
||||
@SuppressLint("Registered")
|
||||
class DeviceStorageApp(context: Context) : Application() {
|
||||
init {
|
||||
attachBaseContext(context.createDeviceProtectedStorageContext())
|
||||
}
|
||||
|
||||
/**
|
||||
* Thou shalt not get the REAL underlying application context which would no longer be operating under device
|
||||
* protected storage.
|
||||
*/
|
||||
override fun getApplicationContext() = this
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.sail_tunnel.sail.utils
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
data class TrafficStats(
|
||||
// Bytes per second
|
||||
var txRate: Long = 0L,
|
||||
var rxRate: Long = 0L,
|
||||
|
||||
// Bytes for the current session
|
||||
var txTotal: Long = 0L,
|
||||
var rxTotal: Long = 0L
|
||||
) : Parcelable {
|
||||
operator fun plus(other: TrafficStats) = TrafficStats(
|
||||
txRate + other.txRate, rxRate + other.rxRate,
|
||||
txTotal + other.txTotal, rxTotal + other.rxTotal
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.sail_tunnel.sail.utils
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Build
|
||||
import kotlinx.coroutines.*
|
||||
import java.io.FileDescriptor
|
||||
import java.net.HttpURLConnection
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
val Throwable.readableMessage: String get() = localizedMessage ?: javaClass.name
|
||||
|
||||
/**
|
||||
* https://android.googlesource.com/platform/prebuilts/runtime/+/94fec32/appcompat/hiddenapi-light-greylist.txt#9466
|
||||
*/
|
||||
@SuppressLint("DiscouragedPrivateApi")
|
||||
private val getInt = FileDescriptor::class.java.getDeclaredMethod("getInt$")
|
||||
val FileDescriptor.int get() = getInt.invoke(this) as Int
|
||||
|
||||
suspend fun <T> HttpURLConnection.useCancellable(block: suspend HttpURLConnection.() -> T): T {
|
||||
return suspendCancellableCoroutine { cont ->
|
||||
cont.invokeOnCancellation {
|
||||
if (Build.VERSION.SDK_INT >= 26) disconnect() else GlobalScope.launch(Dispatchers.IO) { disconnect() }
|
||||
}
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
cont.resume(block())
|
||||
} catch (e: Throwable) {
|
||||
cont.resumeWithException(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="?android:colorBackground" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@android:color/white" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
@@ -0,0 +1,18 @@
|
||||
<?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>
|
||||
</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>
|
||||
@@ -0,0 +1,18 @@
|
||||
<?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>
|
||||
</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>
|
||||
@@ -0,0 +1,10 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="com.sail_tunnel.sail">
|
||||
<!-- 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.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE"/>
|
||||
</manifest>
|
||||
@@ -0,0 +1,31 @@
|
||||
buildscript {
|
||||
ext.kotlin_version = '1.6.10'
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:7.1.3'
|
||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||
}
|
||||
}
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.buildDir = '../build'
|
||||
subprojects {
|
||||
project.buildDir = "${rootProject.buildDir}/${project.name}"
|
||||
}
|
||||
subprojects {
|
||||
project.evaluationDependsOn(':app')
|
||||
}
|
||||
|
||||
tasks.register("clean", Delete) {
|
||||
delete rootProject.buildDir
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
org.gradle.jvmargs=-Xmx1536M
|
||||
android.useAndroidX=true
|
||||
android.enableJetifier=true
|
||||
android.suppressUnsupportedCompileSdk=33
|
||||
@@ -0,0 +1,5 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-all.zip
|
||||
@@ -0,0 +1,11 @@
|
||||
include ':app'
|
||||
|
||||
def localPropertiesFile = new File(rootProject.projectDir, "local.properties")
|
||||
def properties = new Properties()
|
||||
|
||||
assert localPropertiesFile.exists()
|
||||
localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) }
|
||||
|
||||
def flutterSdkPath = properties.getProperty("flutter.sdk")
|
||||
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
|
||||
apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle"
|
||||