Compare commits

...

47 Commits

Author SHA1 Message Date
Mykola Baibuz 914e3e2331 Merge branch 'dev' into android_ss_transport 2023-06-17 13:27:19 -04:00
Mykola Baibuz c589bc9f5d Disable IPv6 traffic
Prevent IPv6 leak
2023-04-22 18:43:05 -04:00
Mykola Baibuz c3d92355b1 Merge remote-tracking branch 'origin/dev' into android_ss_transport 2023-04-10 15:17:00 -04:00
Mykola Baibuz 48704a2711 Merge remote-tracking branch 'origin/dev' into android_ss_transport 2023-04-06 15:20:18 -04:00
Mykola Baibuz 6a6b1230fe Remove OpenVPN from ShadowSocks container 2023-04-03 15:50:28 -04:00
Mykola Baibuz 969aa7ad60 Linux tun creation refactor 2023-04-03 15:09:08 -04:00
Mykola Baibuz 3ec1812da9 Windows binary and some fixes for it. 2023-04-03 09:58:56 -04:00
Mykola Baibuz 6714643c3f Merge remote-tracking branch 'origin/dev' into android_ss_transport 2023-04-02 12:54:48 -04:00
Mykola Baibuz 7f9fb04554 Allow udp traffic through ShadowSocks 2023-04-02 12:49:38 -04:00
Mykola Baibuz fd492cfa9b Fix routing for desktop shadowsocks 2023-04-01 16:32:37 -04:00
Mykola Baibuz 48f01132fb Remove gms AD_ID from project 2023-03-31 16:24:49 -04:00
Mykola Baibuz 690d92f236 Merge branch 'android_pt_transport_source' into android_ss_transport 2023-03-31 15:36:37 -04:00
pokamest 33efa56f25 Merge branch 'dev' into android_pt_transport_source 2023-03-31 19:20:09 +01:00
Mykola Baibuz 0fb854aedb Linux desktop SS clean support (without OpenVPN)
Create/delete tun adapter
Start tun2socks
2023-03-31 05:08:47 -04:00
Mykola Baibuz 7781258930 Refactor ShadowSocks DisallowedApplication logic 2023-03-26 16:48:31 -04:00
Mykola Baibuz cff17836b1 Fix ShadowSocks service restart 2023-03-25 19:07:46 -04:00
Mykola Baibuz f461b66abe Make ShadowSocks work on Android 2023-03-18 17:56:02 -04:00
Mykola Baibuz 88445dc8c4 Tematory disable firebase 2023-03-16 19:44:27 -04:00
Mykola Baibuz 291263f96f Fixing android build 2023-03-16 16:12:26 -04:00
Mykola Baibuz 22fcb51a80 Update Shadowsocks for Android
update Shadowsocks source from https://github.com/shadowsocks/shadowsocks-android and build new binary
2023-03-16 09:59:35 -04:00
Mykola Baibuz 930e227a9e Update OpenVPN3 version
This changes the OpenVPN3 version number to 3.7.2
2023-03-12 17:59:26 -04:00
Mykola Baibuz bdaa56f734 Fix cloak plugin build 2023-03-12 08:25:19 -04:00
Mykola Baibuz c430cca538 Update OpenVPN3 repo
update openvpn3 submodule
2023-03-12 05:41:23 -04:00
pokamest fc8dfce90d Lib prefix added to cloak libs 2023-03-12 01:39:27 +00:00
Dmitriy Karpushin beca12ae40 Setting of minimum cmake version to 3.25 for cloak build 2023-03-10 12:09:46 +03:00
pokamest 109512d83e Merge branch 'dev' into android_pt_transport_source 2023-03-06 12:08:45 +00:00
pokamest 1fb21cfbfc Merge branch 'dev' into android_pt_transport_source 2023-03-05 12:05:43 +00:00
pokamest ff5fc4cd2a Merge branch 'dev' into android_pt_transport_source 2023-02-22 18:27:12 +00:00
pokamest 3f600c0088 Android pt refactor (#176)
Cloak build fix
2023-02-17 01:34:08 +00:00
Mykola Baibuz adc07a2b6a Build Cloak plugin with CMake 2023-02-05 09:25:36 -05:00
Dmitriy Karpushin 7e3134cdbb Proper destruction of ovpn thread 2023-01-31 14:32:42 +03:00
Dmitriy Karpushin 95b3b0eae3 Incorrect Pluggable Transport initialization fix 2023-01-31 10:20:36 +03:00
pokamest 61c27af17c Merge branch 'dev' into android_pt_transport_source 2023-01-30 20:48:59 +00:00
Dmitriy Karpushin 2fa82a05d9 * For the openvpn-cloak container, the choice of the transport protocol in the openvpn settings is blocked
* fixed warning with QFutureWatcher
2023-01-30 18:20:41 +03:00
Dmitriy Karpushin f8408e863a Support of cloak config inside of ovpn config 2023-01-30 18:08:45 +03:00
Mykola Baibuz 3c0ac8170d Pulled down update to openvpn3 2023-01-28 23:49:10 +02:00
Mykola Baibuz 591e0fea80 Remove lzo library and add it as a submodule 2023-01-20 16:15:43 +02:00
Mykola Baibuz 9a67d2684e Remove unused files 2023-01-20 15:13:08 +02:00
Mykola Baibuz 2d624b3b59 Add android plugin into apk 2023-01-20 09:33:15 +02:00
Mykola Baibuz cc93898c60 Add lzo module for Android build 2023-01-19 23:58:16 +02:00
Mykola Baibuz 3f265b899e Remove prebuilded native libraries from cmake 2023-01-19 23:56:10 +02:00
Mykola Baibuz 799c3ec6e6 Remove strip from plugin build script 2023-01-19 23:32:38 +02:00
Mykola Baibuz 32b355a54e Update deploy env for GO support 2023-01-19 23:08:39 +02:00
Mykola Baibuz 82e831f6d8 Add Cloak plugin android build script 2023-01-19 23:00:43 +02:00
Mykola Baibuz a19e69ae61 Update swig output 2023-01-19 22:54:47 +02:00
Mykola Baibuz 995a60c503 Remove prebuilded native libraries 2023-01-19 22:52:48 +02:00
Mykola Baibuz 0bb4ad2fbe Add android OpenVPN3 submodules
This submodules are needed by android native build from sources.
2023-01-19 22:49:01 +02:00
154 changed files with 5286 additions and 1865 deletions
+10 -1
View File
@@ -22,6 +22,8 @@ buildscript {
} }
dependencies { dependencies {
classpath 'com.google.gms:google-services:4.3.2'
classpath 'com.google.firebase:firebase-crashlytics-gradle:2.1.1'
classpath 'com.android.tools.build:gradle:7.2.1' classpath 'com.android.tools.build:gradle:7.2.1'
classpath 'com.github.ben-manes:gradle-versions-plugin:0.21.0' classpath 'com.github.ben-manes:gradle-versions-plugin:0.21.0'
classpath 'com.vanniktech:gradle-maven-publish-plugin:0.8.0' classpath 'com.vanniktech:gradle-maven-publish-plugin:0.8.0'
@@ -39,7 +41,7 @@ apply plugin: 'kotlin-kapt'
dependencies { dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar']) implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar'])
implementation group: 'org.json', name: 'json', version: '20220924' implementation group: 'org.json', name: 'json', version: '20220924'
implementation 'androidx.core:core-ktx:1.7.0' implementation 'androidx.core:core-ktx:1.9.0'
implementation 'androidx.appcompat:appcompat:1.4.1' implementation 'androidx.appcompat:appcompat:1.4.1'
implementation "androidx.security:security-crypto:1.1.0-alpha03" implementation "androidx.security:security-crypto:1.1.0-alpha03"
@@ -63,6 +65,13 @@ dependencies {
def camerax_ml_version = "1.2.0-beta02" def camerax_ml_version = "1.2.0-beta02"
def ml_kit_version = "17.0.3" def ml_kit_version = "17.0.3"
def work_version = "2.7.1"
implementation "com.google.android.gms:play-services-oss-licenses:17.0.0"
implementation "com.google.code.gson:gson:2.10.1"
implementation "com.google.firebase:firebase-analytics-ktx:21.2.0"
implementation "com.google.firebase:firebase-crashlytics:18.3.3"
implementation "androidx.work:work-runtime-ktx:${work_version}"
implementation("androidx.camera:camera-mlkit-vision:${camerax_ml_version}") implementation("androidx.camera:camera-mlkit-vision:${camerax_ml_version}")
implementation("com.google.mlkit:barcode-scanning:${ml_kit_version}") implementation("com.google.mlkit:barcode-scanning:${ml_kit_version}")
} }
+31 -12
View File
@@ -1,14 +1,15 @@
apply plugin: 'com.android.library' apply plugin: 'com.android.library'
apply plugin: 'kotlin-android' apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions' //apply plugin: 'kotlin-android-extensions'
apply plugin: 'kotlin-kapt' apply plugin: 'kotlin-kapt'
apply plugin: 'kotlin-parcelize'
//apply plugin: 'com.novoda.bintray-release' //apply plugin: 'com.novoda.bintray-release'
android { android {
compileSdkVersion 30 compileSdkVersion androidCompileSdkVersion.toInteger()
defaultConfig { defaultConfig {
minSdkVersion 24 minSdkVersion 24
targetSdkVersion 30 targetSdkVersion 31
versionCode 1 versionCode 1
versionName "1.0.0" versionName "1.0.0"
@@ -28,6 +29,9 @@ android {
kotlinOptions { kotlinOptions {
jvmTarget = '1.8' jvmTarget = '1.8'
} }
kapt {
correctErrorTypes = true
}
} }
androidExtensions { androidExtensions {
@@ -42,29 +46,44 @@ dependencies {
implementation 'androidx.lifecycle:lifecycle-common-java8:2.4.0' implementation 'androidx.lifecycle:lifecycle-common-java8:2.4.0'
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.4.30-M1" implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.4.30-M1"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.0" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.0" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4"
implementation "androidx.core:core-ktx:1.2.0" implementation "androidx.core:core-ktx:1.2.0"
implementation "androidx.lifecycle:lifecycle-extensions:2.2.0" implementation "androidx.lifecycle:lifecycle-extensions:2.2.0"
implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.4.0" implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.5.1"
implementation "androidx.lifecycle:lifecycle-livedata-core-ktx:2.4.0" implementation "androidx.lifecycle:lifecycle-livedata-core-ktx:2.4.0"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0" implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0"
implementation "androidx.room:room-runtime:$roomVersion" // runtime implementation "androidx.room:room-runtime:2.5.0" // runtime
implementation "androidx.preference:preference:1.1.0" implementation "androidx.preference:preference:1.2.0"
implementation "androidx.work:work-runtime-ktx:2.7.1" implementation "androidx.work:work-runtime-ktx:2.7.1"
implementation "androidx.browser:browser:1.3.0-alpha01" implementation "androidx.browser:browser:1.3.0-alpha01"
implementation "androidx.constraintlayout:constraintlayout:1.1.3" implementation "androidx.constraintlayout:constraintlayout:1.1.3"
implementation "com.google.android.material:material:1.2.0-alpha05" implementation "com.google.android.material:material:1.2.0-alpha05"
implementation "com.google.code.gson:gson:2.8.5" implementation "androidx.work:work-multiprocess:2.7.1"
implementation "dnsjava:dnsjava:2.1.9" implementation "com.android.support:support-compat:27.0.2"
implementation "androidx.activity:activity:1.6.1"
implementation "androidx.fragment:fragment:1.3.0"
implementation "com.jakewharton.timber:timber:5.0.1"
implementation "com.google.android.gms:play-services-oss-licenses:17.0.0"
implementation "com.google.code.gson:gson:2.10.1"
implementation "com.google.firebase:firebase-analytics-ktx:21.2.0"
implementation "com.google.firebase:firebase-crashlytics:18.3.3"
implementation "androidx.fragment:fragment-ktx:1.3.0-rc02"
implementation "androidx.core:core-ktx:1.9.0"
implementation "dnsjava:dnsjava:3.5.2"
implementation "org.connectbot.jsocks:jsocks:1.0.0" implementation "org.connectbot.jsocks:jsocks:1.0.0"
implementation "com.afollestad.material-dialogs:core:2.6.0" implementation "com.afollestad.material-dialogs:core:2.6.0"
// api "com.takisoft.preferencex:preferencex:1.0.0" // api "com.takisoft.preferencex:preferencex:1.0.0"
implementation 'com.takisoft.preferencex:preferencex:1.1.0' implementation 'com.takisoft.preferencex:preferencex:1.1.0'
api 'org.connectbot.jsocks:jsocks:1.0.0'
kapt "androidx.room:room-compiler:$roomVersion" api 'org.connectbot.jsocks:jsocks:1.0.0'
api 'androidx.fragment:fragment-ktx:1.5.5'
kapt "androidx.room:room-compiler:2.5.0"
kapt "androidx.lifecycle:lifecycle-compiler:2.4.0" kapt "androidx.lifecycle:lifecycle-compiler:2.4.0"
} }
apply plugin: 'com.google.gms.google-services'
@@ -0,0 +1,69 @@
{
"project_info": {
"project_number": "94322785672",
"firebase_url": "https://amnezia-c6715-default-rtdb.europe-west1.firebasedatabase.app",
"project_id": "amnezia-c6715",
"storage_bucket": "amnezia-c6715.appspot.com"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:94322785672:android:ed42ab188d6edecf754e3d",
"android_client_info": {
"package_name": "org.amnezia.vpn"
}
},
"oauth_client": [
{
"client_id": "94322785672-p3q726tro36fr9nluj45l35gqv6nhjsq.apps.googleusercontent.com",
"client_type": 3
}
],
"api_key": [
{
"current_key": "AIzaSyCvblU4w_NZbWk9bNQc-KGmg-WODjbb308"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": [
{
"client_id": "94322785672-p3q726tro36fr9nluj45l35gqv6nhjsq.apps.googleusercontent.com",
"client_type": 3
}
]
}
}
},
{
"client_info": {
"mobilesdk_app_id": "1:94322785672:android:95e8779ff76d6641754e3d",
"android_client_info": {
"package_name": "org.amnezia.vpn.shadowsocks.core"
}
},
"oauth_client": [
{
"client_id": "94322785672-p3q726tro36fr9nluj45l35gqv6nhjsq.apps.googleusercontent.com",
"client_type": 3
}
],
"api_key": [
{
"current_key": "AIzaSyCvblU4w_NZbWk9bNQc-KGmg-WODjbb308"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": [
{
"client_id": "94322785672-p3q726tro36fr9nluj45l35gqv6nhjsq.apps.googleusercontent.com",
"client_type": 3
}
]
}
}
}
],
"configuration_version": "1"
}
@@ -0,0 +1 @@
/build
@@ -0,0 +1,80 @@
* 2.0.1:
* Moved `AlertDialogFragment` and related utilities to `fragment` package, with support for Fragment Result API from AndroidX Fragment 1.3.
* Dependency updates:
- `androidx.fragment:fragment-ktx:1.3.3`;
- `com.google.android.material:material:1.3.0`;
- `org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.4.32`.
* 2.0.0:
* Deprecated passing `-V` and `--fast-open` to plugin.
Please find `__android_vpn` option passed via plugin options.
* Dependency updates:
- `androidx.core:core-ktx:1.3.2`;
- `androidx.drawerlayout:drawerlayout:1.1.1`;
- `com.google.android.material:material:1.2.1`;
- `org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.4.10`.
* 1.3.4:
* Optional new metadata `com.github.shadowsocks.plugin.id.aliases` for plugin ID aliases;
(see doc for `PluginContract.METADATA_KEY_ID_ALIASES` and main documentation "Plugin ID Aliasing" for more information)
* Please use `android:path` instead of `android:pathPrefix`, sample code in documentations have been updated to reflect this recommendation.
* Added missing documentation regarding direct boot support.
Please add `android:directBootAware="true"` with proper support for your `provider` if possible.
* You can now use `android:resources` on `meta-data` tags. (main/host app update required, however, you should never use dynamic resources)
* Fix occasional crash in `AlertDialogFragment`.
* Translation updates.
* Dependency updates:
- `androidx.core:core-ktx:1.2.0`;
- `com.google.android.material:material:1.1.0`.
* 1.3.3:
* Fix a build script issue.
* 1.3.2:
* Fix first key-value pair disappearing with null value. (#2391)
* Dependency updates:
- `androidx.core:core-ktx:1.1.0`;
- `com.google.android.material:material:1.1.0-rc01`;
- `org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.3.61`.
* 1.3.1:
* New theme resource `Theme.Shadowsocks.Immersive` for better Android Q-esque translucent navigation bars.
This is an opt-in feature.
Please add `android:theme="@style/Theme.Shadowsocks.Immersive"` to your `<activity>` to enable this theme.
* New color resources `light_*` and `dark_*` for passing to custom tabs;
* Dependency updates:
- `androidx.core:core-ktx:1.1.0-rc03`;
- `androidx.drawerlayout:drawerlayout:1.1.0-alpha03`;
- `com.google.android.material:material:1.1.0-alpha09`;
- `org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.3.41`.
* 1.3.0:
* Optional new metadata `com.github.shadowsocks.plugin.executable_path` for even faster initialization;
(see doc for `PluginContract.METADATA_KEY_EXECUTABLE_PATH` for more information)
* Breaking API change: `val AlertDialogFragment.ret: Ret?` => `fun AlertDialogFragment.ret(which: Int): Ret?`;
(nothing needs to be done if you are not using this API)
* Dependency updates:
- Now targeting API 29;
- `androidx.core:core-ktx:1.1.0-rc01`;
- `com.google.android.material:material:1.1.0-alpha07`;
- `org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.3.40`.
* 1.2.0:
* New helper class `AlertDialogFragment` for creating `AlertDialog` that persists through configuration changes;
* Dependency update: `com.google.android.material:material:1.1.0-alpha03`.
* 1.1.0:
* Having control characters in plugin options is no longer allowed.
If this breaks your plugin, you are doing it wrong.
* New helper method: `PluginOptions.putWithDefault`.
* 1.0.0:
* BREAKING CHANGE: Plugins developed using this version and forward require shadowsocks-android 4.6.5 or higher.
* `PathProvider` now takes `Int` instead of `String` for file modes;
* Refactor to AndroidX;
* No longer depends on preference libraries.
* 0.1.1:
* Rewritten in Kotlin;
* Fix assert not working;
* Min API 21;
* Update support library version to 27.1.1.
* 0.0.4:
* Enlarge text size of number pickers;
* Update support library version to 26.0.0.
* 0.0.3:
* Update support library version to 25.2.0.
* 0.0.2:
* Add `getOrDefault` to `PluginOptions`;
* Update support library version to 25.1.1.
* 0.0.1: Initial release.
+167
View File
@@ -0,0 +1,167 @@
# shadowsocks-android plugin framework
[Documentation](doc.md) | [Change log](CHANGES.md)
Support library for easier development on [shadowsocks
plugin](https://github.com/shadowsocks/shadowsocks-org/issues/28) for Android. Also includes some
useful resources to easily get consistent styling with the main app.
## Official plugins
These are some plugins ready to use on shadowsocks-android.
* [v2ray](https://github.com/shadowsocks/v2ray-plugin-android)
* [kcptun](https://github.com/shadowsocks/kcptun-android/releases)
* [simple-obfs](https://github.com/shadowsocks/simple-obfs-android/releases)
## Developer's guide
This library is designed with Java interoperability in mind so theoretically you can use this
library with other languages and/or build tools but there isn't documentation for that yet. This
guide is written for Scala + SBT. Contributions are welcome.
### Package name
There are no arbitrary restrictions/requirements on package name, component name and content
provider authority, but you're suggested to follow the format in this documentations. For package
name, use `com.github.shadowsocks.plugin.$PLUGIN_ID` if it only contains a single plugin to
prevent duplicated plugins. In some places hyphens are not accepted, for example package name. In
that case, hyphens `-` should be changed into underscores `_`. For example, the package name for
`obfs-local` would probably be `com.github.shadowsocks.plugin.obfs_local`.
### Add dependency
First you need to add this library to your dependencies.
This library is written mostly in Kotlin but can also work with Java-only projects:
```gradle
implementation 'com.github.shadowsocks:plugin:$LATEST_VERSION'
```
### Native binary configuration
First you need to get your native binary compiling on Android platform.
* [Sample project for C](https://github.com/shadowsocks/simple-obfs-android/tree/4f82c4a4e415d666e70a7e2e60955cb0d85c1615);
* [Sample project for Go](https://github.com/shadowsocks/v2ray-plugin-android/tree/172bd4cec0276112828614482fb646b79dbf1540).
In addition to functionalities of a normal plugin, it has to support these additional options:
* `__android_vpn`: VPN mode.
In this case, the plugin should pass all file descriptors that needs protecting from VPN connections (i.e. its traffic will not be forwarded through the VPN) through an ancillary message to `./protect_path`.
### Implement a binary provider
You just need to implement two or three methods. For example for `v2ray`:
```kotlin
class BinaryProvider : NativePluginProvider() {
override fun populateFiles(provider: PathProvider) {
provider.addPath("v2ray", 0b111101101)
// add additional files here
}
// remove this method to disable fast mode, read more in the documentation
override fun getExecutable() = context!!.applicationInfo.nativeLibraryDir + "/libv2ray.so"
override fun openFile(uri: Uri): ParcelFileDescriptor = when (uri.path) {
"/v2ray" -> ParcelFileDescriptor.open(File(getExecutable()), ParcelFileDescriptor.MODE_READ_ONLY)
// handle additional files here
else -> throw FileNotFoundException()
}
}
```
Then add it to your manifest:
```xml
<manifest>
...
<application>
...
<provider android:name=".BinaryProvider"
android:exported="true"
android:directBootAware="true"
android:authorities="$FULLY_QUALIFIED_NAME_OF_YOUR_CONTENTPROVIDER">
<intent-filter>
<action android:name="com.github.shadowsocks.plugin.ACTION_NATIVE_PLUGIN"/>
</intent-filter>
<intent-filter>
<action android:name="com.github.shadowsocks.plugin.ACTION_NATIVE_PLUGIN"/>
<data android:scheme="plugin"
android:host="com.github.shadowsocks"
android:path="/$PLUGIN_ID"/>
</intent-filter>
<meta-data android:name="com.github.shadowsocks.plugin.id"
android:value="$PLUGIN_ID"/>
<!-- Optional: default is empty -->
<meta-data android:name="com.github.shadowsocks.plugin.default_config"
android:value="dummy=default;plugin=options"/>
<!-- Optional: remove to disable faster mode, read more in the documentation -->
<meta-data android:name="com.github.shadowsocks.plugin.executable_path"
android:value="$PATH_TO_EXECUTABLE_RELATIVE_TO_NATIVE_LIB_DIR"/>
</provider>
...
</application>
</manifest>
```
### Add user interfaces
You should add to your plugin app a configuration activity or a help activity or both if you're
going to use `ConfigurationActivity.fallbackToManualEditor`.
#### Configuration activity
This is used if found instead of a manual input dialog when user clicks "Configure..." in the main
app. This gives you maximum freedom of the user interface. To implement this, you need to extend
`ConfigurationActivity` and you will get current options via
`onInitializePluginOptions(PluginOptions)` and you can invoke `saveChanges(PluginOptions)` or
`discardChanges()` before `finish()` or `fallbackToManualEditor()`. Then add it to your manifest:
```xml
<manifest>
...
<application>
...
<activity android:name=".ConfigActivity">
<intent-filter>
<action android:name="com.github.shadowsocks.plugin.ACTION_CONFIGURE"/>
<category android:name="android.intent.category.DEFAULT"/>
<data android:scheme="plugin"
android:host="com.github.shadowsocks"
android:path="/$PLUGIN_ID"/>
</intent-filter>
</activity>
...
</application>
</manifest>
```
#### Help activity/callback
This is started when user taps "?" in manual editor. To implement this, you need to extend
`HelpCallback` if you want a simple dialog with help message as `CharSequence` or `HelpActivity`
if you want to provide custom user interface, implement the required methods, then add it to your
manifest:
```xml
<manifest>
...
<application>
...
<activity android:name=".HelpActivity">
<intent-filter>
<action android:name="com.github.shadowsocks.plugin.ACTION_HELP"/>
<category android:name="android.intent.category.DEFAULT"/>
<data android:scheme="plugin"
android:host="com.github.shadowsocks"
android:path="/$PLUGIN_ID"/>
</intent-filter>
</activity>
...
</application>
</manifest>
```
Great. Now your plugin is ready to use.
@@ -0,0 +1,20 @@
plugins {
id("com.android.library")
id("com.vanniktech.maven.publish")
kotlin("android")
id("kotlin-parcelize")
}
setupCommon()
android {
namespace = "com.github.shadowsocks.plugin"
lint.informational += "GradleDependency"
}
dependencies {
api(kotlin("stdlib-jdk8"))
api("androidx.core:core-ktx:1.7.0")
api("androidx.fragment:fragment-ktx:1.5.5")
api("com.google.android.material:material:1.6.0")
}
+291
View File
@@ -0,0 +1,291 @@
# Overview
Plugin should be bundled as an apk. `$PLUGIN_ID` in this documentation corresponds to the
executable name for the plugin in order to be cross-platform, e.g. `obfs-local`. An apk can have
more than one plugins bundled. We don't care as long as they have different `$PLUGIN_ID`. For
duplicated plugin ID, host should refuse to start.
There are no arbitrary restrictions/requirements on package name, component name and content
provider authority, but you're suggested to follow the format in this documentations. For package
name, use `com.github.shadowsocks.plugin.$PLUGIN_ID` if it only contains a single plugin to prevent
duplicated plugins. In some places hyphens are not accepted, for example package name. In that
case, hyphens `-` should be changed into underscores `_`. For example, the package name for
`obfs-local` would probably be `com.github.shadowsocks.plugin.obfs_local`.
It's advised to use this library for easier development, but you're free to start from scratch following this
documentation.
# Plugin configuration
Plugins get their args configured via one of the following two options:
* A configuration activity;
([example](https://github.com/shadowsocks/simple-obfs-android/tree/4f82c4a4e415d666e70a7e2e60955cb0d85c1615))
* If no configuration activity is found or the activity requests the fallback mode, the fallback
mode will be used: user manual input and optional help message.
([example](https://github.com/shadowsocks/kcptun-android/tree/41f42077e177618553417c16559784a51e9d8c4c))
Your user interface need not be consistent with shadowsocks-android styling - you don't need to use
preferences UI at all if you don't feel like it - however it's recommended to use Material Design
at minimum.
## Configuration activity
If the plugin provides a configuration activity, it will be started when user picks your plugin and
taps configure. It:
* MUST have action: `com.github.shadowsocks.plugin.ACTION_CONFIGURE`;
* MUST have category: `android.intent.category.DEFAULT`;
* MUST be able to receive data URI `plugin://com.github.shadowsocks/$PLUGIN_ID`;
* SHOULD parse string extra `com.github.shadowsocks.plugin.EXTRA_OPTIONS` (all options as a single
string) and display the current options;
* SHOULD distinguish between server settings and feature settings in some way, e.g. for
`obfs-local`, `obfs` is a server setting and `obfs_host` is a feature setting;
* On finish, it SHOULD return one of the following results:
- `RESULT_OK = 0`: In this case it MUST return the data Intent with the new
`com.github.shadowsocks.plugin.EXTRA_OPTIONS`;
- `RESULT_CANCELED = -1`: Nothing will be changed;
- `RESULT_FALLBACK = 1`: Fallback mode is requested and the host should display the fallback
editor.
This corresponds to `com.github.shadowsocks.plugin.ConfigurationActivity` in the plugin library.
Here's what a proper configuration activity usually should look like in `AndroidManifest.xml`:
```xml
<manifest>
...
<application>
...
<activity android:name=".ConfigActivity">
<intent-filter>
<action android:name="com.github.shadowsocks.plugin.ACTION_CONFIGURE"/>
<category android:name="android.intent.category.DEFAULT"/>
<data android:scheme="plugin"
android:host="com.github.shadowsocks"
android:path="/$PLUGIN_ID"/>
</intent-filter>
</activity>
...
</application>
</manifest>
```
## Help activity/callback
If the plugin doesn't provide a configuration activity, it's highly recommended to provide a help
message in the form of an Activity. It:
* MUST have action: `com.github.shadowsocks.plugin.ACTION_HELP`;
* MUST have category: `android.intent.category.DEFAULT`;
* MUST be able to receive data URI `plugin://com.github.shadowsocks/$PLUGIN_ID`;
* CAN parse string extra `com.github.shadowsocks.plugin.EXTRA_OPTIONS` and display some more
relevant information;
* SHOULD parse `@NightMode` int extra `com.github.shadowsocks.plugin.EXTRA_NIGHT_MODE` and act
accordingly;
* SHOULD either:
- Be invisible and return help message with CharSequence extra
`com.github.shadowsocks.plugin.EXTRA_HELP_MESSAGE` in the data intent with `RESULT_OK`; (in this
case, a simple dialog will be shown containing the message)
- Be visible and return `RESULT_CANCELED`.
* SHOULD distinguish between server settings and feature settings in some way, e.g. for
`simple_obfs`, `obfs` is a server setting and `obfs_host` is a feature setting.
This corresponds to `com.github.shadowsocks.plugin.HelpActivity` or
`com.github.shadowsocks.plugin.HelpCallback` in the plugin library. Here's what a proper help
activity/callback usually should look like in `AndroidManifest.xml`:
```xml
<manifest>
...
<application>
...
<activity android:name=".HelpActivity">
<intent-filter>
<action android:name="com.github.shadowsocks.plugin.ACTION_HELP"/>
<category android:name="android.intent.category.DEFAULT"/>
<data android:scheme="plugin"
android:host="com.github.shadowsocks"
android:path="/$PLUGIN_ID"/>
</intent-filter>
</activity>
...
</application>
</manifest>
```
# Plugin implementation
Every plugin can be either in native mode or JVM mode.
## Native mode
In native mode, plugins are provided as native executables and `shadowsocks-libev`'s plugin mode
will be used.
Every native mode plugin MUST have a content provider to provide the native executables (since they
can exceed 1M which is the limit of Intent size) that:
* MUST have `android:label` and `android:icon`; (may be inherited from parent `application`)
* SHOULD have `android:directBootAware="true"` with proper support if possible;
* MUST have an intent filter with action `com.github.shadowsocks.plugin.ACTION_NATIVE_PLUGIN`;
(used for discovering plugins)
* MUST have meta-data `com.github.shadowsocks.plugin.id` with string value `$PLUGIN_ID` or a string resource;
* MUST have an intent filter with action `com.github.shadowsocks.plugin.ACTION_NATIVE_PLUGIN` and
data `plugin://com.github.shadowsocks/$PLUGIN_ID`; (used for configuring plugin)
* CAN have meta-data `com.github.shadowsocks.plugin.default_config` with string value or a string resource, default is empty;
* MUST implement `query` that returns the file list which MUST include `$PLUGIN_ID` when having
these as arguments:
- `uri = "content://$authority_of_your_provider`;
- `projection = ["path", "mode"]`; (relative path, for example `obfs-local`; file mode as integer, for
example `0b110100100`)
- `selection = null`;
- `selectionArgs = null`;
- `sortOrder = null`;
* MUST implement `openFile` that for files returned in `query`, `openFile` with `mode = "r"` returns
a valid `ParcelFileDescriptor` for reading. For example, `uri` can be
`content://com.github.shadowsocks.plugin.kcptun/kcptun`.
This corresponds to `com.github.shadowsocks.plugin.NativePluginProvider` in the plugin library.
Here's what a proper native plugin provider usually should look like in `AndroidManifest.xml`:
```xml
<manifest>
...
<application>
...
<provider android:name=".BinaryProvider"
android:exported="true"
android:directBootAware="true"
android:authorities="$FULLY_QUALIFIED_NAME_OF_YOUR_CONTENTPROVIDER"
tools:ignore="ExportedContentProvider">
<intent-filter>
<action android:name="com.github.shadowsocks.plugin.ACTION_NATIVE_PLUGIN"/>
</intent-filter>
<intent-filter>
<action android:name="com.github.shadowsocks.plugin.ACTION_NATIVE_PLUGIN"/>
<data android:scheme="plugin"
android:host="com.github.shadowsocks"
android:path="/$PLUGIN_ID"/>
</intent-filter>
<meta-data android:name="com.github.shadowsocks.plugin.id"
android:value="$PLUGIN_ID"/>
<meta-data android:name="com.github.shadowsocks.plugin.default_config"
android:value="dummy=default;plugin=options"/>
</provider>
...
</application>
</manifest>
```
## Native mode without binary copying
If your plugin binary executable can run in place, you can support native mode without binary
copying. To support this mode, your `ContentProvider` must first support native mode with binary
copying (this will be used if the fast routine fails) and:
* MUST implement `call` that returns absolute path to the entry executable as
`com.github.shadowsocks.plugin.EXTRA_ENTRY` when having `method = "shadowsocks:getExecutable"`;
(`com.github.shadowsocks.plugin.EXTRA_OPTIONS` is provided in extras as well just in case you
need them)
* SHOULD define `android:installLocation="internalOnly"` for `<manifest>` in AndroidManifest.xml;
* SHOULD define `android:extractNativeLibs="true"` for `<application>` in AndroidManifest.xml;
If you don't plan to support this mode, you can just throw `UnsupportedOperationException` when
being invoked. It will fallback to the slow routine automatically.
### Native mode without binary copying and setup
Additionally, if your plugin only needs to supply the path of your executable without doing any extra setup work,
you can use an additional `meta-data` with name `com.github.shadowsocks.plugin.executable_path`
to supply executable path to your native binary.
This allows the host app to launch your plugin without ever launching your app.
## JVM mode
This feature hasn't been implemented yet.
Please open an issue if you need this.
# Plugin security
Plugins are certified using package signatures and shadowsocks-android will consider these
signatures as trusted:
* Signatures by [trusted sources](/mobile/src/main/java/com/github/shadowsocks/plugin/PluginManager.kt#L39)
which includes:
- @madeye, i.e. the signer of the main repo;
- The main repo doesn't contain any other trusted signatures. Third-party forks should add their
signatures to this trusted sources if they have plugins signed by them before publishing their
source code.
* Current package signature, which means:
- If you get apk from shadowsocks-android releases or Google Play, this means only apk signed by
@madeye will be recognized as trusted.
- If you get apk from a third-party fork, all plugins from that developer will get recognized as
trusted automatically even if its source code isn't available anywhere online.
A warning will be shown for untrusted plugins. No arbitrary restrictions will be applied.
# Plugin platform versioning
In order to be able to identify compatible and incompatible plugins, [Semantic
Versioning](http://semver.org/) will be used.
>Given a version number MAJOR.MINOR.PATCH, increment the:
>
>1. MAJOR version when you make incompatible API changes,
>2. MINOR version when you add functionality in a backwards-compatible manner, and
>3. PATCH version when you make backwards-compatible bug fixes.
Plugin app must include this in their application tag: (which should be automatically included if
you are using our library)
```
<meta-data android:name="com.github.shadowsocks.plugin.version"
android:value="1.0.0"/>
```
# Plugin ID Aliasing
To implement plugin ID aliasing, you:
* MUST define meta-data `com.github.shadowsocks.plugin.id.aliases` in your plugin content provider with `android:value="alias"`,
or use `android:resources` to specify a string resource or string array resource for multiple aliases.
* MUST be able to be matched by `com.github.shadowsocks.plugin.ACTION_NATIVE_PLUGIN` when invoked on alias.
To do this, you SHOULD use multiple `intent-filter` and use a different `android:path` for each alias.
Alternatively, you MAY also use a single `intent-filter` and use `android:pathPattern` to match all your aliases at once.
You MUST NOT use `android:pathPrefix` or allow `android:pathPattern` to match undeclared plugin ID/alias as it might create a conflict with other plugins.
* SHOULD NOT add or change `intent-filter` for activities to include your aliases -- your plugin ID will always be used.
For example:
```xml
<manifest>
...
<application>
...
<provider>
...
<intent-filter>
<action android:name="com.github.shadowsocks.plugin.ACTION_NATIVE_PLUGIN"/>
<data android:scheme="plugin"
android:host="com.github.shadowsocks"
android:path="/$PLUGIN_ID"/>
</intent-filter>
<intent-filter>
<action android:name="com.github.shadowsocks.plugin.ACTION_NATIVE_PLUGIN"/>
<data android:scheme="plugin"
android:host="com.github.shadowsocks"
android:path="/$PLUGIN_ALIAS"/>
</intent-filter>
<meta-data android:name="com.github.shadowsocks.plugin.id"
android:value="$PLUGIN_ID"/>
<meta-data android:name="com.github.shadowsocks.plugin.aliases"
android:value="$PLUGIN_ALIAS"/>
...
</provider>
...
</application>
</manifest>
```
# Android TV
Android TV client does not invoke configuration activities. Therefore your plugins should automatically work with them.
@@ -0,0 +1,21 @@
GROUP=com.github.shadowsocks
VERSION_NAME=2.0.1
POM_ARTIFACT_ID=plugin
POM_NAME=Shadowsocks Plugin
POM_PACKAGING=aar
POM_DESCRIPTION=SIP003 plugin for Shadowsocks
POM_INCEPTION_YEAR=2018
POM_URL=https://github.com/shadowsocks/shadowsocks-android
POM_SCM_URL=https://github.com/shadowsocks/shadowsocks-android
POM_SCM_CONNECTION=scm:git:git://github.com/shadowsocks/shadowsocks-android.git
POM_SCM_DEV_CONNECTION=scm:git:ssh://git@github.com/shadowsocks/shadowsocks-android.git
POM_LICENCE_NAME=The GNU General Public License v3.0
POM_LICENCE_URL=https://www.gnu.org/licenses/gpl-3.0.html
POM_LICENCE_DIST=repo
POM_DEVELOPER_ID=Mygod
POM_DEVELOPER_NAME=Mygod Studio
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:theme="@style/Theme.Shadowsocks">
<meta-data android:name="com.github.shadowsocks.plugin.version"
android:value="2.0.1"/>
</application>
</manifest>
@@ -0,0 +1,68 @@
/*******************************************************************************
* *
* Copyright (C) 2019 by Max Lv <max.c.lv@gmail.com> *
* Copyright (C) 2019 by Mygod Studio <contact-shadowsocks-android@mygod.be> *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* This program is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU General Public License for more details. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
*******************************************************************************/
package com.github.shadowsocks.plugin
import android.app.Activity
import android.content.DialogInterface
import android.content.Intent
import android.os.Bundle
import android.os.Parcelable
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatDialogFragment
import androidx.fragment.app.Fragment
/**
* Based on: https://android.googlesource.com/platform/packages/apps/ExactCalculator/+/8c43f06/src/com/android/calculator2/AlertDialogFragment.java
*/
@Suppress("DEPRECATION")
@Deprecated("Related APIs are deprecated in AndroidX", ReplaceWith("fragment.AlertDialogFragment"))
abstract class AlertDialogFragment<Arg : Parcelable, Ret : Parcelable> :
AppCompatDialogFragment(), DialogInterface.OnClickListener {
companion object {
private const val KEY_ARG = "arg"
private const val KEY_RET = "ret"
fun <T : Parcelable> getRet(data: Intent) = data.extras!!.getParcelable<T>(KEY_RET)!!
}
protected abstract fun AlertDialog.Builder.prepare(listener: DialogInterface.OnClickListener)
protected val arg by lazy { requireArguments().getParcelable<Arg>(KEY_ARG)!! }
protected open fun ret(which: Int): Ret? = null
fun withArg(arg: Arg) = apply { arguments = Bundle().apply { putParcelable(KEY_ARG, arg) } }
override fun onCreateDialog(savedInstanceState: Bundle?): AlertDialog =
AlertDialog.Builder(requireContext()).also { it.prepare(this) }.create()
override fun onClick(dialog: DialogInterface?, which: Int) {
targetFragment?.onActivityResult(targetRequestCode, which, ret(which)?.let {
Intent().replaceExtras(Bundle().apply { putParcelable(KEY_RET, it) })
})
}
override fun onDismiss(dialog: DialogInterface) {
super.onDismiss(dialog)
onClick(dialog, Activity.RESULT_CANCELED)
}
fun show(target: Fragment, requestCode: Int = 0, tag: String = javaClass.simpleName) {
setTargetFragment(target, requestCode)
showAllowingStateLoss(target.fragmentManager ?: return, tag)
}
}
@@ -0,0 +1,69 @@
/*******************************************************************************
* *
* Copyright (C) 2017 by Max Lv <max.c.lv@gmail.com> *
* Copyright (C) 2017 by Mygod Studio <contact-shadowsocks-android@mygod.be> *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* This program is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU General Public License for more details. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
*******************************************************************************/
package com.github.shadowsocks.plugin
import android.app.Activity
import android.content.Intent
/**
* Base class for configuration activity. A configuration activity is started when user wishes to configure the
* selected plugin. To create a configuration activity, extend this class, implement abstract methods, invoke
* `saveChanges(options)` and `discardChanges()` when appropriate, and add it to your manifest like this:
*
* <pre class="prettyprint">&lt;manifest&gt;
* ...
* &lt;application&gt;
* ...
* &lt;activity android:name=".ConfigureActivity"&gt;
* &lt;intent-filter&gt;
* &lt;action android:name="com.github.shadowsocks.plugin.ACTION_CONFIGURE"/&gt;
* &lt;category android:name="android.intent.category.DEFAULT"/&gt;
* &lt;data android:scheme="plugin"
* android:host="com.github.shadowsocks"
* android:path="/$PLUGIN_ID"/&gt;
* &lt;/intent-filter&gt;
* &lt;/activity&gt;
* ...
* &lt;/application&gt;
*&lt;/manifest&gt;</pre>
*/
abstract class ConfigurationActivity : OptionsCapableActivity() {
/**
* Equivalent to setResult(RESULT_CANCELED).
*/
fun discardChanges() = setResult(Activity.RESULT_CANCELED)
/**
* Equivalent to setResult(RESULT_OK, args_with_correct_format).
*
* @param options PluginOptions to save.
*/
fun saveChanges(options: PluginOptions) =
setResult(Activity.RESULT_OK, Intent().putExtra(PluginContract.EXTRA_OPTIONS, options.toString()))
/**
* Finish this activity and request manual editor to pop up instead.
*/
fun fallbackToManualEditor() {
setResult(PluginContract.RESULT_FALLBACK)
finish()
}
}
@@ -0,0 +1,46 @@
/*******************************************************************************
* *
* Copyright (C) 2017 by Max Lv <max.c.lv@gmail.com> *
* Copyright (C) 2017 by Mygod Studio <contact-shadowsocks-android@mygod.be> *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* This program is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU General Public License for more details. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
*******************************************************************************/
package com.github.shadowsocks.plugin
/**
* Base class for a help activity. A help activity is started when user taps help when configuring options for your
* plugin. To create a help activity, just extend this class, and add it to your manifest like this:
*
* <pre class="prettyprint">&lt;manifest&gt;
* ...
* &lt;application&gt;
* ...
* &lt;activity android:name=".HelpActivity"&gt;
* &lt;intent-filter&gt;
* &lt;action android:name="com.github.shadowsocks.plugin.ACTION_HELP"/&gt;
* &lt;category android:name="android.intent.category.DEFAULT"/&gt;
* &lt;data android:scheme="plugin"
* android:host="com.github.shadowsocks"
* android:path="/$PLUGIN_ID"/&gt;
* &lt;/intent-filter&gt;
* &lt;/activity&gt;
* ...
* &lt;/application&gt;
*&lt;/manifest&gt;</pre>
*/
abstract class HelpActivity : OptionsCapableActivity() {
override fun onInitializePluginOptions(options: PluginOptions) { }
}
@@ -0,0 +1,37 @@
/*******************************************************************************
* *
* Copyright (C) 2017 by Max Lv <max.c.lv@gmail.com> *
* Copyright (C) 2017 by Mygod Studio <contact-shadowsocks-android@mygod.be> *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* This program is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU General Public License for more details. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
*******************************************************************************/
package com.github.shadowsocks.plugin
import android.content.Intent
/**
* HelpCallback is an HelpActivity but you just need to produce a CharSequence help message instead of having to
* provide UI. To create a help callback, just extend this class, implement abstract methods, and add it to your
* manifest following the same procedure as adding a HelpActivity.
*/
abstract class HelpCallback : HelpActivity() {
abstract fun produceHelpMessage(options: PluginOptions): CharSequence
override fun onInitializePluginOptions(options: PluginOptions) {
setResult(RESULT_OK, Intent().putExtra(PluginContract.EXTRA_HELP_MESSAGE, produceHelpMessage(options)))
finish()
}
}
@@ -0,0 +1,102 @@
/*******************************************************************************
* *
* Copyright (C) 2017 by Max Lv <max.c.lv@gmail.com> *
* Copyright (C) 2017 by Mygod Studio <contact-shadowsocks-android@mygod.be> *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* This program is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU General Public License for more details. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
*******************************************************************************/
package com.github.shadowsocks.plugin
import android.content.ContentProvider
import android.content.ContentValues
import android.database.Cursor
import android.database.MatrixCursor
import android.net.Uri
import android.os.Bundle
import android.os.ParcelFileDescriptor
import androidx.core.os.bundleOf
/**
* Base class for a native plugin provider. A native plugin provider offers read-only access to files that are required
* to run a plugin, such as binary files and other configuration files. To create a native plugin provider, extend this
* class, implement the abstract methods, and add it to your manifest like this:
*
* <pre class="prettyprint">&lt;manifest&gt;
* ...
* &lt;application&gt;
* ...
* &lt;provider android:name="com.github.shadowsocks.$PLUGIN_ID.BinaryProvider"
* android:authorities="com.github.shadowsocks.plugin.$PLUGIN_ID.BinaryProvider"&gt;
* &lt;intent-filter&gt;
* &lt;category android:name="com.github.shadowsocks.plugin.ACTION_NATIVE_PLUGIN" /&gt;
* &lt;/intent-filter&gt;
* &lt;/provider&gt;
* ...
* &lt;/application&gt;
*&lt;/manifest&gt;</pre>
*/
abstract class NativePluginProvider : ContentProvider() {
override fun getType(uri: Uri): String? = "application/x-elf"
override fun onCreate(): Boolean = true
/**
* Provide all files needed for native plugin.
*
* @param provider A helper object to use to add files.
*/
protected abstract fun populateFiles(provider: PathProvider)
override fun query(uri: Uri, projection: Array<out String>?, selection: String?, selectionArgs: Array<out String>?,
sortOrder: String?): Cursor? {
check(selection == null && selectionArgs == null && sortOrder == null)
val result = MatrixCursor(projection)
populateFiles(PathProvider(uri, result))
return result
}
/**
* Returns executable entry absolute path.
* This is used for fast mode initialization where ss-local launches your native binary at the path given directly.
* In order for this to work, plugin app is encouraged to have the following in its AndroidManifest.xml:
* - android:installLocation="internalOnly" for <manifest>
* - android:extractNativeLibs="true" for <application>
*
* Default behavior is throwing UnsupportedOperationException. If you don't wish to use this feature, use the
* default behavior.
*
* @return Absolute path for executable entry.
*/
open fun getExecutable(): String = throw UnsupportedOperationException()
abstract fun openFile(uri: Uri): ParcelFileDescriptor
override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor {
check(mode == "r")
return openFile(uri)
}
override fun call(method: String, arg: String?, extras: Bundle?): Bundle? = when (method) {
PluginContract.METHOD_GET_EXECUTABLE -> bundleOf(Pair(PluginContract.EXTRA_ENTRY, getExecutable()))
else -> super.call(method, arg, extras)
}
// Methods that should not be used
override fun insert(uri: Uri, values: ContentValues?): Uri? = throw UnsupportedOperationException()
override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<out String>?): Int =
throw UnsupportedOperationException()
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int =
throw UnsupportedOperationException()
}
@@ -0,0 +1,50 @@
/*******************************************************************************
* *
* Copyright (C) 2017 by Max Lv <max.c.lv@gmail.com> *
* Copyright (C) 2017 by Mygod Studio <contact-shadowsocks-android@mygod.be> *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* This program is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU General Public License for more details. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
*******************************************************************************/
package com.github.shadowsocks.plugin
import android.content.Intent
import android.os.Bundle
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
/**
* Activity that's capable of getting EXTRA_OPTIONS input.
*/
abstract class OptionsCapableActivity : AppCompatActivity() {
protected fun pluginOptions(intent: Intent = this.intent) = try {
PluginOptions("", intent.getStringExtra(PluginContract.EXTRA_OPTIONS))
} catch (exc: IllegalArgumentException) {
Toast.makeText(this, exc.message, Toast.LENGTH_SHORT).show()
PluginOptions()
}
/**
* Populate args to your user interface.
*
* @param options PluginOptions parsed.
*/
protected abstract fun onInitializePluginOptions(options: PluginOptions = pluginOptions())
override fun onPostCreate(savedInstanceState: Bundle?) {
super.onPostCreate(savedInstanceState)
if (savedInstanceState == null) onInitializePluginOptions()
}
}
@@ -0,0 +1,54 @@
/*******************************************************************************
* *
* Copyright (C) 2017 by Max Lv <max.c.lv@gmail.com> *
* Copyright (C) 2017 by Mygod Studio <contact-shadowsocks-android@mygod.be> *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* This program is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU General Public License for more details. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
*******************************************************************************/
package com.github.shadowsocks.plugin
import android.database.MatrixCursor
import android.net.Uri
import java.io.File
/**
* Helper class to provide relative paths of files to copy.
*/
class PathProvider internal constructor(baseUri: Uri, private val cursor: MatrixCursor) {
private val basePath = baseUri.path?.trim('/') ?: ""
fun addPath(path: String, mode: Int = 0b110100100): PathProvider {
val trimmed = path.trim('/')
if (trimmed.startsWith(basePath)) cursor.newRow()
.add(PluginContract.COLUMN_PATH, trimmed)
.add(PluginContract.COLUMN_MODE, mode)
return this
}
fun addTo(file: File, to: String = "", mode: Int = 0b110100100): PathProvider {
var sub = to + file.name
if (basePath.startsWith(sub)) if (file.isDirectory) {
sub += '/'
file.listFiles()!!.forEach { addTo(it, sub, mode) }
} else addPath(sub, mode)
return this
}
fun addAt(file: File, at: String = "", mode: Int = 0b110100100): PathProvider {
if (basePath.startsWith(at)) {
if (file.isDirectory) file.listFiles()!!.forEach { addTo(it, at, mode) } else addPath(at, mode)
}
return this
}
}
@@ -0,0 +1,149 @@
/*******************************************************************************
* *
* Copyright (C) 2017 by Max Lv <max.c.lv@gmail.com> *
* Copyright (C) 2017 by Mygod Studio <contact-shadowsocks-android@mygod.be> *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* This program is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU General Public License for more details. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
*******************************************************************************/
package com.github.shadowsocks.plugin
/**
* The contract between the plugin provider and host. Contains definitions for the supported actions, extras, etc.
*
* This class is written in Java to keep Java interoperability.
*/
object PluginContract {
/**
* ContentProvider Action: Used for NativePluginProvider.
*
* Constant Value: "com.github.shadowsocks.plugin.ACTION_NATIVE_PLUGIN"
*/
const val ACTION_NATIVE_PLUGIN = "com.github.shadowsocks.plugin.ACTION_NATIVE_PLUGIN"
/**
* Activity Action: Used for ConfigurationActivity.
*
* Constant Value: "com.github.shadowsocks.plugin.ACTION_CONFIGURE"
*/
const val ACTION_CONFIGURE = "com.github.shadowsocks.plugin.ACTION_CONFIGURE"
/**
* Activity Action: Used for HelpActivity or HelpCallback.
*
* Constant Value: "com.github.shadowsocks.plugin.ACTION_HELP"
*/
const val ACTION_HELP = "com.github.shadowsocks.plugin.ACTION_HELP"
/**
* The lookup key for a string that provides the plugin entry binary.
*
* Example: "/data/data/com.github.shadowsocks.plugin.obfs_local/lib/libobfs-local.so"
*
* Constant Value: "com.github.shadowsocks.plugin.EXTRA_ENTRY"
*/
const val EXTRA_ENTRY = "com.github.shadowsocks.plugin.EXTRA_ENTRY"
/**
* The lookup key for a string that provides the options as a string.
*
* Example: "obfs=http;obfs-host=www.baidu.com"
*
* Constant Value: "com.github.shadowsocks.plugin.EXTRA_OPTIONS"
*/
const val EXTRA_OPTIONS = "com.github.shadowsocks.plugin.EXTRA_OPTIONS"
/**
* The lookup key for a CharSequence that provides user relevant help message.
*
* Example: "obfs=<http></http>|tls> Enable obfuscating: HTTP or TLS (Experimental).
* obfs-host=<host_name> Hostname for obfuscating (Experimental)."
*
* Constant Value: "com.github.shadowsocks.plugin.EXTRA_HELP_MESSAGE"
</host_name> */
const val EXTRA_HELP_MESSAGE = "com.github.shadowsocks.plugin.EXTRA_HELP_MESSAGE"
/**
* The metadata key to retrieve plugin version. Required for plugin applications.
*
* Constant Value: "com.github.shadowsocks.plugin.version"
*/
const val METADATA_KEY_VERSION = "com.github.shadowsocks.plugin.version"
/**
* The metadata key to retrieve plugin id. Required for plugins.
*
* Constant Value: "com.github.shadowsocks.plugin.id"
*/
const val METADATA_KEY_ID = "com.github.shadowsocks.plugin.id"
/**
* The metadata key to retrieve plugin id aliases.
* Can be a string (representing one alias) or a resource to a string or string array.
*
* Constant Value: "com.github.shadowsocks.plugin.id.aliases"
*/
const val METADATA_KEY_ID_ALIASES = "com.github.shadowsocks.plugin.id.aliases"
/**
* The metadata key to retrieve default configuration. Default value is empty.
*
* Constant Value: "com.github.shadowsocks.plugin.default_config"
*/
const val METADATA_KEY_DEFAULT_CONFIG = "com.github.shadowsocks.plugin.default_config"
/**
* The metadata key to retrieve executable path to your native binary.
* This path should be relative to your application's nativeLibraryDir.
*
* If this is set, the host app will prefer this value and (probably) not launch your app at all (aka faster mode).
* In order for this to work, plugin app is encouraged to have the following in its AndroidManifest.xml:
* - android:installLocation="internalOnly" for <manifest>
* - android:extractNativeLibs="true" for <application>
*
* Do not use this if you plan to do some setup work before giving away your binary path,
* or your native binary is not at a fixed location relative to your application's nativeLibraryDir.
*
* Since plugin lib: 1.3.0
*
* Constant Value: "com.github.shadowsocks.plugin.executable_path"
*/
const val METADATA_KEY_EXECUTABLE_PATH = "com.github.shadowsocks.plugin.executable_path"
const val METHOD_GET_EXECUTABLE = "shadowsocks:getExecutable"
/** ConfigurationActivity result: fallback to manual edit mode. */
const val RESULT_FALLBACK = 1
/**
* Relative to the file to be copied. This column is required.
*
* Example: "kcptun", "doc/help.txt"
*
* Type: String
*/
const val COLUMN_PATH = "path"
/**
* File mode bits. Default value is 644 in octal.
*
* Example: 0b110100100 (for 755 in octal)
*
* Type: Int or String (deprecated)
*/
const val COLUMN_MODE = "mode"
/**
* The scheme for general plugin actions.
*/
const val SCHEME = "plugin"
/**
* The authority for general plugin actions.
*/
const val AUTHORITY = "com.github.shadowsocks"
}
@@ -0,0 +1,109 @@
/*******************************************************************************
* *
* Copyright (C) 2017 by Max Lv <max.c.lv@gmail.com> *
* Copyright (C) 2017 by Mygod Studio <contact-shadowsocks-android@mygod.be> *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* This program is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU General Public License for more details. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
*******************************************************************************/
package com.github.shadowsocks.plugin
import java.util.*
/**
* Helper class for processing plugin options.
*
* Based on: https://github.com/apache/ant/blob/588ce1f/src/main/org/apache/tools/ant/types/Commandline.java
*/
class PluginOptions : HashMap<String, String?> {
var id = ""
constructor() : super()
constructor(initialCapacity: Int) : super(initialCapacity)
constructor(initialCapacity: Int, loadFactor: Float) : super(initialCapacity, loadFactor)
private constructor(options: String?, parseId: Boolean) : this() {
@Suppress("NAME_SHADOWING")
var parseId = parseId
if (options.isNullOrEmpty()) return
check(options.all { !it.isISOControl() }) { "No control characters allowed." }
val tokenizer = StringTokenizer("$options;", "\\=;", true)
val current = StringBuilder()
var key: String? = null
while (tokenizer.hasMoreTokens()) when (val nextToken = tokenizer.nextToken()) {
"\\" -> current.append(tokenizer.nextToken())
"=" -> if (key == null) {
key = current.toString()
current.setLength(0)
} else current.append(nextToken)
";" -> {
if (key != null) {
put(key, current.toString())
key = null
} else if (current.isNotEmpty()) {
if (parseId) id = current.toString() else put(current.toString(), null)
}
current.setLength(0)
parseId = false
}
else -> current.append(nextToken)
}
}
constructor(options: String?) : this(options, true)
constructor(id: String, options: String?) : this(options, false) {
this.id = id
}
/**
* Put but if value is null or default, the entry is deleted.
*
* @return Old value before put.
*/
fun putWithDefault(key: String, value: String?, default: String? = null) =
if (value == null || value == default) remove(key) else put(key, value)
private fun append(result: StringBuilder, str: String) = str.indices.map { str[it] }.forEach {
when (it) {
'\\', '=', ';' -> {
result.append('\\') // intentionally no break
result.append(it)
}
else -> result.append(it)
}
}
fun toString(trimId: Boolean): String {
val result = StringBuilder()
if (!trimId) if (id.isEmpty()) return "" else append(result, id)
for ((key, value) in entries) {
if (result.isNotEmpty()) result.append(';')
append(result, key)
if (value != null) {
result.append('=')
append(result, value)
}
}
return result.toString()
}
override fun toString(): String = toString(true)
override fun equals(other: Any?): Boolean {
if (this === other) return true
return javaClass == other?.javaClass && super.equals(other) && id == (other as PluginOptions).id
}
override fun hashCode(): Int = Objects.hash(super.hashCode(), id)
}
@@ -0,0 +1,37 @@
/*******************************************************************************
* *
* Copyright (C) 2020 by Max Lv <max.c.lv@gmail.com> *
* Copyright (C) 2020 by Mygod Studio <contact-shadowsocks-android@mygod.be> *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* This program is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU General Public License for more details. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
*******************************************************************************/
@file:JvmName("Utils")
package com.github.shadowsocks.plugin
import android.os.Parcelable
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.FragmentManager
import kotlinx.parcelize.Parcelize
@Parcelize
class Empty : Parcelable
@JvmOverloads
@Deprecated("Moved to fragment package", ReplaceWith("fragment.showAllowingStateLoss"))
fun DialogFragment.showAllowingStateLoss(fragmentManager: FragmentManager, tag: String? = null) {
if (!fragmentManager.isStateSaved) show(fragmentManager, tag)
}
@@ -0,0 +1,79 @@
/*******************************************************************************
* *
* Copyright (C) 2020 by Max Lv <max.c.lv@gmail.com> *
* Copyright (C) 2020 by Mygod Studio <contact-shadowsocks-android@mygod.be> *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* This program is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU General Public License for more details. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
*******************************************************************************/
package com.github.shadowsocks.plugin.fragment
import android.app.Activity
import android.content.DialogInterface
import android.os.Bundle
import android.os.Parcelable
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatDialogFragment
import androidx.fragment.app.Fragment
import androidx.fragment.app.setFragmentResult
import androidx.fragment.app.setFragmentResultListener
import com.google.android.material.dialog.MaterialAlertDialogBuilder
/**
* Based on: https://android.googlesource.com/platform/packages/apps/ExactCalculator/+/8c43f06/src/com/android/calculator2/AlertDialogFragment.java
*/
abstract class AlertDialogFragment<Arg : Parcelable, Ret : Parcelable?> :
AppCompatDialogFragment(), DialogInterface.OnClickListener {
companion object {
private const val KEY_RESULT = "result"
private const val KEY_ARG = "arg"
private const val KEY_RET = "ret"
private const val KEY_WHICH = "which"
fun <Ret : Parcelable> setResultListener(fragment: Fragment, requestKey: String,
listener: (Int, Ret?) -> Unit) {
fragment.setFragmentResultListener(requestKey) { _, bundle ->
listener(bundle.getInt(KEY_WHICH, Activity.RESULT_CANCELED), bundle.getParcelable(KEY_RET))
}
}
inline fun <reified T : AlertDialogFragment<*, Ret>, Ret : Parcelable?> setResultListener(
fragment: Fragment, noinline listener: (Int, Ret?) -> Unit) =
setResultListener(fragment, T::class.java.name, listener)
}
protected abstract fun AlertDialog.Builder.prepare(listener: DialogInterface.OnClickListener)
private val resultKey get() = requireArguments().getString(KEY_RESULT)
protected val arg by lazy { requireArguments().getParcelable<Arg>(KEY_ARG)!! }
protected open fun ret(which: Int): Ret? = null
private fun args() = arguments ?: Bundle().also { arguments = it }
fun arg(arg: Arg) = args().putParcelable(KEY_ARG, arg)
fun key(resultKey: String = javaClass.name) = args().putString(KEY_RESULT, resultKey)
override fun onCreateDialog(savedInstanceState: Bundle?): AlertDialog =
MaterialAlertDialogBuilder(requireContext()).also { it.prepare(this) }.create()
override fun onClick(dialog: DialogInterface?, which: Int) {
setFragmentResult(resultKey ?: return, Bundle().apply {
putInt(KEY_WHICH, which)
putParcelable(KEY_RET, ret(which) ?: return@apply)
})
}
override fun onDismiss(dialog: DialogInterface) {
super.onDismiss(dialog)
onClick(null, Activity.RESULT_CANCELED)
}
}
@@ -0,0 +1,33 @@
/*******************************************************************************
* *
* Copyright (C) 2020 by Max Lv <max.c.lv@gmail.com> *
* Copyright (C) 2020 by Mygod Studio <contact-shadowsocks-android@mygod.be> *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* This program is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU General Public License for more details. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
*******************************************************************************/
@file:JvmName("Utils")
package com.github.shadowsocks.plugin.fragment
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.FragmentManager
typealias Empty = com.github.shadowsocks.plugin.Empty
@JvmOverloads
fun DialogFragment.showAllowingStateLoss(fragmentManager: FragmentManager, tag: String? = null) {
if (!fragmentManager.isStateSaved) show(fragmentManager, tag)
}
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Source: https://github.com/material-components/material-components-android/blob/2de39fafe0285aab7e6e101549c4bc93f184a7e5/lib/java/com/google/android/material/button/res/color/mtrl_text_btn_text_color_selector.xml
Copyright 2017 The Android Open Source Project
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:alpha="1.00" android:color="@color/color_primary_text" android:state_checkable="true" android:state_checked="true" android:state_enabled="true"/>
<item android:alpha="0.60" android:color="?attr/colorOnSurface" android:state_checkable="true" android:state_checked="false" android:state_enabled="true"/>
<item android:alpha="1.00" android:color="@color/color_primary_text" android:state_enabled="true"/>
<item android:alpha="0.38" android:color="?attr/colorOnSurface"/>
</selector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M19,6.41L17.59,5 12,10.59 6.41,5 5,6.41 10.59,12 5,17.59 6.41,19 12,13.41 17.59,19 19,17.59 13.41,12z"/>
</vector>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.appbar.MaterialToolbar
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_height="?attr/actionBarSize"
android:layout_width="match_parent"
android:background="?attr/colorPrimary"
android:elevation="4dp"
android:touchscreenBlocksFocus="false"
android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"
app:popupTheme="@style/ThemeOverlay.AppCompat.DayNight"
android:id="@+id/toolbar" />
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright (C) 2019 by Max Lv <max.c.lv@gmail.com>
Copyright (C) 2019 by Mygod Studio <contact-shadowsocks-android@mygod.be>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
DO NOT EDIT: This file was automagically generated by script.
If you are looking for contributing a translation, read this:
https://discourse.shadowsocks.org/t/poeditor-translation-main-thread/30
-->
<resources>
<string name="proxy_cat">"Servereinstellungen"</string>
<string name="feature_cat">"Funktionseinstellungen"</string>
<string name="unsaved_changes_prompt">"Änderungen nicht gespeichert. Speichern?"</string>
<string name="yes">"Ja"</string>
<string name="no">"Nein"</string>
<string name="apply">"Anwenden"</string>
<string name="file_manager_missing">"Bitte installiere einen Dateimanager, z.B. MiXplorer"</string>
<string name="browse">"Durchsuchen..."</string>
</resources>
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright (C) 2019 by Max Lv <max.c.lv@gmail.com>
Copyright (C) 2019 by Mygod Studio <contact-shadowsocks-android@mygod.be>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
DO NOT EDIT: This file was automagically generated by script.
If you are looking for contributing a translation, read this:
https://discourse.shadowsocks.org/t/poeditor-translation-main-thread/30
-->
<resources>
<string name="proxy_cat">"Propiedades del Servidor"</string>
<string name="yes">"Sí"</string>
<string name="no">"No"</string>
<string name="apply">"Aplicar"</string>
<string name="file_manager_missing">"Por favor, instala un explorador de archivos como MiXplorer"</string>
</resources>
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright (C) 2019 by Max Lv <max.c.lv@gmail.com>
Copyright (C) 2019 by Mygod Studio <contact-shadowsocks-android@mygod.be>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
DO NOT EDIT: This file was automagically generated by script.
If you are looking for contributing a translation, read this:
https://discourse.shadowsocks.org/t/poeditor-translation-main-thread/30
-->
<resources>
<string name="proxy_cat">"تنظیمات سرور"</string>
<string name="feature_cat">"تنظیمات ویژگی‌ها"</string>
<string name="unsaved_changes_prompt">"تغییرات ذخیره نشده‌اند. ذخیره شوند؟"</string>
<string name="yes">"بله"</string>
<string name="no">"خیر"</string>
<string name="apply">"تایید"</string>
<string name="file_manager_missing">"لطفاً یک فایل منیجر مانند MiXplorer نصب کنید"</string>
<string name="browse">"مرور کردن..."</string>
</resources>
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright (C) 2019 by Max Lv <max.c.lv@gmail.com>
Copyright (C) 2019 by Mygod Studio <contact-shadowsocks-android@mygod.be>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
DO NOT EDIT: This file was automagically generated by script.
If you are looking for contributing a translation, read this:
https://discourse.shadowsocks.org/t/poeditor-translation-main-thread/30
-->
<resources>
<string name="proxy_cat">"Paramètres du Serveur"</string>
<string name="feature_cat">"Paramètres des Fonctionnalités"</string>
<string name="unsaved_changes_prompt">"Changements non enregistrés. Voulez-vous enregistrer ?"</string>
<string name="yes">"Oui"</string>
<string name="no">"Non"</string>
<string name="apply">"Appliquer"</string>
<string name="file_manager_missing">"Veuillez installer un gestionnaire de fichier tel que MiXplorer"</string>
</resources>
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright (C) 2019 by Max Lv <max.c.lv@gmail.com>
Copyright (C) 2019 by Mygod Studio <contact-shadowsocks-android@mygod.be>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
DO NOT EDIT: This file was automagically generated by script.
If you are looking for contributing a translation, read this:
https://discourse.shadowsocks.org/t/poeditor-translation-main-thread/30
-->
<resources>
<string name="proxy_cat">"サーバー設定"</string>
<string name="feature_cat">"機能設定"</string>
<string name="unsaved_changes_prompt">"変更は保存されておりません、保存しますか?"</string>
<string name="yes">"はい"</string>
<string name="no">"いいえ"</string>
<string name="apply">"適応"</string>
<string name="file_manager_missing">"ファイルマネージャーをインストールしてください(MiXplorerなど)"</string>
<string name="browse">"参照…"</string>
</resources>
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright (C) 2019 by Max Lv <max.c.lv@gmail.com>
Copyright (C) 2019 by Mygod Studio <contact-shadowsocks-android@mygod.be>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
DO NOT EDIT: This file was automagically generated by script.
If you are looking for contributing a translation, read this:
https://discourse.shadowsocks.org/t/poeditor-translation-main-thread/30
-->
<resources>
<string name="proxy_cat">"서버 설정"</string>
<string name="feature_cat">"기능 설정"</string>
<string name="unsaved_changes_prompt">"변경 사항이 저장되지 않았습니다. 저장하시겠습니까?"</string>
<string name="yes">"예"</string>
<string name="no">"아니오"</string>
<string name="apply">"적용"</string>
</resources>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="color_primary">@color/dark_color_primary</color>
<color name="color_primary_dark">@color/dark_color_primary_dark</color>
<color name="color_primary_text">@color/dark_color_primary_text</color>
</resources>
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright (C) 2019 by Max Lv <max.c.lv@gmail.com>
Copyright (C) 2019 by Mygod Studio <contact-shadowsocks-android@mygod.be>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
DO NOT EDIT: This file was automagically generated by script.
If you are looking for contributing a translation, read this:
https://discourse.shadowsocks.org/t/poeditor-translation-main-thread/30
-->
<resources>
<string name="proxy_cat">"Настройки сервера"</string>
<string name="feature_cat">"Функции"</string>
<string name="unsaved_changes_prompt">"Сохранить изменения?"</string>
<string name="yes">"Да"</string>
<string name="no">"Нет"</string>
<string name="apply">"Применить"</string>
<string name="file_manager_missing">"Установите файловый менеджер (например, MiXplorer)"</string>
<string name="browse">"Открыть…"</string>
</resources>
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright (C) 2019 by Max Lv <max.c.lv@gmail.com>
Copyright (C) 2019 by Mygod Studio <contact-shadowsocks-android@mygod.be>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
DO NOT EDIT: This file was automagically generated by script.
If you are looking for contributing a translation, read this:
https://discourse.shadowsocks.org/t/poeditor-translation-main-thread/30
-->
<resources>
<string name="proxy_cat">"Sunucu Ayarları"</string>
<string name="feature_cat">"Özellik Ayarları"</string>
<string name="unsaved_changes_prompt">"Değişiklikler kaydedilmedi. Kaydetmek ister misiniz?"</string>
<string name="yes">"Evet"</string>
<string name="no">"Hayır"</string>
<string name="apply">"Uygula"</string>
</resources>
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright (C) 2019 by Max Lv <max.c.lv@gmail.com>
Copyright (C) 2019 by Mygod Studio <contact-shadowsocks-android@mygod.be>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
DO NOT EDIT: This file was automagically generated by script.
If you are looking for contributing a translation, read this:
https://discourse.shadowsocks.org/t/poeditor-translation-main-thread/30
-->
<resources>
<string name="proxy_cat">"Налаштування сервера"</string>
<string name="feature_cat">"Налаштування функцій"</string>
<string name="unsaved_changes_prompt">"Зміни не збережено. Зберегти?"</string>
<string name="yes">"Так"</string>
<string name="no">"Ні"</string>
<string name="apply">"Застосувати"</string>
<string name="file_manager_missing">"Будь ласка, встановіть менеджер файлів, наприклад, MiXplorer"</string>
</resources>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Shadowsocks.Immersive">
<item name="android:navigationBarColor">@android:color/transparent</item>
</style>
</resources>
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright (C) 2019 by Max Lv <max.c.lv@gmail.com>
Copyright (C) 2019 by Mygod Studio <contact-shadowsocks-android@mygod.be>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
DO NOT EDIT: This file was automagically generated by script.
If you are looking for contributing a translation, read this:
https://discourse.shadowsocks.org/t/poeditor-translation-main-thread/30
-->
<resources>
<string name="proxy_cat">"服务器设置"</string>
<string name="feature_cat">"功能设置"</string>
<string name="unsaved_changes_prompt">"是否要保存修改?"</string>
<string name="yes">"是"</string>
<string name="no">"否"</string>
<string name="apply">"应用"</string>
<string name="file_manager_missing">"请安装文件管理器,如 MiXplorer"</string>
<string name="browse">"浏览…"</string>
</resources>
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright (C) 2019 by Max Lv <max.c.lv@gmail.com>
Copyright (C) 2019 by Mygod Studio <contact-shadowsocks-android@mygod.be>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
DO NOT EDIT: This file was automagically generated by script.
If you are looking for contributing a translation, read this:
https://discourse.shadowsocks.org/t/poeditor-translation-main-thread/30
-->
<resources>
<string name="proxy_cat">"伺服器設定"</string>
<string name="feature_cat">"功能設定"</string>
<string name="unsaved_changes_prompt">"要儲存變更嗎?"</string>
<string name="yes">"是"</string>
<string name="no">"否"</string>
<string name="apply">"套用"</string>
<string name="file_manager_missing">"請安裝文件管理器,如 MiXplorer"</string>
<string name="browse">"瀏覽…"</string>
</resources>
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="material_green_700">#388E3C</color>
<color name="material_green_a700">#00C853</color>
<color name="material_blue_grey_100">#CFD8DC</color>
<color name="material_blue_grey_300">#90A4AE</color>
<color name="material_blue_grey_500">#607D8B</color>
<color name="material_blue_grey_600">#546E7A</color>
<color name="material_blue_grey_700">#455A64</color>
<color name="material_primary_100">@color/material_blue_grey_100</color>
<color name="material_primary_300">@color/material_blue_grey_300</color>
<color name="material_primary_500">@color/material_blue_grey_500</color>
<color name="material_primary_600">@color/material_blue_grey_600</color>
<color name="material_primary_700">@color/material_blue_grey_700</color>
<color name="material_primary_800">@color/material_blue_grey_800</color>
<color name="material_primary_900">@color/material_blue_grey_900</color>
<color name="material_accent_200">@color/material_green_a700</color>
<color name="light_color_primary">@color/material_primary_500</color>
<color name="light_color_primary_dark">@color/material_primary_700</color>
<color name="light_color_primary_text">@color/material_primary_500</color>
<color name="dark_color_primary">@color/material_primary_800</color>
<color name="dark_color_primary_dark">@color/material_primary_900</color>
<color name="dark_color_primary_text">@color/material_primary_300</color>
<color name="color_primary">@color/light_color_primary</color>
<color name="color_primary_dark">@color/light_color_primary_dark</color>
<color name="color_primary_text">@color/light_color_primary_text</color>
</resources>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="proxy_cat">Server Settings</string>
<string name="feature_cat">Feature Settings</string>
<string name="unsaved_changes_prompt">Changes not saved. Do you want to save?</string>
<string name="yes">Yes</string>
<string name="no">No</string>
<string name="apply">Apply</string>
<string name="browse">Browse…</string>
<string name="file_manager_missing">Please install a file manager like MiXplorer</string>
</resources>
@@ -0,0 +1,50 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Shadowsocks" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<item name="android:navigationBarColor">@color/color_primary_dark</item>
<item name="actionBarStyle">@style/Widget.MaterialComponents.Light.ActionBar.Solid</item>
<item name="actionModeCloseDrawable">@drawable/ic_navigation_close</item>
<item name="colorAccent">@color/material_accent_200</item>
<item name="colorButtonNormal">@color/material_accent_200</item>
<item name="colorPrimary">@color/color_primary</item>
<item name="colorPrimaryDark">@color/color_primary_dark</item>
<item name="windowActionModeOverlay">true</item>
<!-- Remove ActionBar but keep styles and themes -->
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
</style>
<style name="Theme.Shadowsocks.ActionBar">
<item name="windowActionBar">true</item>
<item name="windowNoTitle">false</item>
</style>
<style name="Theme.Shadowsocks.Immersive">
<item name="android:navigationBarColor">#6000</item>
</style>
<style name="Theme.Shadowsocks.Translucent" parent="Theme.MaterialComponents.Dialog">
<item name="android:windowBackground">@android:color/transparent</item>
<item name="android:windowFrame">@null</item>
<item name="android:windowContentOverlay">@null</item>
<item name="android:windowAnimationStyle">@null</item>
<item name="android:backgroundDimEnabled">false</item>
<item name="android:windowIsTranslucent">true</item>
<item name="android:windowNoTitle">true</item>
<item name="android:windowCloseOnTouchOutside">false</item>
<item name="colorAccent">@color/material_accent_200</item>
<item name="colorButtonNormal">@color/material_accent_200</item>
<item name="colorPrimary">@color/color_primary</item>
<item name="colorPrimaryDark">@color/color_primary_dark</item>
<item name="windowNoTitle">true</item>
</style>
<style name="Theme.AppCompat.Translucent" parent="Theme.AppCompat.Dialog">
<item name="android:windowBackground">@android:color/transparent</item>
<item name="android:windowFrame">@null</item>
<item name="android:windowContentOverlay">@null</item>
<item name="android:windowAnimationStyle">@null</item>
<item name="android:backgroundDimEnabled">false</item>
<item name="android:windowIsTranslucent">true</item>
<item name="android:windowNoTitle">true</item>
<item name="android:windowCloseOnTouchOutside">false</item>
<item name="windowNoTitle">true</item>
</style>
</resources>
@@ -0,0 +1,59 @@
/*******************************************************************************
* *
* Copyright (C) 2017 by Max Lv <max.c.lv@gmail.com> *
* Copyright (C) 2017 by Mygod Studio <contact-shadowsocks-android@mygod.be> *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* This program is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU General Public License for more details. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
*******************************************************************************/
package com.github.shadowsocks.plugin
import org.junit.Assert
import org.junit.Test
class PluginOptionsTest {
@Test
fun basic() {
val o1 = PluginOptions("obfs-local;obfs=http;obfs-host=localhost")
val o2 = PluginOptions("obfs-local", "obfs-host=localhost;obfs=http")
Assert.assertEquals(o1.hashCode(), o2.hashCode())
Assert.assertEquals(true, o1 == o2)
val o3 = PluginOptions(o1.toString(false))
Assert.assertEquals(true, o2 == o3)
val o4 = PluginOptions(o2.id, o2.toString())
Assert.assertEquals(true, o3 == o4)
}
@Test
fun nullValues() {
val o = PluginOptions("", "a;b;c;d=3")
Assert.assertEquals(true, o == PluginOptions("", o.toString()))
}
@Test
fun escape() {
val options = PluginOptions("escapeTest")
options["subject"] = "value;semicolon"
Assert.assertEquals(true, options == PluginOptions(options.toString(false)))
options["key;semicolon"] = "object"
Assert.assertEquals(true, options == PluginOptions(options.toString(false)))
options["subject"] = "value=equals"
Assert.assertEquals(true, options == PluginOptions(options.toString(false)))
options["key=equals"] = "object"
Assert.assertEquals(true, options == PluginOptions(options.toString(false)))
options["advanced\\=;test"] = "in;=\\progress"
Assert.assertEquals(true, options == PluginOptions(options.toString(false)))
}
}
@@ -11,8 +11,19 @@
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE"/> <uses-permission android:name="android.permission.CHANGE_NETWORK_STATE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/> <uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/> <uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28"/>
<uses-permission android:name="com.google.android.gms.permission.AD_ID" tools:node="remove"/>
<queries>
<intent>
<action android:name="com.github.shadowsocks.plugin.ACTION_NATIVE_PLUGIN" />
</intent>
</queries>
<application <application
@@ -76,6 +87,7 @@
</intent-filter> </intent-filter>
</receiver> </receiver>
<!-- https://android.googlesource.com/platform/frameworks/support/+/androidx-master-dev/work/workmanager/src/main/AndroidManifest.xml --> <!-- https://android.googlesource.com/platform/frameworks/support/+/androidx-master-dev/work/workmanager/src/main/AndroidManifest.xml -->
<provider <provider
android:name="androidx.work.impl.WorkManagerInitializer" android:name="androidx.work.impl.WorkManagerInitializer"
@@ -128,5 +140,18 @@
android:directBootAware="true" android:directBootAware="true"
android:process=":QtOnlyProcess" android:process=":QtOnlyProcess"
tools:replace="android:directBootAware" /> tools:replace="android:directBootAware" />
<service android:name="com.google.firebase.components.ComponentDiscoveryService"
android:process=":QtOnlyProcess"
android:directBootAware="true"/>
<provider android:name="com.google.firebase.provider.FirebaseInitProvider"
android:process=":QtOnlyProcess"
tools:node="remove"/>
<service android:name="androidx.room.MultiInstanceInvalidationService"
android:process=":bg"/>
</application> </application>
</manifest> </manifest>
@@ -25,27 +25,31 @@ import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Build
import org.amnezia.vpn.shadowsocks.core.Core.app import org.amnezia.vpn.shadowsocks.core.Core.app
import org.amnezia.vpn.shadowsocks.core.preference.DataStore import org.amnezia.vpn.shadowsocks.core.preference.DataStore
class BootReceiver : BroadcastReceiver() { class BootReceiver : BroadcastReceiver() {
companion object { companion object {
private val componentName by lazy { ComponentName(app, org.amnezia.vpn.shadowsocks.core.BootReceiver::class.java) } private val componentName by lazy { ComponentName(app, BootReceiver::class.java) }
var enabled: Boolean var enabled: Boolean
get() = app.packageManager.getComponentEnabledSetting(org.amnezia.vpn.shadowsocks.core.BootReceiver.Companion.componentName) == get() = app.packageManager.getComponentEnabledSetting(componentName) ==
PackageManager.COMPONENT_ENABLED_STATE_ENABLED PackageManager.COMPONENT_ENABLED_STATE_ENABLED
set(value) = app.packageManager.setComponentEnabledSetting( set(value) = app.packageManager.setComponentEnabledSetting(componentName,
org.amnezia.vpn.shadowsocks.core.BootReceiver.Companion.componentName,
if (value) PackageManager.COMPONENT_ENABLED_STATE_ENABLED if (value) PackageManager.COMPONENT_ENABLED_STATE_ENABLED
else PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP) else PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP)
} }
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
val locked = when (intent.action) { if (!DataStore.persistAcrossReboot) { // sanity check
Intent.ACTION_BOOT_COMPLETED -> false enabled = false
Intent.ACTION_LOCKED_BOOT_COMPLETED -> true // constant will be folded so no need to do version checks return
else -> return
} }
if (DataStore.directBootAware == locked) org.amnezia.vpn.shadowsocks.core.Core.startService() val doStart = when (intent.action) {
Intent.ACTION_BOOT_COMPLETED -> !DataStore.directBootAware
Intent.ACTION_LOCKED_BOOT_COMPLETED -> DataStore.directBootAware
else -> DataStore.directBootAware || Build.VERSION.SDK_INT >= 24 && Core.user.isUserUnlocked
}
if (doStart) Core.startService()
} }
} }
@@ -20,59 +20,71 @@
package org.amnezia.vpn.shadowsocks.core package org.amnezia.vpn.shadowsocks.core
import android.app.Application import android.app.*
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.admin.DevicePolicyManager import android.app.admin.DevicePolicyManager
import android.content.BroadcastReceiver import android.content.*
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageInfo import android.content.pm.PackageInfo
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.net.ConnectivityManager
import android.os.Build import android.os.Build
import android.os.UserManager import android.os.UserManager
import android.util.Log
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.annotation.VisibleForTesting
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.getSystemService import androidx.core.content.getSystemService
import androidx.core.os.persistableBundleOf
import androidx.work.Configuration import androidx.work.Configuration
import androidx.work.WorkManager
import org.amnezia.vpn.shadowsocks.core.acl.Acl import org.amnezia.vpn.shadowsocks.core.acl.Acl
import org.amnezia.vpn.shadowsocks.core.aidl.ShadowsocksConnection import org.amnezia.vpn.shadowsocks.core.aidl.ShadowsocksConnection
import org.amnezia.vpn.shadowsocks.core.BuildConfig
import org.amnezia.vpn.shadowsocks.core.R
import org.amnezia.vpn.shadowsocks.core.database.Profile import org.amnezia.vpn.shadowsocks.core.database.Profile
import org.amnezia.vpn.shadowsocks.core.database.ProfileManager import org.amnezia.vpn.shadowsocks.core.database.ProfileManager
import org.amnezia.vpn.shadowsocks.core.net.TcpFastOpen
import org.amnezia.vpn.shadowsocks.core.preference.DataStore import org.amnezia.vpn.shadowsocks.core.preference.DataStore
import org.amnezia.vpn.shadowsocks.core.utils.* import org.amnezia.vpn.shadowsocks.core.subscription.SubscriptionService
import org.amnezia.vpn.shadowsocks.core.utils.Action
import org.amnezia.vpn.shadowsocks.core.utils.DeviceStorageApp
import org.amnezia.vpn.shadowsocks.core.utils.DirectBoot
import org.amnezia.vpn.shadowsocks.core.utils.Key
import com.google.firebase.crashlytics.FirebaseCrashlytics
import com.google.firebase.ktx.Firebase
import com.google.firebase.ktx.initialize
import kotlinx.coroutines.DEBUG_PROPERTY_NAME import kotlinx.coroutines.DEBUG_PROPERTY_NAME
import kotlinx.coroutines.DEBUG_PROPERTY_VALUE_ON import kotlinx.coroutines.DEBUG_PROPERTY_VALUE_ON
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import timber.log.Timber
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
import kotlin.reflect.KClass import kotlin.reflect.KClass
object Core { object Core : Configuration.Provider {
const val TAG = "Core"
lateinit var app: Application lateinit var app: Application
@VisibleForTesting set
lateinit var configureIntent: (Context) -> PendingIntent lateinit var configureIntent: (Context) -> PendingIntent
val activity by lazy { app.getSystemService<ActivityManager>()!! }
val clipboard by lazy { app.getSystemService<ClipboardManager>()!! }
val connectivity by lazy { app.getSystemService<ConnectivityManager>()!! }
val notification by lazy { app.getSystemService<NotificationManager>()!! }
val user by lazy { app.getSystemService<UserManager>()!! }
val packageInfo: PackageInfo by lazy { getPackageInfo(app.packageName) } val packageInfo: PackageInfo by lazy { getPackageInfo(app.packageName) }
val deviceStorage by lazy { if (Build.VERSION.SDK_INT < 24) app else DeviceStorageApp(app) } val deviceStorage by lazy { if (Build.VERSION.SDK_INT < 24) app else DeviceStorageApp(app) }
val directBootSupported by lazy { val directBootSupported by lazy {
Build.VERSION.SDK_INT >= 24 && app.getSystemService<DevicePolicyManager>()?.storageEncryptionStatus == Build.VERSION.SDK_INT >= 24 && try {
app.getSystemService<DevicePolicyManager>()?.storageEncryptionStatus ==
DevicePolicyManager.ENCRYPTION_STATUS_ACTIVE_PER_USER DevicePolicyManager.ENCRYPTION_STATUS_ACTIVE_PER_USER
} catch (_: RuntimeException) {
false
}
} }
val activeProfileIds val activeProfileIds get() = ProfileManager.getProfile(DataStore.profileId).let {
get() = ProfileManager.getProfile(DataStore.profileId).let {
if (it == null) emptyList() else listOfNotNull(it.id, it.udpFallback) if (it == null) emptyList() else listOfNotNull(it.id, it.udpFallback)
} }
val currentProfile: Pair<Profile, Profile?>? val currentProfile: ProfileManager.ExpandedProfile? get() {
get() {
if (DataStore.directBootAware) DirectBoot.getDeviceProfile()?.apply { return this } if (DataStore.directBootAware) DirectBoot.getDeviceProfile()?.apply { return this }
return ProfileManager.expand(ProfileManager.getProfile(DataStore.profileId) return ProfileManager.expand(ProfileManager.getProfile(DataStore.profileId) ?: return null)
?: return null)
} }
fun switchProfile(id: Long): Profile { fun switchProfile(id: Long): Profile {
@@ -82,78 +94,93 @@ object Core {
} }
fun init(app: Application, configureClass: KClass<out Any>) { fun init(app: Application, configureClass: KClass<out Any>) {
Core.app = app this.app = app
configureIntent = { this.configureIntent = {
PendingIntent.getActivity(it, 0, PendingIntent.getActivity(it, 0, Intent(it, configureClass.java)
Intent(it, configureClass.java).setFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT), .setFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT), PendingIntent.FLAG_IMMUTABLE)
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
} }
if (Build.VERSION.SDK_INT >= 24) { // migrate old files if (Build.VERSION.SDK_INT >= 24) { // migrate old files
deviceStorage.moveDatabaseFrom(app, Key.DB_PUBLIC) deviceStorage.moveDatabaseFrom(app, Key.DB_PUBLIC)
val old = Acl.getFile(Acl.CUSTOM_RULES, app) val old = Acl.getFile(Acl.CUSTOM_RULES_USER, app)
if (old.canRead()) { if (old.canRead()) {
Acl.getFile(Acl.CUSTOM_RULES).writeText(old.readText()) Acl.getFile(Acl.CUSTOM_RULES_USER).writeText(old.readText())
old.delete() old.delete()
} }
} }
// overhead of debug mode is minimal: https://github.com/Kotlin/kotlinx.coroutines/blob/f528898/docs/debugging.md#debug-mode // 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) System.setProperty(DEBUG_PROPERTY_NAME, DEBUG_PROPERTY_VALUE_ON)
/* Firebase.initialize(deviceStorage) // multiple processes needs manual set-up
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)
FirebaseCrashlytics.getInstance().log("${"XXVDIWEF".getOrElse(priority) { 'X' }}/$tag: $message")
} else {
if (priority >= Log.WARN || priority == Log.DEBUG) Log.println(priority, tag, message)
if (priority >= Log.INFO) FirebaseCrashlytics.getInstance().recordException(t)
}
}
})*/
// handle data restored/crash // handle data restored/crash
if (Build.VERSION.SDK_INT >= 24 && DataStore.directBootAware && if (Build.VERSION.SDK_INT >= 24 && DataStore.directBootAware && user.isUserUnlocked) {
app.getSystemService<UserManager>()?.isUserUnlocked == true) DirectBoot.flushTrafficStats() DirectBoot.flushTrafficStats()
if (DataStore.tcpFastOpen && !TcpFastOpen.sendEnabled) TcpFastOpen.enableTimeout() }
if (DataStore.publicStore.getLong(Key.assetUpdateTime, -1) != packageInfo.lastUpdateTime) { if (DataStore.publicStore.getLong(Key.assetUpdateTime, -1) != packageInfo.lastUpdateTime) {
val assetManager = app.assets val assetManager = app.assets
try { try {
for (file in assetManager.list("acl")!!) assetManager.open("acl/$file").use { input -> for (file in assetManager.list("acl")!!) assetManager.open("acl/$file").use { input ->
File(ContextCompat.getNoBackupFilesDir(deviceStorage), file).outputStream().use { output -> input.copyTo(output) } File(deviceStorage.noBackupFilesDir, file).outputStream().use { output -> input.copyTo(output) }
} }
} catch (e: IOException) { } catch (e: IOException) {
printLog(e) Timber.w(e)
} }
DataStore.publicStore.putLong(Key.assetUpdateTime, packageInfo.lastUpdateTime) DataStore.publicStore.putLong(Key.assetUpdateTime, packageInfo.lastUpdateTime)
} }
updateNotificationChannels() updateNotificationChannels()
} }
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()
fun updateNotificationChannels() { fun updateNotificationChannels() {
if (Build.VERSION.SDK_INT >= 26) @RequiresApi(26) { /* if (Build.VERSION.SDK_INT >= 26) @RequiresApi(26) {
val nm = app.getSystemService<NotificationManager>()!! notification.createNotificationChannels(listOf(
nm.createNotificationChannels(listOf(
NotificationChannel("service-vpn", app.getText(R.string.service_vpn), NotificationChannel("service-vpn", app.getText(R.string.service_vpn),
NotificationManager.IMPORTANCE_LOW), if (Build.VERSION.SDK_INT >= 28) NotificationManager.IMPORTANCE_MIN
else NotificationManager.IMPORTANCE_LOW), // #1355
NotificationChannel("service-proxy", app.getText(R.string.service_proxy), NotificationChannel("service-proxy", app.getText(R.string.service_proxy),
NotificationManager.IMPORTANCE_LOW), NotificationManager.IMPORTANCE_LOW),
NotificationChannel("service-transproxy", app.getText(R.string.service_transproxy), NotificationChannel("service-transproxy", app.getText(R.string.service_transproxy),
NotificationManager.IMPORTANCE_LOW))) NotificationManager.IMPORTANCE_LOW),
nm.deleteNotificationChannel("service-nat") // NAT mode is gone for good SubscriptionService.notificationChannel))
} notification.deleteNotificationChannel("service-nat") // NAT mode is gone for good
} */
} }
fun getPackageInfo(packageName: String) = app.packageManager.getPackageInfo(packageName, fun getPackageInfo(packageName: String) = app.packageManager.getPackageInfo(packageName,
if (Build.VERSION.SDK_INT >= 28) PackageManager.GET_SIGNING_CERTIFICATES if (Build.VERSION.SDK_INT >= 28) PackageManager.GET_SIGNING_CERTIFICATES
else @Suppress("DEPRECATION") PackageManager.GET_SIGNATURES)!! else @Suppress("DEPRECATION") PackageManager.GET_SIGNATURES)!!
fun startService() = ContextCompat.startForegroundService(app, Intent(app, ShadowsocksConnection.serviceClass)) fun trySetPrimaryClip(clip: String, isSensitive: Boolean = false) = try {
fun reloadService() = app.sendBroadcast(Intent(Action.RELOAD)) clipboard.setPrimaryClip(ClipData.newPlainText(null, clip).apply {
fun stopService() = app.sendBroadcast(Intent(Action.CLOSE)) if (isSensitive && Build.VERSION.SDK_INT >= 24) {
//description.extras = persistableBundleOf(ClipDescription.EXTRA_IS_SENSITIVE to true)
fun listenForPackageChanges(onetime: Boolean = true, callback: () -> Unit) = object : BroadcastReceiver() { }
init {
app.registerReceiver(this, IntentFilter().apply {
addAction(Intent.ACTION_PACKAGE_ADDED)
addAction(Intent.ACTION_PACKAGE_REMOVED)
addDataScheme("package")
}) })
true
} catch (e: RuntimeException) {
Timber.d(e)
false
} }
override fun onReceive(context: Context, intent: Intent) { fun startService() = ContextCompat.startForegroundService(app, Intent(app, ShadowsocksConnection.serviceClass))
if (intent.getBooleanExtra(Intent.EXTRA_REPLACING, false)) return fun reloadService() = app.sendBroadcast(Intent(Action.RELOAD).setPackage(app.packageName))
callback() fun stopService() = app.sendBroadcast(Intent(Action.CLOSE).setPackage(app.packageName))
if (onetime) app.unregisterReceiver(this)
}
}
} }
@@ -0,0 +1,76 @@
/*******************************************************************************
* *
* Copyright (C) 2019 by Max Lv <max.c.lv@gmail.com> *
* Copyright (C) 2019 by Mygod Studio <contact-shadowsocks-android@mygod.be> *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* This program is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU General Public License for more details. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
*******************************************************************************/
package org.amnezia.vpn.shadowsocks.core
import android.content.DialogInterface
import android.os.Bundle
import android.os.Parcelable
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import org.amnezia.vpn.shadowsocks.core.R
import org.amnezia.vpn.shadowsocks.core.database.Profile
import org.amnezia.vpn.shadowsocks.core.database.ProfileManager
import org.amnezia.vpn.shadowsocks.plugin.fragment.AlertDialogFragment
import org.amnezia.vpn.shadowsocks.plugin.fragment.Empty
import org.amnezia.vpn.shadowsocks.plugin.fragment.showAllowingStateLoss
import kotlinx.parcelize.Parcelize
class UrlImportActivity : AppCompatActivity() {
@Parcelize
data class ProfilesArg(val profiles: List<Profile>) : Parcelable
class ImportProfilesDialogFragment : AlertDialogFragment<ProfilesArg, Empty>() {
override fun AlertDialog.Builder.prepare(listener: DialogInterface.OnClickListener) {
setTitle(R.string.add_profile_dialog)
setPositiveButton(R.string.yes, listener)
setNegativeButton(R.string.no, listener)
setMessage(arg.profiles.joinToString("\n"))
}
override fun onClick(dialog: DialogInterface?, which: Int) {
if (which == DialogInterface.BUTTON_POSITIVE) arg.profiles.forEach { ProfileManager.createProfile(it) }
requireActivity().finish()
}
override fun onDismiss(dialog: DialogInterface) {
requireActivity().finish()
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
when (val dialog = handleShareIntent()) {
null -> {
Toast.makeText(this, R.string.profile_invalid_input, Toast.LENGTH_SHORT).show()
finish()
}
else -> dialog.showAllowingStateLoss(supportFragmentManager)
}
}
private fun handleShareIntent() = intent.data?.toString()?.let { sharedStr ->
val profiles = Profile.findAllUrls(sharedStr, Core.currentProfile?.main).toList()
if (profiles.isEmpty()) null else ImportProfilesDialogFragment().apply {
arg(ProfilesArg(profiles))
key()
}
}
}
@@ -18,8 +18,7 @@ class VpnManager private constructor() {
var state = BaseService.State.Idle var state = BaseService.State.Idle
private var context: Context? = null private var context: Context? = null
private val handler = Handler() private val connection = ShadowsocksConnection(true)
private val connection = ShadowsocksConnection(handler, true)
private var listener: OnStatusChangeListener? = null private var listener: OnStatusChangeListener? = null
private val callback: ShadowsocksConnection.Callback = object : ShadowsocksConnection.Callback { private val callback: ShadowsocksConnection.Callback = object : ShadowsocksConnection.Callback {
override fun stateChanged(state: BaseService.State, profileName: String?, msg: String?) { override fun stateChanged(state: BaseService.State, profileName: String?, msg: String?) {
@@ -24,22 +24,17 @@ import android.app.KeyguardManager
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.net.VpnService
import android.os.Bundle import android.os.Bundle
import android.widget.Toast import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.getSystemService import androidx.core.content.getSystemService
import org.amnezia.vpn.shadowsocks.core.R
import org.amnezia.vpn.shadowsocks.core.preference.DataStore import org.amnezia.vpn.shadowsocks.core.preference.DataStore
import org.amnezia.vpn.shadowsocks.core.utils.Key import org.amnezia.vpn.shadowsocks.core.utils.Key
import org.amnezia.vpn.shadowsocks.core.utils.StartService
import org.amnezia.vpn.shadowsocks.core.utils.broadcastReceiver import org.amnezia.vpn.shadowsocks.core.utils.broadcastReceiver
class VpnRequestActivity : AppCompatActivity() { class VpnRequestActivity : AppCompatActivity() {
companion object {
private const val TAG = "VpnRequestActivity"
private const val REQUEST_CONNECT = 1
}
private var receiver: BroadcastReceiver? = null private var receiver: BroadcastReceiver? = null
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@@ -49,21 +44,13 @@ class VpnRequestActivity : AppCompatActivity() {
return return
} }
if (getSystemService<KeyguardManager>()!!.isKeyguardLocked) { if (getSystemService<KeyguardManager>()!!.isKeyguardLocked) {
receiver = broadcastReceiver { _, _ -> request() } receiver = broadcastReceiver { _, _ -> connect.launch(null) }
registerReceiver(receiver, IntentFilter(Intent.ACTION_USER_PRESENT)) registerReceiver(receiver, IntentFilter(Intent.ACTION_USER_PRESENT))
} else request() } else connect.launch(null)
} }
private fun request() { private val connect = registerForActivityResult(StartService()) {
val intent = VpnService.prepare(this) if (it) Toast.makeText(this, R.string.vpn_permission_denied, Toast.LENGTH_LONG).show()
if (intent == null) onActivityResult(REQUEST_CONNECT, RESULT_OK, null)
else startActivityForResult(intent, REQUEST_CONNECT)
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (resultCode == RESULT_OK) Core.startService() else {
Toast.makeText(this, R.string.vpn_permission_denied, Toast.LENGTH_LONG).show()
}
finish() finish()
} }
@@ -1,21 +1,46 @@
/*******************************************************************************
* *
* Copyright (C) 2017 by Max Lv <max.c.lv@gmail.com> *
* Copyright (C) 2017 by Mygod Studio <contact-shadowsocks-android@mygod.be> *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* This program is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU General Public License for more details. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
*******************************************************************************/
package org.amnezia.vpn.shadowsocks.core.acl package org.amnezia.vpn.shadowsocks.core.acl
import android.content.Context import android.content.Context
import androidx.recyclerview.widget.SortedList import androidx.recyclerview.widget.SortedList
import org.amnezia.vpn.shadowsocks.core.Core import org.amnezia.vpn.shadowsocks.core.Core
import org.amnezia.vpn.shadowsocks.core.net.Subnet import org.amnezia.vpn.shadowsocks.core.net.Subnet
import org.amnezia.vpn.shadowsocks.core.preference.DataStore import org.amnezia.vpn.shadowsocks.core.utils.BaseSorter
import org.amnezia.vpn.shadowsocks.core.utils.URLSorter
import org.amnezia.vpn.shadowsocks.core.utils.asIterable import org.amnezia.vpn.shadowsocks.core.utils.asIterable
import kotlinx.coroutines.Job
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.runBlocking
import timber.log.Timber
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
import java.io.Reader import java.io.Reader
import java.net.HttpURLConnection
import java.net.URL import java.net.URL
import java.net.URLConnection import java.net.URLConnection
import kotlin.coroutines.coroutineContext
class Acl { class Acl {
companion object { companion object {
const val TAG = "Acl"
const val ALL = "all" const val ALL = "all"
const val BYPASS_LAN = "bypass-lan" const val BYPASS_LAN = "bypass-lan"
const val BYPASS_CHN = "bypass-china" const val BYPASS_CHN = "bypass-china"
@@ -23,48 +48,73 @@ class Acl {
const val GFWLIST = "gfwlist" const val GFWLIST = "gfwlist"
const val CHINALIST = "china-list" const val CHINALIST = "china-list"
const val CUSTOM_RULES = "custom-rules" const val CUSTOM_RULES = "custom-rules"
const val CUSTOM_RULES_USER = "custom-rules-user"
val networkAclParser = "^IMPORT_URL\\s*<(.+)>\\s*$".toRegex() private val networkAclParser = "^IMPORT_URL\\s*<(.+)>\\s*$".toRegex()
fun getFile(id: String, context: Context = Core.deviceStorage) = File(context.noBackupFilesDir, "$id.acl") fun getFile(id: String, context: Context = Core.deviceStorage) = File(context.noBackupFilesDir, "$id.acl")
var customRules: Acl var customRules: Acl
get() { get() {
val acl = Acl() val acl = Acl()
val str = DataStore.publicStore.getString(CUSTOM_RULES) val file = getFile(CUSTOM_RULES_USER)
if (str != null) acl.fromReader(str.reader(), true) if (file.canRead()) acl.fromReader(file.reader(), true)
if (!acl.bypass) { if (!acl.bypass) {
acl.bypass = true acl.bypass = true
acl.subnets.clear() acl.subnets.clear()
} }
return acl return acl
} }
set(value) = DataStore.publicStore.putString(CUSTOM_RULES, set(value) = getFile(CUSTOM_RULES_USER).writeText(value.toString())
if ((!value.bypass || value.subnets.size() == 0) && value.bypassHostnames.size() == 0 &&
value.proxyHostnames.size() == 0 && value.urls.size() == 0) null else value.toString())
fun save(id: String, acl: Acl) = getFile(id).writeText(acl.toString()) fun save(id: String, acl: Acl) = getFile(id).writeText(acl.toString())
suspend fun <T> parse(reader: Reader, bypassHostnames: (String) -> T, proxyHostnames: (String) -> T,
urls: ((URL) -> T)? = null, defaultBypass: Boolean = false): Pair<Boolean, List<Subnet>> {
var bypass = defaultBypass
val bypassSubnets = mutableListOf<Subnet>()
val proxySubnets = mutableListOf<Subnet>()
var hostnames: ((String) -> T)? = if (defaultBypass) proxyHostnames else bypassHostnames
var subnets: MutableList<Subnet>? = if (defaultBypass) proxySubnets else bypassSubnets
reader.useLines {
for (line in it) {
coroutineContext[Job]!!.ensureActive()
val input = (if (urls == null) line else {
val blocks = line.split('#', limit = 2)
val url = networkAclParser.matchEntire(blocks.getOrElse(1) { "" })?.groupValues?.getOrNull(1)
if (url != null) urls(URL(url))
blocks[0]
}).trim()
if (input.getOrNull(0) == '[') when (input) {
"[outbound_block_list]" -> {
hostnames = null
subnets = null
}
"[black_list]", "[bypass_list]" -> {
hostnames = bypassHostnames
subnets = bypassSubnets
}
"[white_list]", "[proxy_list]" -> {
hostnames = proxyHostnames
subnets = proxySubnets
}
"[reject_all]", "[bypass_all]" -> bypass = true
"[accept_all]", "[proxy_all]" -> bypass = false
else -> error("Unrecognized block: $input")
} else if (subnets != null && input.isNotEmpty()) {
val subnet = Subnet.fromString(input)
if (subnet == null) hostnames!!(input) else subnets!!.add(subnet)
}
}
}
return bypass to if (bypass) proxySubnets else bypassSubnets
}
} }
private abstract class BaseSorter<T> : SortedList.Callback<T>() {
override fun onInserted(position: Int, count: Int) { }
override fun areContentsTheSame(oldItem: T?, newItem: T?): Boolean = oldItem == newItem
override fun onMoved(fromPosition: Int, toPosition: Int) { }
override fun onChanged(position: Int, count: Int) { }
override fun onRemoved(position: Int, count: Int) { }
override fun areItemsTheSame(item1: T?, item2: T?): Boolean = item1 == item2
override fun compare(o1: T?, o2: T?): Int =
if (o1 == null) if (o2 == null) 0 else 1 else if (o2 == null) -1 else compareNonNull(o1, o2)
abstract fun compareNonNull(o1: T, o2: T): Int
}
private open class DefaultSorter<T : Comparable<T>> : BaseSorter<T>() { private open class DefaultSorter<T : Comparable<T>> : BaseSorter<T>() {
override fun compareNonNull(o1: T, o2: T): Int = o1.compareTo(o2) override fun compareNonNull(o1: T, o2: T): Int = o1.compareTo(o2)
} }
private object StringSorter : DefaultSorter<String>() private object StringSorter : DefaultSorter<String>()
private object SubnetSorter : DefaultSorter<Subnet>() private object SubnetSorter : DefaultSorter<Subnet>()
private object URLSorter : BaseSorter<URL>() {
private val ordering = compareBy<URL>({ it.host }, { it.port }, { it.file }, { it.protocol })
override fun compareNonNull(o1: URL, o2: URL): Int = ordering.compare(o1, o2)
}
val bypassHostnames = SortedList(String::class.java, StringSorter) val bypassHostnames = SortedList(String::class.java, StringSorter)
val proxyHostnames = SortedList(String::class.java, StringSorter) val proxyHostnames = SortedList(String::class.java, StringSorter)
@@ -89,40 +139,11 @@ class Acl {
proxyHostnames.clear() proxyHostnames.clear()
subnets.clear() subnets.clear()
urls.clear() urls.clear()
bypass = defaultBypass val (bypass, subnets) = runBlocking {
val bypassSubnets by lazy { SortedList(Subnet::class.java, SubnetSorter) } parse(reader, bypassHostnames::add, proxyHostnames::add, urls::add, defaultBypass)
val proxySubnets by lazy { SortedList(Subnet::class.java, SubnetSorter) }
var hostnames: SortedList<String>? = if (defaultBypass) proxyHostnames else bypassHostnames
var subnets: SortedList<Subnet>? = if (defaultBypass) proxySubnets else bypassSubnets
reader.useLines {
for (line in it) {
@Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN")
val blocks = (line as java.lang.String).split("#", 2)
val url = networkAclParser.matchEntire(blocks.getOrElse(1) { "" })?.groupValues?.getOrNull(1)
if (url != null) urls.add(URL(url))
when (val input = blocks[0].trim()) {
"[outbound_block_list]" -> {
hostnames = null
subnets = null
} }
"[black_list]", "[bypass_list]" -> { this.bypass = bypass
hostnames = bypassHostnames for (item in subnets) this.subnets.add(item)
subnets = bypassSubnets
}
"[white_list]", "[proxy_list]" -> {
hostnames = proxyHostnames
subnets = proxySubnets
}
"[reject_all]", "[bypass_all]" -> bypass = true
"[accept_all]", "[proxy_all]" -> bypass = false
else -> if (subnets != null && input.isNotEmpty()) {
val subnet = Subnet.fromString(input)
if (subnet == null) hostnames!!.add(input) else subnets!!.add(subnet)
}
}
}
}
for (item in (if (bypass) proxySubnets else bypassSubnets).asIterable()) this.subnets.add(item)
return this return this
} }
@@ -132,14 +153,13 @@ class Acl {
suspend fun flatten(depth: Int, connect: suspend (URL) -> URLConnection): Acl { suspend fun flatten(depth: Int, connect: suspend (URL) -> URLConnection): Acl {
if (depth > 0) for (url in urls.asIterable()) { if (depth > 0) for (url in urls.asIterable()) {
val child = Acl() val child = Acl().fromReader(connect(url).also {
try { (it as? HttpURLConnection)?.instanceFollowRedirects = true
child.fromReader(connect(url).getInputStream().bufferedReader(), bypass).flatten(depth - 1, connect) }.getInputStream().bufferedReader(), bypass)
} catch (e: IOException) { child.flatten(depth - 1, connect)
e.printStackTrace()
continue
}
if (bypass != child.bypass) { if (bypass != child.bypass) {
Timber.w("Imported network ACL has a conflicting mode set. " +
"This will probably not work as intended. URL: $url")
child.subnets.clear() // subnets for the different mode are discarded child.subnets.clear() // subnets for the different mode are discarded
child.bypass = bypass child.bypass = bypass
} }
@@ -1,9 +1,34 @@
/*******************************************************************************
* *
* Copyright (C) 2017 by Max Lv <max.c.lv@gmail.com> *
* Copyright (C) 2017 by Mygod Studio <contact-shadowsocks-android@mygod.be> *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* This program is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU General Public License for more details. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
*******************************************************************************/
package org.amnezia.vpn.shadowsocks.core.acl package org.amnezia.vpn.shadowsocks.core.acl
import android.content.Context import android.content.Context
import android.os.Build
import androidx.work.* import androidx.work.*
import kotlinx.coroutines.Dispatchers import org.amnezia.vpn.shadowsocks.core.Core
import org.amnezia.vpn.shadowsocks.core.Core.app
import org.amnezia.vpn.shadowsocks.core.utils.useCancellable
import timber.log.Timber
import java.io.IOException import java.io.IOException
import java.net.HttpURLConnection
import java.net.URL import java.net.URL
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@@ -11,8 +36,10 @@ class AclSyncer(context: Context, workerParams: WorkerParameters) : CoroutineWor
companion object { companion object {
private const val KEY_ROUTE = "route" private const val KEY_ROUTE = "route"
fun schedule(route: String) = WorkManager.getInstance().enqueueUniqueWork(route, ExistingWorkPolicy.REPLACE, fun schedule(route: String) {
OneTimeWorkRequestBuilder<AclSyncer>().run { if (Build.VERSION.SDK_INT >= 24 && !Core.user.isUserUnlocked) return // work does not support this
WorkManager.getInstance(app).enqueueUniqueWork(
route, ExistingWorkPolicy.REPLACE, OneTimeWorkRequestBuilder<AclSyncer>().run {
setInputData(Data.Builder().putString(KEY_ROUTE, route).build()) setInputData(Data.Builder().putString(KEY_ROUTE, route).build())
setConstraints(Constraints.Builder() setConstraints(Constraints.Builder()
.setRequiredNetworkType(NetworkType.UNMETERED) .setRequiredNetworkType(NetworkType.UNMETERED)
@@ -22,17 +49,16 @@ class AclSyncer(context: Context, workerParams: WorkerParameters) : CoroutineWor
build() build()
}) })
} }
}
override val coroutineContext get() = Dispatchers.IO
override suspend fun doWork(): Result = try { override suspend fun doWork(): Result = try {
val route = inputData.getString(KEY_ROUTE)!! val route = inputData.getString(KEY_ROUTE)!!
val acl = URL("https://shadowsocks.org/acl/android/v1/$route.acl").openStream().bufferedReader() val connection = URL("https://shadowsocks.org/acl/android/v1/$route.acl").openConnection() as HttpURLConnection
.use { it.readText() } val acl = connection.useCancellable { inputStream.bufferedReader().use { it.readText() } }
Acl.getFile(route).printWriter().use { it.write(acl) } Acl.getFile(route).printWriter().use { it.write(acl) }
Result.success() Result.success()
} catch (e: IOException) { } catch (e: IOException) {
e.printStackTrace() Timber.d(e)
Result.retry() if (runAttemptCount > 5) Result.failure() else Result.retry()
} }
} }
@@ -24,28 +24,27 @@ import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.ServiceConnection import android.content.ServiceConnection
import android.os.DeadObjectException
import android.os.Handler
import android.os.IBinder import android.os.IBinder
import android.os.RemoteException import android.os.RemoteException
import org.amnezia.vpn.shadowsocks.core.bg.BaseService import org.amnezia.vpn.shadowsocks.core.bg.BaseService
import org.amnezia.vpn.shadowsocks.core.bg.ProxyService import org.amnezia.vpn.shadowsocks.core.bg.ProxyService
import org.amnezia.vpn.shadowsocks.core.bg.TransproxyService import org.amnezia.vpn.shadowsocks.core.bg.TransproxyService
import org.amnezia.vpn.shadowsocks.core.bg.ShadowsocksVpnService import org.amnezia.vpn.shadowsocks.core.bg.VpnService
import org.amnezia.vpn.shadowsocks.core.preference.DataStore import org.amnezia.vpn.shadowsocks.core.preference.DataStore
import org.amnezia.vpn.shadowsocks.core.utils.Action import org.amnezia.vpn.shadowsocks.core.utils.Action
import org.amnezia.vpn.shadowsocks.core.utils.Key import org.amnezia.vpn.shadowsocks.core.utils.Key
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
/** /**
* This object should be compact as it will not get GC-ed. * This object should be compact as it will not get GC-ed.
*/ */
class ShadowsocksConnection(private val handler: Handler = Handler(), class ShadowsocksConnection(private var listenForDeath: Boolean = false) : ServiceConnection, IBinder.DeathRecipient {
private var listenForDeath: Boolean = false) :
ServiceConnection, IBinder.DeathRecipient {
companion object { companion object {
val serviceClass get() = when (DataStore.serviceMode) { val serviceClass get() = when (DataStore.serviceMode) {
Key.modeProxy -> ProxyService::class Key.modeProxy -> ProxyService::class
Key.modeVpn -> ShadowsocksVpnService::class Key.modeVpn -> VpnService::class
Key.modeTransproxy -> TransproxyService::class Key.modeTransproxy -> TransproxyService::class
else -> throw UnknownError() else -> throw UnknownError()
}.java }.java
@@ -70,38 +69,38 @@ class ShadowsocksConnection(private val handler: Handler = Handler(),
private val serviceCallback = object : IShadowsocksServiceCallback.Stub() { private val serviceCallback = object : IShadowsocksServiceCallback.Stub() {
override fun stateChanged(state: Int, profileName: String?, msg: String?) { override fun stateChanged(state: Int, profileName: String?, msg: String?) {
val callback = callback ?: return val callback = callback ?: return
handler.post { callback.stateChanged(BaseService.State.values()[state], profileName, msg) } GlobalScope.launch(Dispatchers.Main.immediate) {
callback.stateChanged(BaseService.State.values()[state], profileName, msg)
}
} }
override fun trafficUpdated(profileId: Long, stats: TrafficStats) { override fun trafficUpdated(profileId: Long, stats: TrafficStats) {
val callback = callback ?: return val callback = callback ?: return
handler.post { GlobalScope.launch(Dispatchers.Main.immediate) { callback.trafficUpdated(profileId, stats) }
callback.trafficUpdated(profileId, stats)
}
} }
override fun trafficPersisted(profileId: Long) { override fun trafficPersisted(profileId: Long) {
val callback = callback ?: return val callback = callback ?: return
handler.post { callback.trafficPersisted(profileId) } GlobalScope.launch(Dispatchers.Main.immediate) { callback.trafficPersisted(profileId) }
} }
} }
private var binder: IBinder? = null private var binder: IBinder? = null
var bandwidthTimeout = 0L var bandwidthTimeout = 0L
set(value) { set(value) {
val service = service try {
if (bandwidthTimeout != value && service != null) if (value > 0) service?.startListeningForBandwidth(serviceCallback, value)
if (value > 0) service.startListeningForBandwidth(serviceCallback, value) else try { else service?.stopListeningForBandwidth(serviceCallback)
service.stopListeningForBandwidth(serviceCallback) } catch (_: RemoteException) { }
} catch (_: DeadObjectException) { }
field = value field = value
} }
var service: IShadowsocksService? = null var service: IShadowsocksService? = null
override fun onServiceConnected(name: ComponentName?, binder: IBinder) { override fun onServiceConnected(name: ComponentName?, binder: IBinder) {
this.binder = binder this.binder = binder
if (listenForDeath) binder.linkToDeath(this, 0)
val service = IShadowsocksService.Stub.asInterface(binder)!! val service = IShadowsocksService.Stub.asInterface(binder)!!
this.service = service this.service = service
if (!callbackRegistered) try { try {
if (listenForDeath) binder.linkToDeath(this, 0)
check(!callbackRegistered)
service.registerCallback(serviceCallback) service.registerCallback(serviceCallback)
callbackRegistered = true callbackRegistered = true
if (bandwidthTimeout > 0) service.startListeningForBandwidth(serviceCallback, bandwidthTimeout) if (bandwidthTimeout > 0) service.startListeningForBandwidth(serviceCallback, bandwidthTimeout)
@@ -118,7 +117,8 @@ class ShadowsocksConnection(private val handler: Handler = Handler(),
override fun binderDied() { override fun binderDied() {
service = null service = null
callback?.also { handler.post(it::onBinderDied) } callbackRegistered = false
callback?.also { GlobalScope.launch(Dispatchers.Main.immediate) { it.onBinderDied() } }
} }
private fun unregisterCallback() { private fun unregisterCallback() {
@@ -144,9 +144,13 @@ class ShadowsocksConnection(private val handler: Handler = Handler(),
context.unbindService(this) context.unbindService(this)
} catch (_: IllegalArgumentException) { } // ignore } catch (_: IllegalArgumentException) { } // ignore
connectionActive = false connectionActive = false
if (listenForDeath) binder?.unlinkToDeath(this, 0) if (listenForDeath) try {
binder?.unlinkToDeath(this, 0)
} catch (_: NoSuchElementException) { }
binder = null binder = null
try {
service?.stopListeningForBandwidth(serviceCallback) service?.stopListeningForBandwidth(serviceCallback)
} catch (_: RemoteException) { }
service = null service = null
callback = null callback = null
} }
@@ -20,9 +20,10 @@
package org.amnezia.vpn.shadowsocks.core.aidl package org.amnezia.vpn.shadowsocks.core.aidl
import android.os.Parcel
import android.os.Parcelable import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Parcelize
data class TrafficStats( data class TrafficStats(
// Bytes per second // Bytes per second
var txRate: Long = 0L, var txRate: Long = 0L,
@@ -35,18 +36,4 @@ data class TrafficStats(
operator fun plus(other: TrafficStats) = TrafficStats( operator fun plus(other: TrafficStats) = TrafficStats(
txRate + other.txRate, rxRate + other.rxRate, txRate + other.txRate, rxRate + other.rxRate,
txTotal + other.txTotal, rxTotal + other.rxTotal) txTotal + other.txTotal, rxTotal + other.rxTotal)
constructor(parcel: Parcel) : this(parcel.readLong(), parcel.readLong(), parcel.readLong(), parcel.readLong())
override fun writeToParcel(parcel: Parcel, flags: Int) {
parcel.writeLong(txRate)
parcel.writeLong(rxRate)
parcel.writeLong(txTotal)
parcel.writeLong(rxTotal)
}
override fun describeContents() = 0
companion object CREATOR : Parcelable.Creator<TrafficStats> {
override fun createFromParcel(parcel: Parcel) = TrafficStats(parcel)
override fun newArray(size: Int): Array<TrafficStats?> = arrayOfNulls(size)
}
} }
@@ -24,26 +24,33 @@ import android.app.Service
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.os.* import android.os.Build
import android.util.Log import android.os.IBinder
import androidx.core.content.getSystemService import android.os.RemoteCallbackList
import kotlinx.coroutines.* import android.os.RemoteException
import androidx.core.content.ContextCompat
import org.amnezia.vpn.shadowsocks.core.BootReceiver
import org.amnezia.vpn.shadowsocks.core.Core import org.amnezia.vpn.shadowsocks.core.Core
import org.amnezia.vpn.shadowsocks.core.Core.app import org.amnezia.vpn.shadowsocks.core.Core.app
import org.amnezia.vpn.shadowsocks.core.R import org.amnezia.vpn.shadowsocks.core.acl.Acl
import org.amnezia.vpn.shadowsocks.core.aidl.IShadowsocksService import org.amnezia.vpn.shadowsocks.core.aidl.IShadowsocksService
import org.amnezia.vpn.shadowsocks.core.aidl.IShadowsocksServiceCallback import org.amnezia.vpn.shadowsocks.core.aidl.IShadowsocksServiceCallback
import org.amnezia.vpn.shadowsocks.core.aidl.TrafficStats import org.amnezia.vpn.shadowsocks.core.aidl.TrafficStats
import org.amnezia.vpn.shadowsocks.core.plugin.PluginManager import org.amnezia.vpn.shadowsocks.core.R
import org.amnezia.vpn.shadowsocks.core.net.DnsResolverCompat
import org.amnezia.vpn.shadowsocks.core.preference.DataStore
import org.amnezia.vpn.shadowsocks.core.utils.Action import org.amnezia.vpn.shadowsocks.core.utils.Action
import org.amnezia.vpn.shadowsocks.core.utils.broadcastReceiver import org.amnezia.vpn.shadowsocks.core.utils.broadcastReceiver
import org.amnezia.vpn.shadowsocks.core.utils.printLog
import org.amnezia.vpn.shadowsocks.core.utils.readableMessage import org.amnezia.vpn.shadowsocks.core.utils.readableMessage
import com.google.firebase.analytics.FirebaseAnalytics
import com.google.firebase.analytics.ktx.analytics
import com.google.firebase.analytics.ktx.logEvent
import com.google.firebase.ktx.Firebase
import kotlinx.coroutines.*
import timber.log.Timber
import java.io.File import java.io.File
import java.net.BindException import java.io.IOException
import java.net.InetAddress
import java.net.URL import java.net.URL
import java.net.UnknownHostException
/** /**
* This object uses WeakMap to simulate the effects of multi-inheritance. * This object uses WeakMap to simulate the effects of multi-inheritance.
@@ -63,15 +70,20 @@ object BaseService {
const val CONFIG_FILE = "shadowsocks.conf" const val CONFIG_FILE = "shadowsocks.conf"
const val CONFIG_FILE_UDP = "shadowsocks-udp.conf" const val CONFIG_FILE_UDP = "shadowsocks-udp.conf"
interface ExpectedException
class ExpectedExceptionWrapper(e: Exception) : Exception(e.localizedMessage, e), ExpectedException
class Data (private val service: Interface) { class Data (private val service: Interface) {
var state = State.Stopped var state = State.Stopped
var processes: GuardedProcessPool? = null var processes: GuardedProcessPool? = null
var proxy: ProxyInstance? = null var proxy: ProxyInstance? = null
var udpFallback: ProxyInstance? = null var udpFallback: ProxyInstance? = null
var localDns: LocalDnsWorker? = null
// var notification: ServiceNotification? = null // var notification: ServiceNotification? = null
val closeReceiver = broadcastReceiver { _, intent -> val closeReceiver = broadcastReceiver { _, intent ->
when (intent.action) { when (intent.action) {
Intent.ACTION_SHUTDOWN -> service.persistStats()
Action.RELOAD -> service.forceLoad() Action.RELOAD -> service.forceLoad()
else -> service.stopRunner() else -> service.stopRunner()
} }
@@ -88,16 +100,16 @@ object BaseService {
} }
} }
class Binder(private var data: Data? = null) : IShadowsocksService.Stub(), AutoCloseable { class Binder(private var data: Data? = null) : IShadowsocksService.Stub(), CoroutineScope, AutoCloseable {
val callbacks = object : RemoteCallbackList<IShadowsocksServiceCallback>() { private val callbacks = object : RemoteCallbackList<IShadowsocksServiceCallback>() {
override fun onCallbackDied(callback: IShadowsocksServiceCallback?, cookie: Any?) { override fun onCallbackDied(callback: IShadowsocksServiceCallback?, cookie: Any?) {
super.onCallbackDied(callback, cookie) super.onCallbackDied(callback, cookie)
stopListeningForBandwidth(callback ?: return) stopListeningForBandwidth(callback ?: return)
} }
} }
private val bandwidthListeners = private val bandwidthListeners = mutableMapOf<IBinder, Long>() // the binder is the real identifier
mutableMapOf<IBinder, Long>() // the binder is the real identifier override val coroutineContext = Dispatchers.Main.immediate + Job()
private val handler = Handler() private var looper: Job? = null
override fun getState(): Int = (data?.state ?: State.Idle).ordinal override fun getState(): Int = (data?.state ?: State.Idle).ordinal
override fun getProfileName(): String = data?.proxy?.profile?.name ?: "Idle" override fun getProfileName(): String = data?.proxy?.profile?.name ?: "Idle"
@@ -107,22 +119,24 @@ object BaseService {
} }
private fun broadcast(work: (IShadowsocksServiceCallback) -> Unit) { private fun broadcast(work: (IShadowsocksServiceCallback) -> Unit) {
repeat(callbacks.beginBroadcast()) { val count = callbacks.beginBroadcast()
try {
repeat(count) {
try { try {
work(callbacks.getBroadcastItem(it)) work(callbacks.getBroadcastItem(it))
} catch (_: DeadObjectException) { } catch (_: RemoteException) {
} catch (e: Exception) { } catch (e: Exception) {
printLog(e) Timber.w(e)
} }
} }
} finally {
callbacks.finishBroadcast() callbacks.finishBroadcast()
} }
private fun registerTimeout() {
handler.postDelayed(this::onTimeout, bandwidthListeners.values.minOrNull() ?: return)
} }
private fun onTimeout() { private suspend fun loop() {
while (true) {
delay(bandwidthListeners.values.minOrNull() ?: return)
val proxies = listOfNotNull(data?.proxy, data?.udpFallback) val proxies = listOfNotNull(data?.proxy, data?.udpFallback)
val stats = proxies val stats = proxies
.map { Pair(it.profile.id, it.trafficMonitor?.requestUpdate()) } .map { Pair(it.profile.id, it.trafficMonitor?.requestUpdate()) }
@@ -137,33 +151,31 @@ object BaseService {
} }
} }
} }
registerTimeout() }
} }
override fun startListeningForBandwidth(cb: IShadowsocksServiceCallback, timeout: Long) { override fun startListeningForBandwidth(cb: IShadowsocksServiceCallback, timeout: Long) {
val wasEmpty = bandwidthListeners.isEmpty() launch {
if (bandwidthListeners.put(cb.asBinder(), timeout) == null) { if (bandwidthListeners.isEmpty() and (bandwidthListeners.put(cb.asBinder(), timeout) == null)) {
if (wasEmpty) registerTimeout() check(looper == null)
if (data?.state != State.Connected) return looper = launch { loop() }
}
if (data?.state != State.Connected) return@launch
var sum = TrafficStats() var sum = TrafficStats()
val data = data val data = data
val proxy = data?.proxy ?: return val proxy = data?.proxy ?: return@launch
proxy.trafficMonitor?.out.also { stats -> proxy.trafficMonitor?.out.also { stats ->
cb.trafficUpdated( cb.trafficUpdated(proxy.profile.id, if (stats == null) sum else {
proxy.profile.id, if (stats == null) sum else {
sum += stats sum += stats
stats stats
} })
)
} }
data.udpFallback?.also { udpFallback -> data.udpFallback?.also { udpFallback ->
udpFallback.trafficMonitor?.out.also { stats -> udpFallback.trafficMonitor?.out.also { stats ->
cb.trafficUpdated( cb.trafficUpdated(udpFallback.profile.id, if (stats == null) TrafficStats() else {
udpFallback.profile.id, if (stats == null) TrafficStats() else {
sum += stats sum += stats
stats stats
} })
)
} }
} }
cb.trafficUpdated(0, sum) cb.trafficUpdated(0, sum)
@@ -171,8 +183,11 @@ object BaseService {
} }
override fun stopListeningForBandwidth(cb: IShadowsocksServiceCallback) { override fun stopListeningForBandwidth(cb: IShadowsocksServiceCallback) {
launch {
if (bandwidthListeners.remove(cb.asBinder()) != null && bandwidthListeners.isEmpty()) { if (bandwidthListeners.remove(cb.asBinder()) != null && bandwidthListeners.isEmpty()) {
handler.removeCallbacksAndMessages(null) looper!!.cancel()
looper = null
}
} }
} }
@@ -181,12 +196,12 @@ object BaseService {
callbacks.unregister(cb) callbacks.unregister(cb)
} }
fun stateChanged(s: State, msg: String?) { fun stateChanged(s: State, msg: String?) = launch {
val profileName = profileName val profileName = profileName
broadcast { it.stateChanged(s.ordinal, profileName, msg) } broadcast { it.stateChanged(s.ordinal, profileName, msg) }
} }
fun trafficPersisted(ids: List<Long>) { fun trafficPersisted(ids: List<Long>) = launch {
if (bandwidthListeners.isNotEmpty() && ids.isNotEmpty()) broadcast { item -> if (bandwidthListeners.isNotEmpty() && ids.isNotEmpty()) broadcast { item ->
if (bandwidthListeners.contains(item.asBinder())) ids.forEach(item::trafficPersisted) if (bandwidthListeners.contains(item.asBinder())) ids.forEach(item::trafficPersisted)
} }
@@ -194,7 +209,7 @@ object BaseService {
override fun close() { override fun close() {
callbacks.kill() callbacks.kill()
handler.removeCallbacksAndMessages(null) cancel()
data = null data = null
} }
} }
@@ -204,46 +219,34 @@ object BaseService {
val tag: String val tag: String
//fun createNotification(profileName: String): ServiceNotification //fun createNotification(profileName: String): ServiceNotification
fun onBind(intent: Intent): IBinder? = fun onBind(intent: Intent): IBinder? = if (intent.action == Action.SERVICE) data.binder else null
if (intent.action == Action.SERVICE) data.binder else null
fun forceLoad() { fun forceLoad() {
val (profile, fallback) = Core.currentProfile
?: return stopRunner(false, (this as Context).getString(R.string.profile_empty))
if (profile.host.isEmpty() || profile.password.isEmpty() ||
fallback != null && (fallback.host.isEmpty() || fallback.password.isEmpty())
) {
stopRunner(false, (this as Context).getString(R.string.proxy_empty))
return
}
val s = data.state val s = data.state
when { when {
s == State.Stopped -> startRunner() s == State.Stopped -> startRunner()
s.canStop -> stopRunner(true) s.canStop -> stopRunner(true)
else -> {} else -> Timber.w("Illegal state $s when invoking use")
} }
} }
fun buildAdditionalArguments(cmd: ArrayList<String>): ArrayList<String> = cmd val isVpnService get() = false
suspend fun startProcesses() { suspend fun startProcesses() {
val configRoot = (if (Build.VERSION.SDK_INT < 24 || app.getSystemService<UserManager>() val context = if (Build.VERSION.SDK_INT < 24 || Core.user.isUserUnlocked) app else Core.deviceStorage
?.isUserUnlocked != false val configRoot = context.noBackupFilesDir
) app else Core.deviceStorage).noBackupFilesDir
val udpFallback = data.udpFallback val udpFallback = data.udpFallback
data.proxy!!.start( data.proxy!!.start(this,
this,
File(Core.deviceStorage.noBackupFilesDir, "stat_main"), File(Core.deviceStorage.noBackupFilesDir, "stat_main"),
File(configRoot, CONFIG_FILE), File(configRoot, CONFIG_FILE),
if (udpFallback == null) "-u" else null if (udpFallback == null && data.proxy?.plugin == null) "tcp_and_udp" else "tcp_and_udp")
) if (udpFallback?.plugin != null) throw ExpectedExceptionWrapper(IllegalStateException(
check(udpFallback?.pluginPath == null) { "UDP fallback cannot have plugins" } "UDP fallback cannot have plugins"))
udpFallback?.start( udpFallback?.start(this,
this,
File(Core.deviceStorage.noBackupFilesDir, "stat_udp"), File(Core.deviceStorage.noBackupFilesDir, "stat_udp"),
File(configRoot, CONFIG_FILE_UDP), File(configRoot, CONFIG_FILE_UDP),
"-U" "udp_only", false)
) data.localDns = LocalDnsWorker(this::rawResolver).apply { start() }
} }
fun startRunner() { fun startRunner() {
@@ -257,6 +260,8 @@ object BaseService {
close(scope) close(scope)
data.processes = null data.processes = null
} }
data.localDns?.shutdown(scope)
data.localDns = null
} }
fun stopRunner(restart: Boolean = false, msg: String? = null) { fun stopRunner(restart: Boolean = false, msg: String? = null) {
@@ -264,6 +269,7 @@ object BaseService {
// change the state // change the state
data.changeState(State.Stopping) data.changeState(State.Stopping)
GlobalScope.launch(Dispatchers.Main.immediate) { GlobalScope.launch(Dispatchers.Main.immediate) {
//Firebase.analytics.logEvent("stop") { param(FirebaseAnalytics.Param.METHOD, tag) }
data.connectingJob?.cancelAndJoin() // ensure stop connecting first data.connectingJob?.cancelAndJoin() // ensure stop connecting first
this@Interface as Service this@Interface as Service
// we use a coroutineScope here to allow clean-up in parallel // we use a coroutineScope here to allow clean-up in parallel
@@ -292,86 +298,89 @@ object BaseService {
data.changeState(State.Stopped, msg) data.changeState(State.Stopped, msg)
// stop the service if nothing has bound to it // stop the service if nothing has bound to it
if (restart) { if (restart) startRunner() else {
startRunner() BootReceiver.enabled = false
} else { stopSelf()
Log.d("Aman", "Stop Self BaseService-------")
// stopSelf()
} }
} }
} }
fun persistStats() =
listOfNotNull(data.proxy, data.udpFallback).forEach { it.trafficMonitor?.persistStats(it.profile.id) }
suspend fun preInit() { } suspend fun preInit() { }
suspend fun resolver(host: String) = InetAddress.getAllByName(host) suspend fun rawResolver(query: ByteArray) = DnsResolverCompat.resolveRawOnActiveNetwork(query)
suspend fun openConnection(url: URL) = url.openConnection() suspend fun openConnection(url: URL) = url.openConnection()
fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val data = data val data = data
if (data.state != State.Stopped) return Service.START_REDELIVER_INTENT if (data.state != State.Stopped) return Service.START_NOT_STICKY
val profilePair = Core.currentProfile val expanded = Core.currentProfile
this as Context this as Context
if (profilePair == null) { if (expanded == null) {
// gracefully shutdown: https://stackoverflow.com/q/47337857/2245107 // gracefully shutdown: https://stackoverflow.com/q/47337857/2245107
// data.notification = createNotification("") // data.notification = createNotification("")
stopRunner(false, getString(R.string.profile_empty)) stopRunner(false, getString(R.string.profile_empty))
return Service.START_REDELIVER_INTENT return Service.START_NOT_STICKY
}
val (profile, fallback) = expanded
try {
data.proxy = ProxyInstance(profile)
data.udpFallback = if (fallback == null) null else ProxyInstance(fallback, profile.route)
} catch (e: IllegalArgumentException) {
// data.notification = createNotification("")
stopRunner(false, e.message)
return Service.START_NOT_STICKY
} }
val (profile, fallback) = profilePair
profile.name = profile.formattedName // save name for later queries
val proxy = ProxyInstance(profile)
data.proxy = proxy
data.udpFallback =
if (fallback == null) null else ProxyInstance(fallback, profile.route)
BootReceiver.enabled = DataStore.persistAcrossReboot
if (!data.closeReceiverRegistered) { if (!data.closeReceiverRegistered) {
registerReceiver(data.closeReceiver, IntentFilter().apply { ContextCompat.registerReceiver(this, data.closeReceiver, IntentFilter().apply {
addAction(Action.RELOAD) addAction(Action.RELOAD)
addAction(Intent.ACTION_SHUTDOWN) addAction(Intent.ACTION_SHUTDOWN)
addAction(Action.CLOSE) addAction(Action.CLOSE)
}) }, ContextCompat.RECEIVER_NOT_EXPORTED)
data.closeReceiverRegistered = true data.closeReceiverRegistered = true
} }
// data.notification = createNotification(profile.formattedName) // data.notification = createNotification(profile.formattedName)
//Firebase.analytics.logEvent("start") { param(FirebaseAnalytics.Param.METHOD, tag) }
data.changeState(State.Connecting) data.changeState(State.Connecting)
data.connectingJob = GlobalScope.launch(Dispatchers.Main) { data.connectingJob = GlobalScope.launch(Dispatchers.Main) {
try { try {
Executable.killAll() // clean up old processes Executable.killAll() // clean up old processes
preInit() preInit()
proxy.init(this@Interface) if (profile.route == Acl.CUSTOM_RULES) try {
data.udpFallback?.init(this@Interface) withContext(Dispatchers.IO) {
Acl.customRules.flatten(10, this@Interface::openConnection).also {
Acl.save(Acl.CUSTOM_RULES, it)
}
}
} catch (e: IOException) {
throw ExpectedExceptionWrapper(e)
}
data.processes = GuardedProcessPool { data.processes = GuardedProcessPool {
printLog(it) Timber.w(it)
stopRunner(false, it.readableMessage) stopRunner(false, it.readableMessage)
} }
startProcesses() startProcesses()
proxy.scheduleUpdate() data.proxy!!.scheduleUpdate()
data.udpFallback?.scheduleUpdate() data.udpFallback?.scheduleUpdate()
data.changeState(State.Connected) data.changeState(State.Connected)
} catch (_: CancellationException) { } catch (_: CancellationException) {
// if the job was cancelled, it is canceller's responsibility to call stopRunner // if the job was cancelled, it is canceller's responsibility to call stopRunner
} catch (_: UnknownHostException) {
stopRunner(false, getString(R.string.invalid_server))
} catch (exc: Throwable) { } catch (exc: Throwable) {
if (exc !is PluginManager.PluginNotFoundException && if (exc is ExpectedException) Timber.d(exc) else Timber.w(exc)
exc !is BindException && stopRunner(false, "${getString(R.string.service_failed)}: ${exc.readableMessage}")
exc !is ShadowsocksVpnService.NullConnectionException
) {
printLog(exc)
}
stopRunner(
false,
"${getString(R.string.service_failed)}: ${exc.readableMessage}"
)
} finally { } finally {
data.connectingJob = null data.connectingJob = null
} }
} }
return Service.START_REDELIVER_INTENT return Service.START_NOT_STICKY
} }
} }
} }
@@ -24,19 +24,19 @@ import android.system.ErrnoException
import android.system.Os import android.system.Os
import android.system.OsConstants import android.system.OsConstants
import android.text.TextUtils import android.text.TextUtils
import timber.log.Timber
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
object Executable { object Executable {
const val REDSOCKS = "libredsocks.so" const val REDSOCKS = "libredsocks.so"
const val SS_LOCAL = "libss-local.so" const val SS_LOCAL = "libsslocal.so"
const val TUN2SOCKS = "libtun2socks.so" const val TUN2SOCKS = "libtun2socks.so"
private val EXECUTABLES = setOf(SS_LOCAL, REDSOCKS, TUN2SOCKS) private val EXECUTABLES = setOf(SS_LOCAL, REDSOCKS, TUN2SOCKS)
fun killAll() { fun killAll() {
for (process in File("/proc").listFiles { _, name -> TextUtils.isDigitsOnly(name) }) { for (process in File("/proc").listFiles { _, name -> TextUtils.isDigitsOnly(name) } ?: return) {
val exe = File(try { val exe = File(try {
File(process, "cmdline").inputStream().bufferedReader().readText() File(process, "cmdline").inputStream().bufferedReader().readText()
} catch (_: IOException) { } catch (_: IOException) {
@@ -46,7 +46,8 @@ object Executable {
Os.kill(process.name.toInt(), OsConstants.SIGKILL) Os.kill(process.name.toInt(), OsConstants.SIGKILL)
} catch (e: ErrnoException) { } catch (e: ErrnoException) {
if (e.errno != OsConstants.ESRCH) { if (e.errno != OsConstants.ESRCH) {
e.printStackTrace() Timber.w("SIGKILL ${exe.absolutePath} (${process.name}) failed")
Timber.w(e)
} }
} }
} }
@@ -25,12 +25,13 @@ import android.os.SystemClock
import android.system.ErrnoException import android.system.ErrnoException
import android.system.Os import android.system.Os
import android.system.OsConstants import android.system.OsConstants
import android.util.Log
import androidx.annotation.MainThread import androidx.annotation.MainThread
import org.amnezia.vpn.shadowsocks.core.Core import org.amnezia.vpn.shadowsocks.core.Core
import org.amnezia.vpn.shadowsocks.core.utils.Commandline
import android.util.Log
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import timber.log.Timber
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
import java.io.InputStream import java.io.InputStream
@@ -38,7 +39,6 @@ import kotlin.concurrent.thread
class GuardedProcessPool(private val onFatal: suspend (IOException) -> Unit) : CoroutineScope { class GuardedProcessPool(private val onFatal: suspend (IOException) -> Unit) : CoroutineScope {
companion object { companion object {
private const val TAG = "GuardedProcessPool"
private val pid by lazy { private val pid by lazy {
Class.forName("java.lang.ProcessManager\$ProcessImpl").getDeclaredField("pid").apply { isAccessible = true } Class.forName("java.lang.ProcessManager\$ProcessImpl").getDeclaredField("pid").apply { isAccessible = true }
} }
@@ -49,8 +49,7 @@ class GuardedProcessPool(private val onFatal: suspend (IOException) -> Unit) : C
private fun streamLogger(input: InputStream, logger: (String) -> Unit) = try { private fun streamLogger(input: InputStream, logger: (String) -> Unit) = try {
input.bufferedReader().forEachLine(logger) input.bufferedReader().forEachLine(logger)
} catch (_: IOException) { } catch (_: IOException) { } // ignore
} // ignore
fun start() { fun start() {
process = ProcessBuilder(cmd).directory(Core.deviceStorage.noBackupFilesDir).start() process = ProcessBuilder(cmd).directory(Core.deviceStorage.noBackupFilesDir).start()
@@ -62,31 +61,40 @@ class GuardedProcessPool(private val onFatal: suspend (IOException) -> Unit) : C
val exitChannel = Channel<Int>() val exitChannel = Channel<Int>()
try { try {
while (true) { while (true) {
thread(name = "stderr-$cmdName") { streamLogger(process.errorStream) { Log.e(cmdName, it) } } thread(name = "stderr-$cmdName") {
streamLogger(process.errorStream) { Log.e(cmdName, it) }
}
thread(name = "stdout-$cmdName") { thread(name = "stdout-$cmdName") {
streamLogger(process.inputStream) { Log.i(cmdName, it) } streamLogger(process.inputStream) { Log.e(cmdName, it) }
// this thread also acts as a daemon thread for waitFor // this thread also acts as a daemon thread for waitFor
runBlocking { exitChannel.send(process.waitFor()) } runBlocking { exitChannel.send(process.waitFor()) }
} }
val startTime = SystemClock.elapsedRealtime() val startTime = SystemClock.elapsedRealtime()
val exitCode = exitChannel.receive() val exitCode = exitChannel.receive()
running = false running = false
if (SystemClock.elapsedRealtime() - startTime < 1000) { when {
throw IOException("$cmdName exits too fast (exit code: $exitCode)") SystemClock.elapsedRealtime() - startTime < 1000 -> throw IOException(
"$cmdName exits too fast (exit code: $exitCode)")
exitCode == 128 + OsConstants.SIGKILL -> Log.e(cmdName, "$cmdName was killed")
else -> Log.e(cmdName, "$cmdName unexpectedly exits with code $exitCode")
} }
Log.e(cmdName, "restart process: ${Commandline.toString(cmd)} (last exit code: $exitCode)")
start() start()
running = true
onRestartCallback?.invoke() onRestartCallback?.invoke()
} }
} catch (e: IOException) { } catch (e: IOException) {
Log.e(cmdName, "error occurred. stop guard: ${Commandline.toString(cmd)}")
GlobalScope.launch(Dispatchers.Main) { onFatal(e) } GlobalScope.launch(Dispatchers.Main) { onFatal(e) }
} finally { } finally {
if (running) withContext(NonCancellable) { if (running) withContext(NonCancellable) { // clean-up cannot be cancelled
// clean-up cannot be cancelled
if (Build.VERSION.SDK_INT < 24) { if (Build.VERSION.SDK_INT < 24) {
try { try {
Os.kill(pid.get(process) as Int, OsConstants.SIGTERM) Os.kill(pid.get(process) as Int, OsConstants.SIGTERM)
} catch (e: ErrnoException) { } catch (e: ErrnoException) {
if (e.errno != OsConstants.ESRCH) throw e if (e.errno != OsConstants.ESRCH) Log.e(cmdName, e.toString())
} catch (e: ReflectiveOperationException) {
Log.e(cmdName, e.toString())
} }
if (withTimeoutOrNull(500) { exitChannel.receive() } != null) return@withContext if (withTimeoutOrNull(500) { exitChannel.receive() } != null) return@withContext
} }
@@ -105,6 +113,7 @@ class GuardedProcessPool(private val onFatal: suspend (IOException) -> Unit) : C
@MainThread @MainThread
fun start(cmd: List<String>, onRestartCallback: (suspend () -> Unit)? = null) { fun start(cmd: List<String>, onRestartCallback: (suspend () -> Unit)? = null) {
Log.i("GuardedProcessPool", "start process: ${Commandline.toString(cmd)}")
Guard(cmd).apply { Guard(cmd).apply {
start() // if start fails, IOException will be thrown directly start() // if start fails, IOException will be thrown directly
launch { looper(onRestartCallback) } launch { looper(onRestartCallback) }
@@ -1,70 +0,0 @@
/*******************************************************************************
* *
* Copyright (C) 2017 by Max Lv <max.c.lv@gmail.com> *
* Copyright (C) 2017 by Mygod Studio <contact-shadowsocks-android@mygod.be> *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* This program is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU General Public License for more details. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
*******************************************************************************/
package org.amnezia.vpn.shadowsocks.core.bg
import org.amnezia.vpn.shadowsocks.core.Core.app
import org.amnezia.vpn.shadowsocks.core.acl.Acl
import org.amnezia.vpn.shadowsocks.core.R
import org.amnezia.vpn.shadowsocks.core.net.LocalDnsServer
import org.amnezia.vpn.shadowsocks.core.net.Socks5Endpoint
import org.amnezia.vpn.shadowsocks.core.net.Subnet
import org.amnezia.vpn.shadowsocks.core.preference.DataStore
import kotlinx.coroutines.CoroutineScope
import java.net.InetSocketAddress
import java.net.URI
import java.util.*
object LocalDnsService {
private val googleApisTester =
"(^|\\.)googleapis(\\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?){1,2}\$".toRegex()
private val chinaIpList by lazy {
app.resources.openRawResource(R.raw.china_ip_list).bufferedReader()
.lineSequence().map(Subnet.Companion::fromString).filterNotNull().toList()
}
private val servers = WeakHashMap<Interface, LocalDnsServer>()
interface Interface : BaseService.Interface {
override suspend fun startProcesses() {
super.startProcesses()
val profile = data.proxy!!.profile
val dns = URI("dns://${profile.remoteDns}")
LocalDnsServer(this::resolver,
Socks5Endpoint(dns.host, if (dns.port < 0) 53 else dns.port),
DataStore.proxyAddress).apply {
tcp = !profile.udpdns
when (profile.route) {
Acl.BYPASS_CHN, Acl.BYPASS_LAN_CHN, Acl.GFWLIST, Acl.CUSTOM_RULES -> {
remoteDomainMatcher = googleApisTester
localIpMatcher = chinaIpList
}
Acl.CHINALIST -> { }
else -> forwardOnly = true
}
}.also { servers[this] = it }.start(InetSocketAddress(DataStore.listenAddress, DataStore.portLocalDns))
}
override fun killProcesses(scope: CoroutineScope) {
servers.remove(this)?.shutdown(scope)
super.killProcesses(scope)
}
}
}
@@ -0,0 +1,61 @@
package org.amnezia.vpn.shadowsocks.core.bg
import android.net.LocalSocket
import org.amnezia.vpn.shadowsocks.core.Core
import org.amnezia.vpn.shadowsocks.core.net.ConcurrentLocalSocketListener
import org.amnezia.vpn.shadowsocks.core.net.DnsResolverCompat
import org.amnezia.vpn.shadowsocks.core.utils.readableMessage
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.launch
import org.xbill.DNS.Message
import org.xbill.DNS.Rcode
import timber.log.Timber
import java.io.DataInputStream
import java.io.DataOutputStream
import java.io.File
import java.io.IOException
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)
}
}
}
}
}
}
@@ -21,80 +21,109 @@
package org.amnezia.vpn.shadowsocks.core.bg package org.amnezia.vpn.shadowsocks.core.bg
import android.content.Context import android.content.Context
import android.util.Base64
import org.amnezia.vpn.shadowsocks.core.Core.app
import org.amnezia.vpn.shadowsocks.core.acl.Acl import org.amnezia.vpn.shadowsocks.core.acl.Acl
import org.amnezia.vpn.shadowsocks.core.acl.AclSyncer import org.amnezia.vpn.shadowsocks.core.acl.AclSyncer
import org.amnezia.vpn.shadowsocks.core.R
import org.amnezia.vpn.shadowsocks.core.database.Profile import org.amnezia.vpn.shadowsocks.core.database.Profile
import org.amnezia.vpn.shadowsocks.core.database.ProfileManager import org.amnezia.vpn.shadowsocks.plugin.PluginConfiguration
import org.amnezia.vpn.shadowsocks.core.plugin.PluginConfiguration import org.amnezia.vpn.shadowsocks.plugin.PluginManager
import org.amnezia.vpn.shadowsocks.core.plugin.PluginManager
import org.amnezia.vpn.shadowsocks.core.preference.DataStore import org.amnezia.vpn.shadowsocks.core.preference.DataStore
import org.amnezia.vpn.shadowsocks.core.utils.DirectBoot import kotlinx.coroutines.CoroutineScope
import org.amnezia.vpn.shadowsocks.core.utils.parseNumericAddress import org.json.JSONArray
import kotlinx.coroutines.* import org.json.JSONObject
import java.io.File import java.io.File
import java.io.IOException import java.net.URI
import java.net.UnknownHostException import java.net.URISyntaxException
/** /**
* This class sets up environment for ss-local. * This class sets up environment for ss-local.
*/ */
class ProxyInstance(val profile: Profile, private val route: String = profile.route) { class ProxyInstance(val profile: Profile, private val route: String = profile.route) {
init {
require(profile.host.isNotEmpty() && (profile.method == "none" || profile.password.isNotEmpty())) {
app.getString(R.string.proxy_empty)
}
// check the crypto
require(profile.method !in arrayOf("aes-192-gcm", "chacha20", "salsa20")) {
"cipher ${profile.method} is deprecated."
}
// check the key format for aead-2022-cipher
require(profile.method !in setOf(
"2022-blake3-aes-128-gcm",
"2022-blake3-aes-256-gcm",
"2022-blake3-chacha20-poly1305",
) || Base64.decode(profile.password, Base64.DEFAULT).size in arrayOf(16, 32)) {
"The Base64 Key is invalid."
}
}
private var configFile: File? = null private var configFile: File? = null
var trafficMonitor: TrafficMonitor? = null var trafficMonitor: TrafficMonitor? = null
private val plugin = PluginConfiguration(profile.plugin ?: "").selectedOptions val plugin by lazy { PluginManager.init(PluginConfiguration(profile.plugin ?: "")) }
val pluginPath by lazy { PluginManager.init(plugin) }
suspend fun init(service: BaseService.Interface) {
if (route == Acl.CUSTOM_RULES) withContext(Dispatchers.IO) {
Acl.save(Acl.CUSTOM_RULES, Acl.customRules.flatten(10, service::openConnection))
}
// it's hard to resolve DNS on a specific interface so we'll do it here
if (profile.host.parseNumericAddress() == null) {
while (true) try {
val io = GlobalScope.async(Dispatchers.IO) { service.resolver(profile.host) }
profile.host = io.await().firstOrNull()?.hostAddress ?: throw UnknownHostException()
return
} catch (e: UnknownHostException) {
// retries are only needed on Chrome OS where arc0 is brought up/down during VPN changes
if (!DataStore.hasArc0) throw e
Thread.yield()
}
}
}
/** /**
* Sensitive shadowsocks configuration file requires extra protection. It may be stored in encrypted storage or * Sensitive shadowsocks configuration file requires extra protection. It may be stored in encrypted storage or
* device storage, depending on which is currently available. * device storage, depending on which is currently available.
*/ */
fun start(service: BaseService.Interface, stat: File, configFile: File, extraFlag: String? = null) { fun start(service: BaseService.Interface, stat: File, configFile: File, mode: String, dnsRelay: Boolean = true) {
// setup traffic monitor path
trafficMonitor = TrafficMonitor(stat) trafficMonitor = TrafficMonitor(stat)
// init JSON config
this.configFile = configFile this.configFile = configFile
val config = profile.toJson() val config = profile.toJson()
if (pluginPath != null) config.put("plugin", pluginPath).put("plugin_opts", plugin.toString()) plugin?.let { (path, opts, isV2) ->
if (service.isVpnService) {
if (isV2) opts["__android_vpn"] = "" else config.put("plugin_args", JSONArray(arrayOf("-V")))
}
config.put("plugin", path).put("plugin_opts", opts.toString())
}
config.put("dns", "system")
config.put("locals", JSONArray().apply {
// local SOCKS5 proxy
put(JSONObject().apply {
put("local_address", DataStore.listenAddress)
put("local_port", DataStore.portProxy)
put("local_udp_address", DataStore.listenAddress)
put("local_udp_port", DataStore.portProxy)
put("mode", mode)
})
// local DNS proxy
if (dnsRelay) try {
URI("dns://${profile.remoteDns}")
} catch (e: URISyntaxException) {
throw BaseService.ExpectedExceptionWrapper(e)
}.let { dns ->
put(JSONObject().apply {
put("local_address", DataStore.listenAddress)
put("local_port", DataStore.portLocalDns)
put("local_dns_address", "local_dns_path")
put("remote_dns_address", dns.host ?: "0.0.0.0")
put("remote_dns_port", if (dns.port < 0) 53 else dns.port)
put("protocol", "dns")
})
}
})
configFile.writeText(config.toString()) configFile.writeText(config.toString())
val cmd = service.buildAdditionalArguments(arrayListOf( // build the command line
val cmd = arrayListOf(
File((service as Context).applicationInfo.nativeLibraryDir, Executable.SS_LOCAL).absolutePath, File((service as Context).applicationInfo.nativeLibraryDir, Executable.SS_LOCAL).absolutePath,
"-b", DataStore.listenAddress, "--stat-path", stat.absolutePath,
"-l", DataStore.portProxy.toString(), "-c", configFile.absolutePath,
"-t", "600", )
"-S", stat.absolutePath,
"-c", configFile.absolutePath)) if (service.isVpnService) cmd += "--vpn"
if (extraFlag != null) cmd.add(extraFlag) cmd += "--tcp-fast-open"
if (route != Acl.ALL) { if (route != Acl.ALL) {
cmd += "--acl" cmd += "--acl"
cmd += Acl.getFile(route).absolutePath cmd += Acl.getFile(route).absolutePath
} }
// for UDP profile, it's only going to operate in UDP relay mode-only so this flag has no effect
if (profile.route == Acl.ALL || profile.route == Acl.BYPASS_LAN) cmd += "-D"
if (DataStore.tcpFastOpen) cmd += "--fast-open"
service.data.processes!!.start(cmd) service.data.processes!!.start(cmd)
} }
@@ -105,22 +134,7 @@ class ProxyInstance(val profile: Profile, private val route: String = profile.ro
fun shutdown(scope: CoroutineScope) { fun shutdown(scope: CoroutineScope) {
trafficMonitor?.apply { trafficMonitor?.apply {
thread.shutdown(scope) thread.shutdown(scope)
// Make sure update total traffic when stopping the runner persistStats(profile.id) // Make sure update total traffic when stopping the runner
try {
// profile may have host, etc. modified and thus a re-fetch is necessary (possible race condition)
val profile = ProfileManager.getProfile(profile.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().filterNotNull().single { it.id == profile.id }
profile.tx += current.txTotal
profile.rx += current.rxTotal
profile.dirty = true
DirectBoot.update(profile)
DirectBoot.listenForUnlock()
}
} }
trafficMonitor = null trafficMonitor = null
configFile?.delete() // remove old config possibly in device storage configFile?.delete() // remove old config possibly in device storage
@@ -29,8 +29,8 @@ import android.content.Intent
class ProxyService : Service(), BaseService.Interface { class ProxyService : Service(), BaseService.Interface {
override val data = BaseService.Data(this) override val data = BaseService.Data(this)
override val tag: String get() = "ShadowsocksProxyService" override val tag: String get() = "ShadowsocksProxyService"
// override fun createNotification(profileName: String): ServiceNotification = fun createNotification(profileName: String): ServiceNotification =
// ServiceNotification(this, profileName, "service-proxy", true) ServiceNotification(this, profileName, "service-proxy", true)
override fun onBind(intent: Intent) = super.onBind(intent) override fun onBind(intent: Intent) = super.onBind(intent)
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int = override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int =
@@ -1,145 +1,124 @@
///******************************************************************************* /*******************************************************************************
// * * * *
// * Copyright (C) 2017 by Max Lv <max.c.lv@gmail.com> * * Copyright (C) 2017 by Max Lv <max.c.lv@gmail.com> *
// * Copyright (C) 2017 by Mygod Studio <contact-shadowsocks-android@mygod.be> * * Copyright (C) 2017 by Mygod Studio <contact-shadowsocks-android@mygod.be> *
// * * * *
// * This program is free software: you can redistribute it and/or modify * * This program is free software: you can redistribute it and/or modify *
// * it under the terms of the GNU General Public License as published by * * it under the terms of the GNU General Public License as published by *
// * the Free Software Foundation, either version 3 of the License, or * * the Free Software Foundation, either version 3 of the License, or *
// * (at your option) any later version. * * (at your option) any later version. *
// * * * *
// * This program is distributed in the hope that it will be useful, * * This program is distributed in the hope that it will be useful, *
// * but WITHOUT ANY WARRANTY; without even the implied warranty of * * but WITHOUT ANY WARRANTY; without even the implied warranty of *
// * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
// * GNU General Public License for more details. * * GNU General Public License for more details. *
// * * * *
// * You should have received a copy of the GNU General Public License * * You should have received a copy of the GNU General Public License *
// * along with this program. If not, see <http://www.gnu.org/licenses/>. * * along with this program. If not, see <http://www.gnu.org/licenses/>. *
// * * * *
// *******************************************************************************/ *******************************************************************************/
//
//package org.amnezia.vpn.shadowsocks.core.bg package org.amnezia.vpn.shadowsocks.core.bg
//
//import android.app.KeyguardManager import android.app.PendingIntent
//import android.app.NotificationManager import android.app.Service
//import android.app.PendingIntent import android.content.BroadcastReceiver
//import android.app.Service import android.content.Context
//import android.content.Context import android.content.Intent
//import android.content.Intent import android.content.IntentFilter
//import android.content.IntentFilter import android.os.Build
//import android.os.Build import android.os.PowerManager
//import android.os.PowerManager import android.text.format.Formatter
//import android.text.format.Formatter import androidx.core.app.NotificationCompat
//import androidx.core.app.NotificationCompat import androidx.core.app.ServiceCompat
//import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
//import androidx.core.content.getSystemService import androidx.core.content.getSystemService
//import org.amnezia.vpn.shadowsocks.core.Core import org.amnezia.vpn.shadowsocks.core.Core
//import org.amnezia.vpn.shadowsocks.core.aidl.IShadowsocksServiceCallback import org.amnezia.vpn.shadowsocks.core.aidl.IShadowsocksServiceCallback
//import org.amnezia.vpn.shadowsocks.core.aidl.TrafficStats import org.amnezia.vpn.shadowsocks.core.aidl.TrafficStats
//import org.amnezia.vpn.shadowsocks.core.R import org.amnezia.vpn.shadowsocks.core.R
//import org.amnezia.vpn.shadowsocks.core.utils.Action import org.amnezia.vpn.shadowsocks.core.utils.Action
//import org.amnezia.vpn.shadowsocks.core.utils.broadcastReceiver
// /**
///** * User can customize visibility of notification since Android 8.
// * Android < 8 VPN: always invisible because of VPN notification/icon * The default visibility:
// * Android < 8 other: only invisible in (possibly unsecure) lockscreen *
// * Android 8+: always visible due to system limitations * Android 8.x: always visible due to system limitations
// * (user can choose to hide the notification in secure lockscreen or anywhere) * VPN: always invisible because of VPN notification/icon
// */ * Other: always visible
//class ServiceNotification(private val service: BaseService.Interface, profileName: String, *
// channel: String, private val visible: Boolean = false) { * See also: https://github.com/aosp-mirror/platform_frameworks_base/commit/070d142993403cc2c42eca808ff3fafcee220ac4
// private val keyGuard = (service as Context).getSystemService<KeyguardManager>()!! */
// private val nm by lazy { (service as Context).getSystemService<NotificationManager>()!! } class ServiceNotification(private val service: BaseService.Interface, profileName: String,
// private val callback: IShadowsocksServiceCallback by lazy { channel: String, visible: Boolean = false) : BroadcastReceiver() {
// object : IShadowsocksServiceCallback.Stub() { private val callback: IShadowsocksServiceCallback by lazy {
// override fun stateChanged(state: Int, profileName: String?, msg: String?) { object : IShadowsocksServiceCallback.Stub() {
// when (state) { override fun stateChanged(state: Int, profileName: String?, msg: String?) { } // ignore
// BaseService.State.Connected.ordinal -> { override fun trafficUpdated(profileId: Long, stats: TrafficStats) {
// builder.setContentText("VPN Connected") if (profileId != 0L) return
// } builder.apply {
// BaseService.State.Stopped.ordinal -> { // setContentText((service as Context).getString(R.string.traffic,
// builder.setContentText("VPN Disconnected") // service.getString(R.string.speed, Formatter.formatFileSize(service, stats.txRate)),
// } // service.getString(R.string.speed, Formatter.formatFileSize(service, stats.rxRate))))
// } // setSubText(service.getString(R.string.traffic,
// } // ignore // Formatter.formatFileSize(service, stats.txTotal),
// override fun trafficUpdated(profileId: Long, stats: TrafficStats) { // Formatter.formatFileSize(service, stats.rxTotal)))
//// if (profileId != 0L) return }
//// service as Context show()
//// val txr = service.getString(R.string.speed, Formatter.formatFileSize(service, stats.txRate)) }
//// val rxr = service.getString(R.string.speed, Formatter.formatFileSize(service, stats.rxRate)) override fun trafficPersisted(profileId: Long) { }
//// builder.setContentText("$txr↑\t$rxr↓") }
//// style.bigText(service.getString(R.string.stat_summary, txr, rxr, }
//// Formatter.formatFileSize(service, stats.txTotal), private var callbackRegistered = false
//// Formatter.formatFileSize(service, stats.rxTotal)))
//// show() private val builder = NotificationCompat.Builder(service as Context, channel)
// } .setWhen(0)
// override fun trafficPersisted(profileId: Long) { } .setColor(ContextCompat.getColor(service, R.color.material_primary_500))
// } .setTicker(service.getString(R.string.forward_success))
// } .setContentTitle(profileName)
//// private val lockReceiver = broadcastReceiver { _, intent -> update(intent.action) } .setContentIntent(Core.configureIntent(service))
// private var callbackRegistered = false .setCategory(NotificationCompat.CATEGORY_SERVICE)
// .setPriority(if (visible) NotificationCompat.PRIORITY_LOW else NotificationCompat.PRIORITY_MIN)
// private val builder = NotificationCompat.Builder(service as Context, channel)
// .setWhen(0) init {
// .setColor(ContextCompat.getColor(service, R.color.material_primary_500)) service as Context
// .setTicker(service.getString(R.string.forward_success)) val closeAction = NotificationCompat.Action.Builder(
// .setContentTitle("AmneziaVPN -- testing") R.drawable.ic_navigation_close,
// .setContentIntent(Core.configureIntent(service)) service.getText(R.string.stop),
// .setSmallIcon(R.drawable.ic_amnezia_round) PendingIntent.getBroadcast(service, 0, Intent(Action.CLOSE).setPackage(service.packageName),
// private val style = NotificationCompat.BigTextStyle(builder).bigText("") PendingIntent.FLAG_IMMUTABLE)).apply {
// private var isVisible = true setAuthenticationRequired(true)
// setShowsUserInterface(false)
// init { }.build()
// service as Context if (Build.VERSION.SDK_INT < 24) builder.addAction(closeAction) else builder.addInvisibleAction(closeAction)
//// if (Build.VERSION.SDK_INT < 24) builder.addAction(R.drawable.ic_navigation_close, updateCallback(service.getSystemService<PowerManager>()?.isInteractive != false)
//// service.getString(R.string.stop), PendingIntent.getBroadcast(service, 0, Intent(Action.CLOSE), 0)) service.registerReceiver(this, IntentFilter().apply {
//// update(if (service.getSystemService<PowerManager>()?.isInteractive != false) addAction(Intent.ACTION_SCREEN_ON)
//// Intent.ACTION_SCREEN_ON else Intent.ACTION_SCREEN_OFF, true) addAction(Intent.ACTION_SCREEN_OFF)
//// service.registerReceiver(lockReceiver, IntentFilter().apply { })
//// addAction(Intent.ACTION_SCREEN_ON) show()
//// addAction(Intent.ACTION_SCREEN_OFF) }
//// if (visible && Build.VERSION.SDK_INT < 26) addAction(Intent.ACTION_USER_PRESENT)
//// }) override fun onReceive(context: Context, intent: Intent) {
// } if (service.data.state == BaseService.State.Connected) updateCallback(intent.action == Intent.ACTION_SCREEN_ON)
// }
//// private fun update(action: String?, forceShow: Boolean = false) {
//// if (forceShow || service.data.state == BaseService.State.Connected) when (action) { private fun updateCallback(screenOn: Boolean) {
//// Intent.ACTION_SCREEN_OFF -> { if (screenOn) {
//// setVisible(false, forceShow) service.data.binder.registerCallback(callback)
//// unregisterCallback() // unregister callback to save battery service.data.binder.startListeningForBandwidth(callback, 1000)
//// } callbackRegistered = true
//// Intent.ACTION_SCREEN_ON -> { } else if (callbackRegistered) { // unregister callback to save battery
//// setVisible(visible && !keyGuard.isKeyguardLocked, forceShow) service.data.binder.unregisterCallback(callback)
//// service.data.binder.registerCallback(callback) callbackRegistered = false
//// service.data.binder.startListeningForBandwidth(callback, 1000) }
//// callbackRegistered = true }
//// }
//// Intent.ACTION_USER_PRESENT -> setVisible(true, forceShow) private fun show() = (service as Service).startForeground(1, builder.build())
//// }
//// } fun destroy() {
// (service as Service).unregisterReceiver(this)
// private fun unregisterCallback() { updateCallback(false)
// if (callbackRegistered) { ServiceCompat.stopForeground(service, ServiceCompat.STOP_FOREGROUND_REMOVE)
// service.data.binder.unregisterCallback(callback) }
// callbackRegistered = false }
// }
// }
//
// private fun setVisible(visible: Boolean, forceShow: Boolean = false) {
// if (isVisible != visible) {
// isVisible = visible
// builder.priority = if (visible) NotificationCompat.PRIORITY_LOW else NotificationCompat.PRIORITY_MIN
// show()
// } else if (forceShow) show()
// }
//
//
// private fun show() = (service as Service).startForeground(1337, builder.build())
//
// fun destroy() {
//// (service as Service).unregisterReceiver(lockReceiver)
// unregisterCallback()
//// service.stopForeground(true)
// nm.cancel(1337)
// }
//}
@@ -23,7 +23,10 @@ package org.amnezia.vpn.shadowsocks.core.bg
import android.net.LocalSocket import android.net.LocalSocket
import android.os.SystemClock import android.os.SystemClock
import org.amnezia.vpn.shadowsocks.core.aidl.TrafficStats import org.amnezia.vpn.shadowsocks.core.aidl.TrafficStats
import org.amnezia.vpn.shadowsocks.core.database.ProfileManager
import org.amnezia.vpn.shadowsocks.core.net.LocalSocketListener import org.amnezia.vpn.shadowsocks.core.net.LocalSocketListener
import org.amnezia.vpn.shadowsocks.core.preference.DataStore
import org.amnezia.vpn.shadowsocks.core.utils.DirectBoot
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
import java.nio.ByteBuffer import java.nio.ByteBuffer
@@ -34,7 +37,11 @@ class TrafficMonitor(statFile: File) {
private val buffer = ByteArray(16) private val buffer = ByteArray(16)
private val stat = ByteBuffer.wrap(buffer).order(ByteOrder.LITTLE_ENDIAN) private val stat = ByteBuffer.wrap(buffer).order(ByteOrder.LITTLE_ENDIAN)
override fun acceptInternal(socket: LocalSocket) { override fun acceptInternal(socket: LocalSocket) {
if (socket.inputStream.read(buffer) != 16) throw IOException("Unexpected traffic stat length") when (val read = socket.inputStream.read(buffer)) {
-1 -> return
16 -> { }
else -> throw IOException("Unexpected traffic stat length $read")
}
val tx = stat.getLong(0) val tx = stat.getLong(0)
val rx = stat.getLong(8) val rx = stat.getLong(8)
if (current.txTotal != tx) { if (current.txTotal != tx) {
@@ -52,6 +59,7 @@ class TrafficMonitor(statFile: File) {
var out = TrafficStats() var out = TrafficStats()
private var timestampLast = 0L private var timestampLast = 0L
private var dirty = false private var dirty = false
private var persisted: TrafficStats? = null
fun requestUpdate(): Pair<TrafficStats, Boolean> { fun requestUpdate(): Pair<TrafficStats, Boolean> {
val now = SystemClock.elapsedRealtime() val now = SystemClock.elapsedRealtime()
@@ -79,4 +87,25 @@ class TrafficMonitor(statFile: File) {
} }
return Pair(out, updated) return Pair(out, updated)
} }
fun persistStats(id: Long) {
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()
}
}
} }
@@ -26,19 +26,18 @@ import org.amnezia.vpn.shadowsocks.core.Core
import org.amnezia.vpn.shadowsocks.core.preference.DataStore import org.amnezia.vpn.shadowsocks.core.preference.DataStore
import java.io.File import java.io.File
class TransproxyService : Service(), LocalDnsService.Interface { class TransproxyService : Service(), BaseService.Interface {
override val data = BaseService.Data(this) override val data = BaseService.Data(this)
override val tag: String get() = "ShadowsocksTransproxyService" override val tag: String get() = "ShadowsocksTransproxyService"
// override fun createNotification(profileName: String): ServiceNotification = fun createNotification(profileName: String): ServiceNotification =
// ServiceNotification(this, profileName, "service-transproxy", true) ServiceNotification(this, profileName, "service-transproxy", true)
override fun onBind(intent: Intent) = super.onBind(intent) override fun onBind(intent: Intent) = super.onBind(intent)
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int = override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int =
super<LocalDnsService.Interface>.onStartCommand(intent, flags, startId) super<BaseService.Interface>.onStartCommand(intent, flags, startId)
private fun startRedsocksDaemon() { private fun startRedsocksDaemon() {
File(Core.deviceStorage.noBackupFilesDir, "redsocks.conf").writeText( File(Core.deviceStorage.noBackupFilesDir, "redsocks.conf").writeText("""base {
"""base {
log_debug = off; log_debug = off;
log_info = off; log_info = off;
log = stderr; log = stderr;
@@ -52,15 +51,9 @@ redsocks {
port = ${DataStore.portProxy}; port = ${DataStore.portProxy};
type = socks5; type = socks5;
} }
""" """)
) data.processes!!.start(listOf(
data.processes!!.start( File(applicationInfo.nativeLibraryDir, Executable.REDSOCKS).absolutePath, "-c", "redsocks.conf"))
listOf(
File(applicationInfo.nativeLibraryDir, Executable.REDSOCKS).absolutePath,
"-c",
"redsocks.conf"
)
)
} }
override suspend fun startProcesses() { override suspend fun startProcesses() {
@@ -30,29 +30,29 @@ import android.os.Build
import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor
import android.system.ErrnoException import android.system.ErrnoException
import android.system.Os import android.system.Os
import android.util.Log import android.system.OsConstants
import org.amnezia.vpn.shadowsocks.core.Core import org.amnezia.vpn.shadowsocks.core.Core
import org.amnezia.vpn.shadowsocks.core.R
import org.amnezia.vpn.shadowsocks.core.VpnRequestActivity import org.amnezia.vpn.shadowsocks.core.VpnRequestActivity
import org.amnezia.vpn.shadowsocks.core.acl.Acl import org.amnezia.vpn.shadowsocks.core.acl.Acl
import org.amnezia.vpn.shadowsocks.core.R
import org.amnezia.vpn.shadowsocks.core.net.ConcurrentLocalSocketListener import org.amnezia.vpn.shadowsocks.core.net.ConcurrentLocalSocketListener
import org.amnezia.vpn.shadowsocks.core.net.DefaultNetworkListener import org.amnezia.vpn.shadowsocks.core.net.DefaultNetworkListener
import org.amnezia.vpn.shadowsocks.core.net.DnsResolverCompat
import org.amnezia.vpn.shadowsocks.core.net.Subnet import org.amnezia.vpn.shadowsocks.core.net.Subnet
import org.amnezia.vpn.shadowsocks.core.preference.DataStore import org.amnezia.vpn.shadowsocks.core.preference.DataStore
import org.amnezia.vpn.shadowsocks.core.utils.Key import org.amnezia.vpn.shadowsocks.core.utils.Key
import org.amnezia.vpn.shadowsocks.core.utils.printLog import org.amnezia.vpn.shadowsocks.core.utils.int
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.Closeable import timber.log.Timber
import java.io.File import java.io.File
import java.io.FileDescriptor import java.io.FileDescriptor
import java.io.IOException import java.io.IOException
import java.net.URL import java.net.URL
import java.util.*
import android.net.VpnService as BaseVpnService import android.net.VpnService as BaseVpnService
open class ShadowsocksVpnService : BaseVpnService(), LocalDnsService.Interface { class VpnService : BaseVpnService(), BaseService.Interface {
companion object { companion object {
private const val VPN_MTU = 1500 private const val VPN_MTU = 1500
private const val PRIVATE_VLAN4_CLIENT = "172.19.0.1" private const val PRIVATE_VLAN4_CLIENT = "172.19.0.1"
@@ -60,69 +60,69 @@ open class ShadowsocksVpnService : BaseVpnService(), LocalDnsService.Interface {
private const val PRIVATE_VLAN6_CLIENT = "fdfe:dcba:9876::1" private const val PRIVATE_VLAN6_CLIENT = "fdfe:dcba:9876::1"
private const val PRIVATE_VLAN6_ROUTER = "fdfe:dcba:9876::2" private const val PRIVATE_VLAN6_ROUTER = "fdfe:dcba:9876::2"
/** private fun <T> FileDescriptor.use(block: (FileDescriptor) -> T) = try {
* https://android.googlesource.com/platform/prebuilts/runtime/+/94fec32/appcompat/hiddenapi-light-greylist.txt#9466 block(this)
*/ } finally {
private val getInt = FileDescriptor::class.java.getDeclaredMethod("getInt$") try {
Os.close(this)
} catch (_: ErrnoException) { }
} }
class CloseableFd(val fd: FileDescriptor) : Closeable {
override fun close() = Os.close(fd)
} }
private inner class ProtectWorker : ConcurrentLocalSocketListener("ShadowsocksVpnThread", private inner class ProtectWorker : ConcurrentLocalSocketListener("ShadowsocksVpnThread",
File(Core.deviceStorage.noBackupFilesDir, "protect_path")) { File(Core.deviceStorage.noBackupFilesDir, "protect_path")) {
override fun acceptInternal(socket: LocalSocket) { override fun acceptInternal(socket: LocalSocket) {
socket.inputStream.read() if (socket.inputStream.read() == -1) return
val fd = socket.ancillaryFileDescriptors!!.single()!! val success = socket.ancillaryFileDescriptors!!.single()!!.use { fd ->
CloseableFd(fd).use { underlyingNetwork.let { network ->
socket.outputStream.write(if (underlyingNetwork.let { network -> if (network != null) try {
if (network != null && Build.VERSION.SDK_INT >= 23) try {
network.bindSocket(fd) network.bindSocket(fd)
true return@let true
} catch (e: IOException) { } catch (e: IOException) {
// suppress ENONET (Machine is not on the network) when ((e.cause as? ErrnoException)?.errno) {
if ((e.cause as? ErrnoException)?.errno != 64) printLog(e) OsConstants.EPERM, OsConstants.EACCES, 64 -> Timber.d(e)
false else -> Timber.w(e)
} else protect(getInt.invoke(fd) as Int)
}) 0 else 1)
} }
return@let false
}
protect(fd.int)
}
}
try {
socket.outputStream.write(if (success) 0 else 1)
} catch (_: IOException) { } // ignore connection early close
} }
} }
inner class NullConnectionException : NullPointerException() { inner class NullConnectionException : NullPointerException(), BaseService.ExpectedException {
override fun getLocalizedMessage() = getString(R.string.reboot_required) override fun getLocalizedMessage() = getString(R.string.reboot_required)
} }
override val data = BaseService.Data(this) override val data = BaseService.Data(this)
override val tag: String get() = "ShadowsocksVpnService" override val tag: String get() = "ShadowsocksVpnService"
fun createNotification(profileName: String): ServiceNotification =
val NOTIFICATION_CHANNEL_ID = "com.amnezia.vpnNotification" ServiceNotification(this, profileName, "service-vpn")
// override fun createNotification(profileName: String): ServiceNotification =
// ServiceNotification(this, profileName, NOTIFICATION_CHANNEL_ID)
private var conn: ParcelFileDescriptor? = null private var conn: ParcelFileDescriptor? = null
private var worker: ProtectWorker? = null private var worker: ProtectWorker? = null
private var active = false private var active = false
private var metered = false private var metered = false
@Volatile
private var underlyingNetwork: Network? = null private var underlyingNetwork: Network? = null
set(value) { set(value) {
field = value field = value
if (active && Build.VERSION.SDK_INT >= 22) setUnderlyingNetworks(underlyingNetworks) if (active) setUnderlyingNetworks(underlyingNetworks)
} }
private val underlyingNetworks private val underlyingNetworks get() =
get() = // clearing underlyingNetworks makes Android 9 consider the network to be metered
// clearing underlyingNetworks makes Android 9+ consider the network to be metered if (Build.VERSION.SDK_INT == 28 && metered) null else underlyingNetwork?.let { arrayOf(it) }
if (Build.VERSION.SDK_INT >= 28 && metered) null else underlyingNetwork?.let { arrayOf(it) }
override fun onBind(intent: Intent) = when (intent.action) { override fun onBind(intent: Intent) = when (intent.action) {
SERVICE_INTERFACE -> super<BaseVpnService>.onBind(intent) SERVICE_INTERFACE -> super<BaseVpnService>.onBind(intent)
else -> super<LocalDnsService.Interface>.onBind(intent) else -> super<BaseService.Interface>.onBind(intent)
} }
override fun onRevoke() { override fun onRevoke() = stopRunner()
stopRunner()
}
override fun killProcesses(scope: CoroutineScope) { override fun killProcesses(scope: CoroutineScope) {
super.killProcesses(scope) super.killProcesses(scope)
@@ -138,14 +138,17 @@ open class ShadowsocksVpnService : BaseVpnService(), LocalDnsService.Interface {
if (DataStore.serviceMode == Key.modeVpn) { if (DataStore.serviceMode == Key.modeVpn) {
if (prepare(this) != null) { if (prepare(this) != null) {
startActivity(Intent(this, VpnRequestActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) startActivity(Intent(this, VpnRequestActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
} else return super<LocalDnsService.Interface>.onStartCommand(intent, flags, startId) } else return super<BaseService.Interface>.onStartCommand(intent, flags, startId)
} }
stopRunner() stopRunner()
return Service.START_STICKY return Service.START_NOT_STICKY
} }
override suspend fun preInit() = DefaultNetworkListener.start(this) { underlyingNetwork = it } override suspend fun preInit() = DefaultNetworkListener.start(this) { underlyingNetwork = it }
override suspend fun resolver(host: String) = DefaultNetworkListener.get().getAllByName(host) override 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)
override suspend fun openConnection(url: URL) = DefaultNetworkListener.get().openConnection(url) override suspend fun openConnection(url: URL) = DefaultNetworkListener.get().openConnection(url)
override suspend fun startProcesses() { override suspend fun startProcesses() {
@@ -154,10 +157,7 @@ open class ShadowsocksVpnService : BaseVpnService(), LocalDnsService.Interface {
sendFd(startVpn()) sendFd(startVpn())
} }
override fun buildAdditionalArguments(cmd: ArrayList<String>): ArrayList<String> { override val isVpnService get() = true
cmd += "-V"
return cmd
}
private suspend fun startVpn(): FileDescriptor { private suspend fun startVpn(): FileDescriptor {
val profile = data.proxy!!.profile val profile = data.proxy!!.profile
@@ -168,12 +168,10 @@ open class ShadowsocksVpnService : BaseVpnService(), LocalDnsService.Interface {
.addAddress(PRIVATE_VLAN4_CLIENT, 30) .addAddress(PRIVATE_VLAN4_CLIENT, 30)
.addDnsServer(PRIVATE_VLAN4_ROUTER) .addDnsServer(PRIVATE_VLAN4_ROUTER)
if (profile.ipv6) { if (profile.ipv6) builder.addAddress(PRIVATE_VLAN6_CLIENT, 126)
builder.addAddress(PRIVATE_VLAN6_CLIENT, 126)
builder.addRoute("::", 0)
}
val me = packageName
if (profile.proxyApps) { if (profile.proxyApps) {
val me = packageName
profile.individual.split('\n') profile.individual.split('\n')
.filter { it != me } .filter { it != me }
.forEach { .forEach {
@@ -181,30 +179,32 @@ open class ShadowsocksVpnService : BaseVpnService(), LocalDnsService.Interface {
if (profile.bypass) builder.addDisallowedApplication(it) if (profile.bypass) builder.addDisallowedApplication(it)
else builder.addAllowedApplication(it) else builder.addAllowedApplication(it)
} catch (ex: PackageManager.NameNotFoundException) { } catch (ex: PackageManager.NameNotFoundException) {
printLog(ex) Timber.w(ex)
} }
} }
if (profile.bypass) { if (!profile.bypass) builder.addAllowedApplication(me)
builder.addDisallowedApplication(me)
}
} else {
builder.addDisallowedApplication(me)
} }
when (profile.route) { when (profile.route) {
Acl.ALL, Acl.BYPASS_CHN, Acl.CUSTOM_RULES -> builder.addRoute("0.0.0.0", 0) Acl.ALL, Acl.BYPASS_CHN, Acl.CUSTOM_RULES -> {
builder.addRoute("0.0.0.0", 0)
if (profile.ipv6) builder.addRoute("::", 0)
}
else -> { else -> {
resources.getStringArray(R.array.bypass_private_route).forEach { resources.getStringArray(R.array.bypass_private_route).forEach {
val subnet = Subnet.fromString(it)!! val subnet = Subnet.fromString(it)!!
builder.addRoute(subnet.address.hostAddress, subnet.prefixSize) builder.addRoute(subnet.address.hostAddress!!, subnet.prefixSize)
} }
builder.addRoute(PRIVATE_VLAN4_ROUTER, 32) builder.addRoute(PRIVATE_VLAN4_ROUTER, 32)
// https://issuetracker.google.com/issues/149636790
if (profile.ipv6) builder.addRoute("2000::", 3)
} }
} }
metered = profile.metered metered = profile.metered
active = true // possible race condition here? active = true // possible race condition here?
if (Build.VERSION.SDK_INT >= 22) builder.setUnderlyingNetworks(underlyingNetworks) builder.setUnderlyingNetworks(underlyingNetworks)
if (Build.VERSION.SDK_INT >= 29) builder.setMetered(metered)
val conn = builder.establish() ?: throw NullConnectionException() val conn = builder.establish() ?: throw NullConnectionException()
this.conn = conn this.conn = conn
@@ -225,7 +225,6 @@ open class ShadowsocksVpnService : BaseVpnService(), LocalDnsService.Interface {
try { try {
sendFd(conn.fileDescriptor) sendFd(conn.fileDescriptor)
} catch (e: ErrnoException) { } catch (e: ErrnoException) {
e.printStackTrace()
stopRunner(false, e.message) stopRunner(false, e.message)
} }
}) })
@@ -118,8 +118,10 @@ class KeyValuePair() {
fun put(value: Set<String>): KeyValuePair { fun put(value: Set<String>): KeyValuePair {
valueType = TYPE_STRING_SET valueType = TYPE_STRING_SET
val stream = ByteArrayOutputStream() val stream = ByteArrayOutputStream()
val intBuffer = ByteBuffer.allocate(4)
for (v in value) { for (v in value) {
stream.write(ByteBuffer.allocate(4).putInt(v.length).array()) intBuffer.rewind()
stream.write(intBuffer.putInt(v.length).array())
stream.write(v.toByteArray()) stream.write(v.toByteArray())
} }
this.value = stream.toByteArray() this.value = stream.toByteArray()
@@ -23,25 +23,32 @@ package org.amnezia.vpn.shadowsocks.core.database
import androidx.room.Database import androidx.room.Database
import androidx.room.Room import androidx.room.Room
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import androidx.room.migration.Migration import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase import androidx.sqlite.db.SupportSQLiteDatabase
import org.amnezia.vpn.shadowsocks.core.Core.app import org.amnezia.vpn.shadowsocks.core.Core.app
import org.amnezia.vpn.shadowsocks.core.database.migration.RecreateSchemaMigration import org.amnezia.vpn.shadowsocks.core.database.migration.RecreateSchemaMigration
import org.amnezia.vpn.shadowsocks.core.utils.Key import org.amnezia.vpn.shadowsocks.core.utils.Key
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
@Database(entities = [Profile::class, KeyValuePair::class], version = 29) @Database(entities = [Profile::class, KeyValuePair::class], version = 29)
@TypeConverters(Profile.SubscriptionStatus::class)
abstract class PrivateDatabase : RoomDatabase() { abstract class PrivateDatabase : RoomDatabase() {
companion object { companion object {
private val instance by lazy { private val instance by lazy {
Room.databaseBuilder(app, PrivateDatabase::class.java, Key.DB_PROFILE) Room.databaseBuilder(app, PrivateDatabase::class.java, Key.DB_PROFILE).apply {
.addMigrations( addMigrations(
Migration26, Migration26,
Migration27, Migration27,
Migration28 Migration28,
Migration29
) )
.fallbackToDestructiveMigration() allowMainThreadQueries()
.allowMainThreadQueries() enableMultiInstanceInvalidation()
.build() fallbackToDestructiveMigration()
setQueryExecutor { GlobalScope.launch { it.run() } }
}.build()
} }
val profileDao get() = instance.profileDao() val profileDao get() = instance.profileDao()
@@ -66,4 +73,9 @@ abstract class PrivateDatabase : RoomDatabase() {
override fun migrate(database: SupportSQLiteDatabase) = override fun migrate(database: SupportSQLiteDatabase) =
database.execSQL("ALTER TABLE `Profile` ADD COLUMN `metered` INTEGER NOT NULL DEFAULT 0") database.execSQL("ALTER TABLE `Profile` ADD COLUMN `metered` INTEGER NOT NULL DEFAULT 0")
} }
object Migration29 : Migration(28, 29) {
override fun migrate(database: SupportSQLiteDatabase) =
database.execSQL("ALTER TABLE `Profile` ADD COLUMN `subscription` INTEGER NOT NULL DEFAULT " +
Profile.SubscriptionStatus.UserConfigured.persistedValue)
}
} }
@@ -24,20 +24,22 @@ import android.annotation.TargetApi
import android.net.Uri import android.net.Uri
import android.os.Parcelable import android.os.Parcelable
import android.util.Base64 import android.util.Base64
import android.util.Log
import android.util.LongSparseArray import android.util.LongSparseArray
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.room.* import androidx.room.*
import org.amnezia.vpn.shadowsocks.core.plugin.PluginConfiguration import org.amnezia.vpn.shadowsocks.plugin.PluginConfiguration
import org.amnezia.vpn.shadowsocks.plugin.PluginOptions import org.amnezia.vpn.shadowsocks.plugin.PluginOptions
import org.amnezia.vpn.shadowsocks.core.preference.DataStore import org.amnezia.vpn.shadowsocks.core.preference.DataStore
import org.amnezia.vpn.shadowsocks.core.utils.Key import org.amnezia.vpn.shadowsocks.core.utils.Key
import org.amnezia.vpn.shadowsocks.core.utils.asIterable
import org.amnezia.vpn.shadowsocks.core.utils.parsePort import org.amnezia.vpn.shadowsocks.core.utils.parsePort
import kotlinx.android.parcel.Parcelize import com.google.gson.JsonArray
import com.google.gson.JsonElement
import com.google.gson.JsonObject
import com.google.gson.JsonPrimitive
import kotlinx.parcelize.Parcelize
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import org.json.JSONTokener import timber.log.Timber
import java.io.Serializable import java.io.Serializable
import java.net.URI import java.net.URI
import java.net.URISyntaxException import java.net.URISyntaxException
@@ -48,34 +50,59 @@ import java.util.*
data class Profile( data class Profile(
@PrimaryKey(autoGenerate = true) @PrimaryKey(autoGenerate = true)
var id: Long = 0, var id: Long = 0,
// user configurable fields
var name: String? = "", var name: String? = "",
var host: String = "155.94.174.51",
var remotePort: Int = 444, var host: String = "example.shadowsocks.org",
var password: String = "789456123", var remotePort: Int = 8388,
var password: String = "u1rRWTssNv0p",
var method: String = "aes-256-cfb", var method: String = "aes-256-cfb",
var route: String = "all", var route: String = "all",
var remoteDns: String = "dns.google", var remoteDns: String = "dns.google",
var proxyApps: Boolean = false, var proxyApps: Boolean = false,
var bypass: Boolean = false, var bypass: Boolean = false,
var udpdns: Boolean = false, var udpdns: Boolean = false,
var ipv6: Boolean = true, var ipv6: Boolean = false,
@TargetApi(28) @TargetApi(28)
var metered: Boolean = false, var metered: Boolean = false,
var individual: String = "", var individual: String = "",
var plugin: String? = null,
var udpFallback: Long? = null,
// managed fields
var subscription: SubscriptionStatus = SubscriptionStatus.UserConfigured,
var tx: Long = 0, var tx: Long = 0,
var rx: Long = 0, var rx: Long = 0,
var userOrder: Long = 0, var userOrder: Long = 0,
var plugin: String? = null,
var udpFallback: Long? = null,
@Ignore // not persisted in db, only used by direct boot @Ignore // not persisted in db, only used by direct boot
var dirty: Boolean = false var dirty: Boolean = false
) : Parcelable, Serializable { ) : Parcelable, Serializable {
enum class SubscriptionStatus(val persistedValue: Int) {
UserConfigured(0),
Active(1),
/**
* This profile is no longer present in subscriptions.
*/
Obsolete(2),
;
companion object {
@JvmStatic
@TypeConverter
fun of(value: Int) = values().single { it.persistedValue == value }
@JvmStatic
@TypeConverter
fun toInt(status: SubscriptionStatus) = status.persistedValue
}
}
companion object { companion object {
private const val TAG = "ShadowParser"
private const val serialVersionUID = 1L private const val serialVersionUID = 1L
private val pattern = private val pattern =
"""(?i)ss://[-a-zA-Z0-9+&@#/%?=.~*'()|!:,;\[\]]*[-a-zA-Z0-9+&@#/%=.~*'()|\[\]]""".toRegex() """(?i)ss://[-a-zA-Z0-9+&@#/%?=.~*'()|!:,;_\[\]]*[-a-zA-Z0-9+&@#/%=.~*'()|\[\]]""".toRegex()
private val userInfoPattern = "^(.+?):(.*)$".toRegex() private val userInfoPattern = "^(.+?):(.*)$".toRegex()
private val legacyPattern = "^(.+?):(.*)@(.+?):(\\d+?)$".toRegex() private val legacyPattern = "^(.+?):(.*)@(.+?):(\\d+?)$".toRegex()
@@ -87,7 +114,7 @@ data class Profile(
if (match != null) { if (match != null) {
val profile = Profile() val profile = Profile()
feature?.copyFeatureSettingsTo(profile) feature?.copyFeatureSettingsTo(profile)
profile.method = match.groupValues[1].toLowerCase() profile.method = match.groupValues[1].lowercase(Locale.ENGLISH)
profile.password = match.groupValues[2] profile.password = match.groupValues[2]
profile.host = match.groupValues[3] profile.host = match.groupValues[3]
profile.remotePort = match.groupValues[4].toInt() profile.remotePort = match.groupValues[4].toInt()
@@ -95,7 +122,7 @@ data class Profile(
profile.name = uri.fragment profile.name = uri.fragment
profile profile
} else { } else {
Log.e(TAG, "Unrecognized URI: ${it.value}") Timber.e("Unrecognized URI: ${it.value}")
null null
} }
} else { } else {
@@ -110,38 +137,50 @@ data class Profile(
try { try {
val javaURI = URI(it.value) val javaURI = URI(it.value)
profile.host = javaURI.host ?: "" profile.host = javaURI.host ?: ""
if (profile.host.firstOrNull() == '[' && profile.host.lastOrNull() == ']') if (profile.host.firstOrNull() == '[' && profile.host.lastOrNull() == ']') {
profile.host = profile.host.substring(1, profile.host.length - 1) profile.host = profile.host.substring(1, profile.host.length - 1)
}
profile.remotePort = javaURI.port profile.remotePort = javaURI.port
profile.plugin = uri.getQueryParameter(Key.plugin) profile.plugin = uri.getQueryParameter(Key.plugin)
profile.name = uri.fragment ?: "" profile.name = uri.fragment ?: ""
profile profile
} catch (e: URISyntaxException) { } catch (e: URISyntaxException) {
Log.e(TAG, "Invalid URI: ${it.value}") Timber.e("Invalid URI: ${it.value}")
null null
} }
} else { } else {
Log.e(TAG, "Unknown user info: ${it.value}") Timber.e("Unknown user info: ${it.value}")
null null
} }
} }
} catch (e: IllegalArgumentException) { } catch (e: IllegalArgumentException) {
Log.e(TAG, "Invalid base64 detected: ${it.value}") Timber.e("Invalid base64 detected: ${it.value}")
null null
} }
}.filterNotNull() }.filterNotNull()
private class JsonParser(private val feature: Profile? = null) : ArrayList<Profile>() { private class JsonParser(private val feature: Profile? = null) : ArrayList<Profile>() {
private val fallbackMap = mutableMapOf<Profile, Profile>() val fallbackMap = mutableMapOf<Profile, Profile>()
private fun tryParse(json: JSONObject, fallback: Boolean = false): Profile? { private val JsonElement?.optString get() = (this as? JsonPrimitive)?.asString
val host = json.optString("server") private val JsonElement?.optBoolean
get() = // asBoolean attempts to cast everything to boolean
(this as? JsonPrimitive)?.run { if (isBoolean) asBoolean else null }
private val JsonElement?.optInt
get() = try {
(this as? JsonPrimitive)?.asInt
} catch (_: NumberFormatException) {
null
}
private fun tryParse(json: JsonObject, fallback: Boolean = false): Profile? {
val host = json["server"].optString
if (host.isNullOrEmpty()) return null if (host.isNullOrEmpty()) return null
val remotePort = json.optInt("server_port") val remotePort = json["server_port"]?.optInt
if (remotePort <= 0) return null if (remotePort == null || remotePort <= 0) return null
val password = json.optString("password") val password = json["password"].optString
if (password.isNullOrEmpty()) return null if (password.isNullOrEmpty()) return null
val method = json.optString("method") val method = json["method"].optString
if (method.isNullOrEmpty()) return null if (method.isNullOrEmpty()) return null
return Profile().also { return Profile().also {
it.host = host it.host = host
@@ -150,37 +189,39 @@ data class Profile(
it.method = method it.method = method
}.apply { }.apply {
feature?.copyFeatureSettingsTo(this) feature?.copyFeatureSettingsTo(this)
val id = json.optString("plugin") val id = json["plugin"].optString
if (!id.isNullOrEmpty()) { if (!id.isNullOrEmpty()) {
plugin = PluginOptions(id, json.optString("plugin_opts")).toString(false) plugin = PluginOptions(id, json["plugin_opts"].optString).toString(false)
} }
name = json.optString("remarks") name = json["remarks"].optString
route = json.optString("route", route) route = json["route"].optString ?: route
if (fallback) return@apply if (fallback) return@apply
remoteDns = json.optString("remote_dns", remoteDns) remoteDns = json["remote_dns"].optString ?: remoteDns
ipv6 = json.optBoolean("ipv6", ipv6) ipv6 = json["ipv6"].optBoolean ?: ipv6
metered = json.optBoolean("metered", metered) metered = json["metered"].optBoolean ?: metered
json.optJSONObject("proxy_apps")?.also { (json["proxy_apps"] as? JsonObject)?.also {
proxyApps = it.optBoolean("enabled", proxyApps) proxyApps = it["enabled"].optBoolean ?: proxyApps
bypass = it.optBoolean("bypass", bypass) bypass = it["bypass"].optBoolean ?: bypass
individual = it.optJSONArray("android_list")?.asIterable()?.joinToString("\n") ?: individual individual = (it["android_list"] as? JsonArray)?.asIterable()?.mapNotNull { it.optString }
?.joinToString("\n") ?: individual
} }
udpdns = json.optBoolean("udpdns", udpdns) udpdns = json["udpdns"].optBoolean ?: udpdns
json.optJSONObject("udp_fallback")?.let { tryParse(it, true) }?.also { fallbackMap[this] = it } (json["udp_fallback"] as? JsonObject)?.let { tryParse(it, true) }?.also { fallbackMap[this] = it }
} }
} }
fun process(json: Any) { fun process(json: JsonElement?) {
when (json) { when (json) {
is JSONObject -> { is JsonObject -> {
val profile = tryParse(json) val profile = tryParse(json)
if (profile != null) add(profile) else for (key in json.keys()) process(json.get(key)) if (profile != null) add(profile) else for ((_, value) in json.entrySet()) process(value)
} }
is JSONArray -> json.asIterable().forEach(this::process) is JsonArray -> json.asIterable().forEach(this::process)
// ignore other types // ignore other types
} }
} }
fun finalize(create: (Profile) -> Unit) {
fun finalize(create: (Profile) -> Profile) {
val profiles = ProfileManager.getAllProfiles() ?: emptyList() val profiles = ProfileManager.getAllProfiles() ?: emptyList()
for ((profile, fallback) in fallbackMap) { for ((profile, fallback) in fallbackMap) {
val match = profiles.firstOrNull { val match = profiles.firstOrNull {
@@ -188,28 +229,35 @@ data class Profile(
fallback.password == it.password && fallback.method == it.method && fallback.password == it.password && fallback.method == it.method &&
it.plugin.isNullOrEmpty() it.plugin.isNullOrEmpty()
} }
profile.udpFallback = if (match == null) { profile.udpFallback = (match ?: create(fallback)).id
create(fallback)
fallback.id
} else match.id
ProfileManager.updateProfile(profile) ProfileManager.updateProfile(profile)
} }
} }
} }
fun parseJson(json: String, feature: Profile? = null, create: (Profile) -> Unit) = JsonParser(feature).run {
process(JSONTokener(json).nextValue()) fun parseJson(json: JsonElement, feature: Profile? = null, create: (Profile) -> Profile) {
for (profile in this) create(profile) JsonParser(feature).run {
process(json)
for (i in indices) {
val fallback = fallbackMap.remove(this[i])
this[i] = create(this[i])
fallback?.also { fallbackMap[this[i]] = it }
}
finalize(create) finalize(create)
} }
} }
}
@androidx.room.Dao @androidx.room.Dao
interface Dao { interface Dao {
@Query("SELECT * FROM `Profile` WHERE `id` = :id") @Query("SELECT * FROM `Profile` WHERE `id` = :id")
operator fun get(id: Long): Profile? operator fun get(id: Long): Profile?
@Query("SELECT * FROM `Profile` ORDER BY `userOrder`") @Query("SELECT * FROM `Profile` WHERE `Subscription` != 2 ORDER BY `userOrder`")
fun list(): List<Profile> fun listActive(): List<Profile>
@Query("SELECT * FROM `Profile`")
fun listAll(): List<Profile>
@Query("SELECT MAX(`userOrder`) + 1 FROM `Profile`") @Query("SELECT MAX(`userOrder`) + 1 FROM `Profile`")
fun nextOrder(): Long? fun nextOrder(): Long?
@@ -251,11 +299,13 @@ data class Profile(
.scheme("ss") .scheme("ss")
.encodedAuthority("$auth@$wrappedHost:$remotePort") .encodedAuthority("$auth@$wrappedHost:$remotePort")
val configuration = PluginConfiguration(plugin ?: "") val configuration = PluginConfiguration(plugin ?: "")
if (configuration.selected.isNotEmpty()) if (configuration.selected.isNotEmpty()) {
builder.appendQueryParameter(Key.plugin, configuration.selectedOptions.toString(false)) builder.appendQueryParameter(Key.plugin, configuration.getOptions().toString(false))
}
if (!name.isNullOrEmpty()) builder.fragment(name) if (!name.isNullOrEmpty()) builder.fragment(name)
return builder.build() return builder.build()
} }
override fun toString() = toUri().toString() override fun toString() = toUri().toString()
fun toJson(profiles: LongSparseArray<Profile>? = null): JSONObject = JSONObject().apply { fun toJson(profiles: LongSparseArray<Profile>? = null): JSONObject = JSONObject().apply {
@@ -264,7 +314,7 @@ data class Profile(
put("password", password) put("password", password)
put("method", method) put("method", method)
if (profiles == null) return@apply if (profiles == null) return@apply
PluginConfiguration(plugin ?: "").selectedOptions.also { PluginConfiguration(plugin ?: "").getOptions().also {
if (it.id.isNotEmpty()) { if (it.id.isNotEmpty()) {
put("plugin", it.id) put("plugin", it.id)
put("plugin_opts", it.toString()) put("plugin_opts", it.toString())
@@ -307,12 +357,14 @@ data class Profile(
DataStore.udpFallback = udpFallback DataStore.udpFallback = udpFallback
DataStore.privateStore.remove(Key.dirty) DataStore.privateStore.remove(Key.dirty)
} }
fun deserialize() { fun deserialize() {
check(id == 0L || DataStore.editingId == id) check(id == 0L || DataStore.editingId == id)
DataStore.editingId = null DataStore.editingId = null
// It's assumed that default values are never used, so 0/false/null is always used even if that isn't the case // It's assumed that default values are never used, so 0/false/null is always used even if that isn't the case
name = DataStore.privateStore.getString(Key.name) ?: "" name = DataStore.privateStore.getString(Key.name) ?: ""
host = DataStore.privateStore.getString(Key.host) ?: "" // It's safe to trim the hostname, as we expect no leading or trailing whitespaces here
host = (DataStore.privateStore.getString(Key.host) ?: "").trim()
remotePort = parsePort(DataStore.privateStore.getString(Key.remotePort), 8388, 1) remotePort = parsePort(DataStore.privateStore.getString(Key.remotePort), 8388, 1)
password = DataStore.privateStore.getString(Key.password) ?: "" password = DataStore.privateStore.getString(Key.password) ?: ""
method = DataStore.privateStore.getString(Key.method) ?: "" method = DataStore.privateStore.getString(Key.method) ?: ""
@@ -25,10 +25,13 @@ import android.util.LongSparseArray
import org.amnezia.vpn.shadowsocks.core.Core import org.amnezia.vpn.shadowsocks.core.Core
import org.amnezia.vpn.shadowsocks.core.preference.DataStore import org.amnezia.vpn.shadowsocks.core.preference.DataStore
import org.amnezia.vpn.shadowsocks.core.utils.DirectBoot import org.amnezia.vpn.shadowsocks.core.utils.DirectBoot
import org.amnezia.vpn.shadowsocks.core.utils.printLog import org.amnezia.vpn.shadowsocks.core.utils.forEachTry
import com.google.gson.JsonStreamParser
import org.json.JSONArray import org.json.JSONArray
import timber.log.Timber
import java.io.IOException import java.io.IOException
import java.io.InputStream import java.io.InputStream
import java.io.Serializable
import java.sql.SQLException import java.sql.SQLException
/** /**
@@ -40,9 +43,18 @@ object ProfileManager {
fun onAdd(profile: Profile) fun onAdd(profile: Profile)
fun onRemove(profileId: Long) fun onRemove(profileId: Long)
fun onCleared() fun onCleared()
fun reloadProfiles()
} }
var listener: Listener? = null var listener: Listener? = null
data class ExpandedProfile(val main: Profile, val udpFallback: Profile?) : Serializable {
companion object {
private const val serialVersionUID = 1L
}
fun toList() = listOfNotNull(main, udpFallback)
}
@Throws(SQLException::class) @Throws(SQLException::class)
fun createProfile(profile: Profile = Profile()): Profile { fun createProfile(profile: Profile = Profile()): Profile {
profile.id = 0 profile.id = 0
@@ -56,11 +68,10 @@ object ProfileManager {
val profiles = if (replace) getAllProfiles()?.associateBy { it.formattedAddress } else null val profiles = if (replace) getAllProfiles()?.associateBy { it.formattedAddress } else null
val feature = if (replace) { val feature = if (replace) {
profiles?.values?.singleOrNull { it.id == DataStore.profileId } profiles?.values?.singleOrNull { it.id == DataStore.profileId }
} else Core.currentProfile?.first } else Core.currentProfile?.main
val lazyClear = lazy { clear() } val lazyClear = lazy { clear() }
var result: Exception? = null jsons.asIterable().forEachTry { json ->
for (json in jsons) try { Profile.parseJson(JsonStreamParser(json.bufferedReader()).asSequence().single(), feature) {
Profile.parseJson(json.bufferedReader().readText(), feature) {
if (replace) { if (replace) {
lazyClear.value lazyClear.value
// if two profiles has the same address, treat them as the same profile and copy stats over // if two profiles has the same address, treat them as the same profile and copy stats over
@@ -71,12 +82,10 @@ object ProfileManager {
} }
createProfile(it) createProfile(it)
} }
} catch (e: Exception) {
if (result == null) result = e else result.addSuppressed(e)
} }
if (result != null) throw result
} }
fun serializeToJson(profiles: List<Profile>? = getAllProfiles()): JSONArray? {
fun serializeToJson(profiles: List<Profile>? = getActiveProfiles()): JSONArray? {
if (profiles == null) return null if (profiles == null) return null
val lookup = LongSparseArray<Profile>(profiles.size).apply { profiles.forEach { put(it.id, it) } } val lookup = LongSparseArray<Profile>(profiles.size).apply { profiles.forEach { put(it.id, it) } }
return JSONArray(profiles.map { it.toJson(lookup) }.toTypedArray()) return JSONArray(profiles.map { it.toJson(lookup) }.toTypedArray())
@@ -94,12 +103,12 @@ object ProfileManager {
} catch (ex: SQLiteCantOpenDatabaseException) { } catch (ex: SQLiteCantOpenDatabaseException) {
throw IOException(ex) throw IOException(ex)
} catch (ex: SQLException) { } catch (ex: SQLException) {
printLog(ex) Timber.w(ex)
null null
} }
@Throws(IOException::class) @Throws(IOException::class)
fun expand(profile: Profile): Pair<Profile, Profile?> = Pair(profile, profile.udpFallback?.let { getProfile(it) }) fun expand(profile: Profile) = ExpandedProfile(profile, profile.udpFallback?.let { getProfile(it) })
@Throws(SQLException::class) @Throws(SQLException::class)
fun delProfile(id: Long) { fun delProfile(id: Long) {
@@ -122,19 +131,29 @@ object ProfileManager {
} catch (ex: SQLiteCantOpenDatabaseException) { } catch (ex: SQLiteCantOpenDatabaseException) {
throw IOException(ex) throw IOException(ex)
} catch (ex: SQLException) { } catch (ex: SQLException) {
printLog(ex) Timber.w(ex)
false false
} }
if (!nonEmpty) DataStore.profileId = createProfile().id if (!nonEmpty) DataStore.profileId = createProfile().id
} }
@Throws(IOException::class) @Throws(IOException::class)
fun getAllProfiles(): List<Profile>? = try { fun getActiveProfiles(): List<Profile>? = try {
PrivateDatabase.profileDao.list() PrivateDatabase.profileDao.listActive()
} catch (ex: SQLiteCantOpenDatabaseException) { } catch (ex: SQLiteCantOpenDatabaseException) {
throw IOException(ex) throw IOException(ex)
} catch (ex: SQLException) { } catch (ex: SQLException) {
printLog(ex) Timber.w(ex)
null
}
@Throws(IOException::class)
fun getAllProfiles(): List<Profile>? = try {
PrivateDatabase.profileDao.listAll()
} catch (ex: SQLiteCantOpenDatabaseException) {
throw IOException(ex)
} catch (ex: SQLException) {
Timber.w(ex)
null null
} }
} }
@@ -26,18 +26,22 @@ import androidx.room.RoomDatabase
import org.amnezia.vpn.shadowsocks.core.Core import org.amnezia.vpn.shadowsocks.core.Core
import org.amnezia.vpn.shadowsocks.core.database.migration.RecreateSchemaMigration import org.amnezia.vpn.shadowsocks.core.database.migration.RecreateSchemaMigration
import org.amnezia.vpn.shadowsocks.core.utils.Key import org.amnezia.vpn.shadowsocks.core.utils.Key
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
@Database(entities = [KeyValuePair::class], version = 4) @Database(entities = [KeyValuePair::class], version = 3)
abstract class PublicDatabase : RoomDatabase() { abstract class PublicDatabase : RoomDatabase() {
companion object { companion object {
private val instance by lazy { private val instance by lazy {
Room.databaseBuilder(Core.deviceStorage, PublicDatabase::class.java, Key.DB_PUBLIC) Room.databaseBuilder(Core.deviceStorage, PublicDatabase::class.java, Key.DB_PUBLIC).apply {
.allowMainThreadQueries() addMigrations(
.addMigrations(
Migration3 Migration3
) )
.fallbackToDestructiveMigration() allowMainThreadQueries()
.build() enableMultiInstanceInvalidation()
fallbackToDestructiveMigration()
setQueryExecutor { GlobalScope.launch { it.run() } }
}.build()
} }
val kvPairDao get() = instance.keyValuePairDao() val kvPairDao get() = instance.keyValuePairDao()
@@ -1,127 +0,0 @@
/*******************************************************************************
* *
* Copyright (C) 2019 by Max Lv <max.c.lv@gmail.com> *
* Copyright (C) 2019 by Mygod Studio <contact-shadowsocks-android@mygod.be> *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* This program is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU General Public License for more details. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
*******************************************************************************/
package org.amnezia.vpn.shadowsocks.core.net
import android.os.Build
import org.amnezia.vpn.shadowsocks.core.utils.printLog
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.sendBlocking
import java.io.IOException
import java.nio.ByteBuffer
import java.nio.channels.*
class ChannelMonitor : Thread("ChannelMonitor") {
private data class Registration(val channel: SelectableChannel,
val ops: Int,
val listener: (SelectionKey) -> Unit) {
val result = CompletableDeferred<SelectionKey>()
}
private val selector = Selector.open()
private val registrationPipe = Pipe.open()
private val pendingRegistrations = Channel<Registration>(Channel.UNLIMITED)
private val closeChannel = Channel<Unit>(1)
@Volatile
private var running = true
private fun registerInternal(channel: SelectableChannel, ops: Int, block: (SelectionKey) -> Unit) =
channel.register(selector, ops, block)
init {
registrationPipe.source().apply {
configureBlocking(false)
registerInternal(this, SelectionKey.OP_READ) {
val junk = ByteBuffer.allocateDirect(1)
while (read(junk) > 0) {
pendingRegistrations.poll()!!.apply {
try {
result.complete(registerInternal(channel, ops, listener))
} catch (e: Exception) {
result.completeExceptionally(e)
}
}
junk.clear()
}
}
}
start()
}
/**
* Prevent NetworkOnMainThreadException because people enable strict mode for no reasons.
*/
private suspend fun WritableByteChannel.writeCompat(src: ByteBuffer) =
if (Build.VERSION.SDK_INT <= 23) withContext(Dispatchers.Default) { write(src) } else write(src)
suspend fun register(channel: SelectableChannel, ops: Int, block: (SelectionKey) -> Unit): SelectionKey {
val registration = Registration(channel, ops, block)
pendingRegistrations.send(registration)
ByteBuffer.allocateDirect(1).also { junk ->
loop@ while (running) when (registrationPipe.sink().writeCompat(junk)) {
0 -> kotlinx.coroutines.yield()
1 -> break@loop
else -> throw IOException("Failed to register in the channel")
}
}
if (!running) throw CancellationException()
return registration.result.await()
}
suspend fun wait(channel: SelectableChannel, ops: Int) = CompletableDeferred<SelectionKey>().run {
register(channel, ops) {
if (it.isValid) try {
it.interestOps(0) // stop listening
} catch (_: CancelledKeyException) { }
complete(it)
}
await()
}
override fun run() {
while (running) {
val num = try {
selector.select()
} catch (e: Exception) {
printLog(e)
continue
}
if (num <= 0) continue
val iterator = selector.selectedKeys().iterator()
while (iterator.hasNext()) {
val key = iterator.next()
iterator.remove()
(key.attachment() as (SelectionKey) -> Unit)(key)
}
}
closeChannel.sendBlocking(Unit)
}
fun close(scope: CoroutineScope) {
running = false
selector.wakeup()
scope.launch {
closeChannel.receive()
selector.keys().forEach { it.channel().close() }
selector.close()
}
}
}
@@ -21,13 +21,13 @@
package org.amnezia.vpn.shadowsocks.core.net package org.amnezia.vpn.shadowsocks.core.net
import android.net.LocalSocket import android.net.LocalSocket
import org.amnezia.vpn.shadowsocks.core.utils.printLog
import kotlinx.coroutines.* import kotlinx.coroutines.*
import timber.log.Timber
import java.io.File import java.io.File
abstract class ConcurrentLocalSocketListener(name: String, socketFile: File) : LocalSocketListener(name, socketFile), abstract class ConcurrentLocalSocketListener(name: String, socketFile: File) : LocalSocketListener(name, socketFile),
CoroutineScope { CoroutineScope {
override val coroutineContext = Dispatchers.IO + SupervisorJob() + CoroutineExceptionHandler { _, t -> printLog(t) } override val coroutineContext = Dispatchers.IO + SupervisorJob() + CoroutineExceptionHandler { _, t -> Timber.w(t) }
override fun accept(socket: LocalSocket) { override fun accept(socket: LocalSocket) {
launch { super.accept(socket) } launch { super.accept(socket) }
@@ -26,11 +26,14 @@ import android.net.Network
import android.net.NetworkCapabilities import android.net.NetworkCapabilities
import android.net.NetworkRequest import android.net.NetworkRequest
import android.os.Build import android.os.Build
import androidx.core.content.getSystemService import android.os.Handler
import android.os.Looper
import org.amnezia.vpn.shadowsocks.core.Core.app import org.amnezia.vpn.shadowsocks.core.Core
import kotlinx.coroutines.* import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.channels.actor import kotlinx.coroutines.channels.actor
import kotlinx.coroutines.runBlocking
import java.net.UnknownHostException import java.net.UnknownHostException
object DefaultNetworkListener { object DefaultNetworkListener {
@@ -45,7 +48,6 @@ object DefaultNetworkListener {
class Update(val network: Network) : NetworkMessage() class Update(val network: Network) : NetworkMessage()
class Lost(val network: Network) : NetworkMessage() class Lost(val network: Network) : NetworkMessage()
} }
@ObsoleteCoroutinesApi
private val networkActor = GlobalScope.actor<NetworkMessage>(Dispatchers.Unconfined) { private val networkActor = GlobalScope.actor<NetworkMessage>(Dispatchers.Unconfined) {
val listeners = mutableMapOf<Any, (Network?) -> Unit>() val listeners = mutableMapOf<Any, (Network?) -> Unit>()
var network: Network? = null var network: Network? = null
@@ -80,55 +82,35 @@ object DefaultNetworkListener {
} }
} }
suspend fun start(key: Any, listener: (Network?) -> Unit) = networkActor.send(DefaultNetworkListener.NetworkMessage.Start(key, listener)) suspend fun start(key: Any, listener: (Network?) -> Unit) = networkActor.send(NetworkMessage.Start(key, listener))
suspend fun get() = if (fallback) @TargetApi(23) { suspend fun get() = if (fallback) @TargetApi(23) {
connectivity.activeNetwork ?: throw UnknownHostException() // failed to listen, return current if available Core.connectivity.activeNetwork ?: throw UnknownHostException() // failed to listen, return current if available
} else DefaultNetworkListener.NetworkMessage.Get().run { } else NetworkMessage.Get().run {
networkActor.send(this) networkActor.send(this)
response.await() response.await()
} }
suspend fun stop(key: Any) = networkActor.send(DefaultNetworkListener.NetworkMessage.Stop(key)) suspend fun stop(key: Any) = networkActor.send(NetworkMessage.Stop(key))
private object Callback: ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
super.onAvailable(network)
runBlocking { networkActor.send(DefaultNetworkListener.NetworkMessage.Put(network)) }
}
override fun onCapabilitiesChanged(
network: Network,
networkCapabilities: NetworkCapabilities
) {
super.onCapabilitiesChanged(network, networkCapabilities)
// it's a good idea to refresh capabilities
runBlocking { networkActor.send(DefaultNetworkListener.NetworkMessage.Update(network)) }
}
override fun onLost(network: Network) {
super.onLost(network)
runBlocking {
networkActor.send(DefaultNetworkListener.NetworkMessage.Lost(network))
}
}
}
// NB: this runs in ConnectivityThread, and this behavior cannot be changed until API 26 // NB: this runs in ConnectivityThread, and this behavior cannot be changed until API 26
// private object Callback : ConnectivityManager.NetworkCallback() { private object Callback : ConnectivityManager.NetworkCallback() {
// override fun onAvailable(network: Network) = runBlocking { networkActor.send(DefaultNetworkListener.NetworkMessage.Put(network)) } override fun onAvailable(network: Network) = runBlocking { networkActor.send(NetworkMessage.Put(network)) }
// override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities?) { override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) {
// // it's a good idea to refresh capabilities // it's a good idea to refresh capabilities
// runBlocking { networkActor.send(DefaultNetworkListener.NetworkMessage.Update(network)) } runBlocking { networkActor.send(NetworkMessage.Update(network)) }
// } }
// override fun onLost(network: Network) = runBlocking { networkActor.send(DefaultNetworkListener.NetworkMessage.Lost(network)) } override fun onLost(network: Network) = runBlocking { networkActor.send(NetworkMessage.Lost(network)) }
// } }
private var fallback = false private var fallback = false
private val connectivity = app.getSystemService<ConnectivityManager>()!!
private val request = NetworkRequest.Builder().apply { private val request = NetworkRequest.Builder().apply {
addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED) 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() }.build()
private val mainHandler = Handler(Looper.getMainLooper())
/** /**
* Unfortunately registerDefaultNetworkCallback is going to return VPN interface since Android P DP1: * Unfortunately registerDefaultNetworkCallback is going to return VPN interface since Android P DP1:
* https://android.googlesource.com/platform/frameworks/base/+/dda156ab0c5d66ad82bdcf76cda07cbc0a9c8a2e * https://android.googlesource.com/platform/frameworks/base/+/dda156ab0c5d66ad82bdcf76cda07cbc0a9c8a2e
@@ -139,16 +121,39 @@ object DefaultNetworkListener {
* *
* Source: https://android.googlesource.com/platform/frameworks/base/+/2df4c7d/services/core/java/com/android/server/ConnectivityService.java#887 * 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 -> @TargetApi(24) {
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 register() { private fun register() {
if (Build.VERSION.SDK_INT in 24..27) @TargetApi(24) { if (Build.VERSION.SDK_INT in 24..27) @TargetApi(24) {
connectivity.registerDefaultNetworkCallback(Callback) Core.connectivity.registerDefaultNetworkCallback(Callback)
} else try { } else try {
fallback = false fallback = false
// we want REQUEST here instead of LISTEN // we want REQUEST here instead of LISTEN
connectivity.requestNetwork(request, Callback) Core.connectivity.requestNetwork(request, Callback)
} catch (e: SecurityException) { } catch (e: SecurityException) {
fallback = true fallback = true
} }
} }
private fun unregister() = connectivity.unregisterNetworkCallback(Callback) private fun unregister() = Core.connectivity.unregisterNetworkCallback(Callback)
} }
@@ -0,0 +1,178 @@
/*******************************************************************************
* *
* Copyright (C) 2019 by Max Lv <max.c.lv@gmail.com> *
* Copyright (C) 2019 by Mygod Studio <contact-shadowsocks-android@mygod.be> *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* This program is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU General Public License for more details. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
*******************************************************************************/
package org.amnezia.vpn.shadowsocks.core.net
import android.annotation.TargetApi
import android.net.DnsResolver
import android.net.Network
import android.os.Build
import android.os.CancellationSignal
import org.amnezia.vpn.shadowsocks.core.Core
import kotlinx.coroutines.*
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
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)
}
}
@@ -24,14 +24,15 @@ import android.os.Build
import android.os.SystemClock import android.os.SystemClock
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import org.amnezia.vpn.shadowsocks.core.Core
import org.amnezia.vpn.shadowsocks.core.Core.app import org.amnezia.vpn.shadowsocks.core.Core.app
import org.amnezia.vpn.shadowsocks.core.acl.Acl
import org.amnezia.vpn.shadowsocks.core.R import org.amnezia.vpn.shadowsocks.core.R
import org.amnezia.vpn.shadowsocks.core.preference.DataStore import org.amnezia.vpn.shadowsocks.core.preference.DataStore
import org.amnezia.vpn.shadowsocks.core.utils.Key import org.amnezia.vpn.shadowsocks.core.utils.Key
import org.amnezia.vpn.shadowsocks.core.utils.disconnectFromMain import org.amnezia.vpn.shadowsocks.core.utils.useCancellable
import kotlinx.coroutines.* import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import java.io.IOException import java.io.IOException
import java.net.HttpURLConnection import java.net.HttpURLConnection
import java.net.Proxy import java.net.Proxy
@@ -75,42 +76,38 @@ class HttpsTest : ViewModel() {
} }
} }
private var running: Pair<HttpURLConnection, Job>? = null private var running: Job? = null
val status = MutableLiveData<Status>().apply { value = Status.Idle } val status = MutableLiveData<Status>(Status.Idle)
fun testConnection() { fun testConnection() {
cancelTest() cancelTest()
status.value = Status.Testing status.value = Status.Testing
val url = URL("https", when ((Core.currentProfile ?: return).first.route) { val url = URL("https://cp.cloudflare.com")
Acl.CHINALIST -> "www.qualcomm.cn"
else -> "www.google.com"
}, "/generate_204")
val conn = (if (DataStore.serviceMode != Key.modeVpn) { val conn = (if (DataStore.serviceMode != Key.modeVpn) {
url.openConnection(Proxy(Proxy.Type.SOCKS, DataStore.proxyAddress)) url.openConnection(Proxy(Proxy.Type.SOCKS, DataStore.proxyAddress))
} else url.openConnection()) as HttpURLConnection } else url.openConnection()) as HttpURLConnection
conn.setRequestProperty("Connection", "close") conn.setRequestProperty("Connection", "close")
conn.instanceFollowRedirects = false conn.instanceFollowRedirects = false
conn.useCaches = false conn.useCaches = false
running = conn to GlobalScope.launch(Dispatchers.Main.immediate) { running = GlobalScope.launch(Dispatchers.Main.immediate) {
status.value = withContext(Dispatchers.IO) { status.value = conn.useCancellable {
try { try {
val start = SystemClock.elapsedRealtime() val start = SystemClock.elapsedRealtime()
val code = conn.responseCode val code = responseCode
val elapsed = SystemClock.elapsedRealtime() - start val elapsed = SystemClock.elapsedRealtime() - start
if (code == 204 || code == 200 && conn.responseLength == 0L) Status.Success(elapsed) if (code == 204 || code == 200 && responseLength == 0L) Status.Success(elapsed)
else Status.Error.UnexpectedResponseCode(code) else Status.Error.UnexpectedResponseCode(code)
} catch (e: IOException) { } catch (e: IOException) {
Status.Error.IOFailure(e) Status.Error.IOFailure(e)
} finally { } finally {
conn.disconnect() disconnect()
} }
} }
} }
} }
private fun cancelTest() = running?.let { (conn, job) -> private fun cancelTest() {
job.cancel() // ensure job is cancelled before interrupting running?.cancel()
conn.disconnectFromMain()
running = null running = null
} }
@@ -1,171 +0,0 @@
/*******************************************************************************
* *
* Copyright (C) 2019 by Max Lv <max.c.lv@gmail.com> *
* Copyright (C) 2019 by Mygod Studio <contact-shadowsocks-android@mygod.be> *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* This program is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU General Public License for more details. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
*******************************************************************************/
package org.amnezia.vpn.shadowsocks.core.net
import org.amnezia.vpn.shadowsocks.core.utils.printLog
import kotlinx.coroutines.*
import org.xbill.DNS.*
import java.io.IOException
import java.net.*
import java.nio.ByteBuffer
import java.nio.channels.DatagramChannel
import java.nio.channels.SelectionKey
import java.nio.channels.SocketChannel
/**
* A simple DNS conditional forwarder.
*
* No cache is provided as localResolver may change from time to time. We expect DNS clients to do cache themselves.
*
* Based on:
* https://github.com/bitcoinj/httpseed/blob/809dd7ad9280f4bc98a356c1ffb3d627bf6c7ec5/src/main/kotlin/dns.kt
* https://github.com/shadowsocks/overture/tree/874f22613c334a3b78e40155a55479b7b69fee04
*/
class LocalDnsServer(private val localResolver: suspend (String) -> Array<InetAddress>,
private val remoteDns: Socks5Endpoint, private val proxy: SocketAddress) : CoroutineScope {
/**
* Forward all requests to remote and ignore localResolver.
*/
var forwardOnly = false
/**
* Forward UDP queries to TCP.
*/
var tcp = true
var remoteDomainMatcher: Regex? = null
var localIpMatcher: List<Subnet> = emptyList()
companion object {
private const val TAG = "LocalDnsServer"
private const val TIMEOUT = 10_000L
/**
* 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
private const val UDP_PACKET_SIZE = 512
private fun prepareDnsResponse(request: Message) = Message(request.header.id).apply {
header.setFlag(Flags.QR.toInt()) // this is a response
if (request.header.getFlag(Flags.RD.toInt())) header.setFlag(Flags.RD.toInt())
request.question?.also { addRecord(it, Section.QUESTION) }
}
}
private val monitor = ChannelMonitor()
override val coroutineContext = SupervisorJob() + CoroutineExceptionHandler { _, t -> printLog(t) }
suspend fun start(listen: SocketAddress) = DatagramChannel.open().run {
configureBlocking(false)
socket().bind(listen)
monitor.register(this, SelectionKey.OP_READ) { handlePacket(this) }
}
private fun handlePacket(channel: DatagramChannel) {
val buffer = ByteBuffer.allocateDirect(UDP_PACKET_SIZE)
val source = channel.receive(buffer)!!
buffer.flip()
launch {
val reply = resolve(buffer)
while (channel.send(reply, source) <= 0) monitor.wait(channel, SelectionKey.OP_WRITE)
}
}
private suspend fun resolve(packet: ByteBuffer): ByteBuffer {
val request = try {
Message(packet)
} catch (e: IOException) { // we cannot parse the message, do not attempt to handle it at all
printLog(e)
return forward(packet)
}
return supervisorScope {
val remote = async { withTimeout(TIMEOUT) { forward(packet) } }
try {
if (forwardOnly || request.header.opcode != Opcode.QUERY) return@supervisorScope remote.await()
val question = request.question
if (question?.type != Type.A) return@supervisorScope remote.await()
val host = question.name.toString(true)
if (remoteDomainMatcher?.containsMatchIn(host) == true) return@supervisorScope remote.await()
val localResults = try {
withTimeout(TIMEOUT) { GlobalScope.async(Dispatchers.IO) { localResolver(host) }.await() }
} catch (_: TimeoutCancellationException) {
return@supervisorScope remote.await()
} catch (_: UnknownHostException) {
return@supervisorScope remote.await()
}
if (localResults.isEmpty()) return@supervisorScope remote.await()
if (localIpMatcher.isEmpty() || localIpMatcher.any { subnet -> localResults.any(subnet::matches) }) {
remote.cancel()
ByteBuffer.wrap(prepareDnsResponse(request).apply {
header.setFlag(Flags.RA.toInt()) // recursion available
for (address in localResults) addRecord(when (address) {
is Inet4Address -> ARecord(question.name, DClass.IN, TTL, address)
is Inet6Address -> AAAARecord(question.name, DClass.IN, TTL, address)
else -> throw IllegalStateException("Unsupported address $address")
}, Section.ANSWER)
}.toWire())
} else remote.await()
} catch (e: Exception) {
remote.cancel()
when (e) {
is CancellationException -> { } // ignore
else -> printLog(e)
}
ByteBuffer.wrap(prepareDnsResponse(request).apply {
header.rcode = Rcode.SERVFAIL
}.toWire())
}
}
}
@ExperimentalUnsignedTypes
private suspend fun forward(packet: ByteBuffer): ByteBuffer {
packet.position(0) // the packet might have been parsed, reset to beginning
return if (tcp) SocketChannel.open().use { channel ->
channel.configureBlocking(false)
channel.connect(proxy)
val wrapped = remoteDns.tcpWrap(packet)
while (!channel.finishConnect()) monitor.wait(channel, SelectionKey.OP_CONNECT)
while (channel.write(wrapped) >= 0 && wrapped.hasRemaining()) monitor.wait(channel, SelectionKey.OP_WRITE)
val result = remoteDns.tcpReceiveBuffer(UDP_PACKET_SIZE)
remoteDns.tcpUnwrap(result, channel::read) { monitor.wait(channel, SelectionKey.OP_READ) }
result
} else DatagramChannel.open().use { channel ->
channel.configureBlocking(false)
monitor.wait(channel, SelectionKey.OP_WRITE)
check(channel.send(remoteDns.udpWrap(packet), proxy) > 0)
val result = remoteDns.udpReceiveBuffer(UDP_PACKET_SIZE)
while (isActive) {
monitor.wait(channel, SelectionKey.OP_READ)
if (channel.receive(result) == proxy) break
result.clear()
}
result.flip()
remoteDns.udpUnwrap(result)
result
}
}
fun shutdown(scope: CoroutineScope) {
cancel()
monitor.close(scope)
coroutineContext[Job]!!.also { job -> scope.launch { job.join() } }
}
}
@@ -20,17 +20,19 @@
package org.amnezia.vpn.shadowsocks.core.net package org.amnezia.vpn.shadowsocks.core.net
import android.annotation.SuppressLint
import android.net.LocalServerSocket import android.net.LocalServerSocket
import android.net.LocalSocket import android.net.LocalSocket
import android.net.LocalSocketAddress import android.net.LocalSocketAddress
import android.system.ErrnoException import android.system.ErrnoException
import android.system.Os import android.system.Os
import android.system.OsConstants import android.system.OsConstants
import org.amnezia.vpn.shadowsocks.core.utils.printLog
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.sendBlocking import kotlinx.coroutines.channels.onFailure
import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
@@ -55,14 +57,15 @@ abstract class LocalSocketListener(name: String, socketFile: File) : Thread(name
try { try {
accept(serverSocket.accept()) accept(serverSocket.accept())
} catch (e: IOException) { } catch (e: IOException) {
if (running) printLog(e) if (running) Timber.w(e)
continue continue
} }
} }
} }
closeChannel.sendBlocking(Unit) closeChannel.trySendBlocking(Unit).onFailure { throw it!! }
} }
@SuppressLint("NewApi")
open fun shutdown(scope: CoroutineScope) { open fun shutdown(scope: CoroutineScope) {
running = false running = false
localSocket.fileDescriptor?.apply { localSocket.fileDescriptor?.apply {
@@ -71,7 +74,7 @@ abstract class LocalSocketListener(name: String, socketFile: File) : Thread(name
Os.shutdown(this, OsConstants.SHUT_RDWR) Os.shutdown(this, OsConstants.SHUT_RDWR)
} catch (e: ErrnoException) { } catch (e: ErrnoException) {
// suppress fd inactive or already closed // suppress fd inactive or already closed
if (e.errno != OsConstants.EBADF && e.errno != OsConstants.ENOTCONN) throw IOException(e) if (e.errno != OsConstants.EBADF && e.errno != OsConstants.ENOTCONN) throw e.rethrowAsSocketException()
} }
} }
scope.launch { closeChannel.receive() } scope.launch { closeChannel.receive() }
@@ -1,123 +0,0 @@
/*******************************************************************************
* *
* Copyright (C) 2019 by Max Lv <max.c.lv@gmail.com> *
* Copyright (C) 2019 by Mygod Studio <contact-shadowsocks-android@mygod.be> *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* This program is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU General Public License for more details. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
*******************************************************************************/
package org.amnezia.vpn.shadowsocks.core.net
import org.amnezia.vpn.shadowsocks.core.utils.parseNumericAddress
import net.sourceforge.jsocks.Socks4Message
import net.sourceforge.jsocks.Socks5Message
import java.io.EOFException
import java.io.IOException
import java.net.Inet4Address
import java.net.Inet6Address
import java.nio.ByteBuffer
import kotlin.math.max
class Socks5Endpoint(host: String, port: Int) {
private val dest = host.parseNumericAddress().let { numeric ->
val bytes = numeric?.address ?: host.toByteArray().apply { check(size < 256) { "Hostname too long" } }
val type = when (numeric) {
null -> Socks5Message.SOCKS_ATYP_DOMAINNAME
is Inet4Address -> Socks5Message.SOCKS_ATYP_IPV4
is Inet6Address -> Socks5Message.SOCKS_ATYP_IPV6
else -> throw IllegalStateException("Unsupported address type")
}
ByteBuffer.allocate(bytes.size + (if (numeric == null) 1 else 0) + 3).apply {
put(type.toByte())
if (numeric == null) put(bytes.size.toByte())
put(bytes)
putShort(port.toShort())
}
}.array()
private val headerReserved = max(3 + 3 + 16, 3 + dest.size)
fun tcpWrap(message: ByteBuffer): ByteBuffer {
check(message.remaining() < 65536) { "TCP message too large" }
return ByteBuffer.allocateDirect(8 + dest.size + message.remaining()).apply {
put(Socks5Message.SOCKS_VERSION.toByte())
put(1) // nmethods
put(0) // no authentication required
// header
put(Socks5Message.SOCKS_VERSION.toByte())
put(Socks4Message.REQUEST_CONNECT.toByte())
put(0) // reserved
put(dest)
// data
putShort(message.remaining().toShort())
put(message)
flip()
}
}
fun tcpReceiveBuffer(size: Int) = ByteBuffer.allocateDirect(headerReserved + 4 + size)
@ExperimentalUnsignedTypes
suspend fun tcpUnwrap(buffer: ByteBuffer, reader: (ByteBuffer) -> Int, wait: suspend () -> Unit) {
suspend fun readBytes(till: Int) {
if (buffer.position() >= till) return
while (reader(buffer) >= 0 && buffer.position() < till) wait()
if (buffer.position() < till) throw EOFException("${buffer.position()} < $till")
}
suspend fun read(index: Int): Byte {
readBytes(index + 1)
return buffer[index]
}
check(read(0) == Socks5Message.SOCKS_VERSION.toByte()) { "Unsupported SOCKS version" }
if (read(1) != 0.toByte()) throw IOException("Unsupported authentication ${buffer[1]}")
check(read(2) == Socks5Message.SOCKS_VERSION.toByte()) { "Unsupported SOCKS version" }
if (read(3) != 0.toByte()) throw IOException("SOCKS5 server returned error ${buffer[3]}")
val dataOffset = when (read(5)) {
Socks5Message.SOCKS_ATYP_IPV4.toByte() -> 4
Socks5Message.SOCKS_ATYP_DOMAINNAME.toByte() -> 1 + read(6)
Socks5Message.SOCKS_ATYP_IPV6.toByte() -> 16
else -> throw IllegalStateException("Unsupported address type ${buffer[5]}")
} + 8
readBytes(dataOffset + 2)
buffer.limit(buffer.position()) // store old position to update mark
buffer.position(dataOffset)
val dataLength = buffer.short.toUShort().toInt()
val end = buffer.position() + dataLength
check(end <= buffer.capacity()) { "Buffer too small to contain the message" }
buffer.mark()
buffer.position(buffer.limit()) // restore old position
buffer.limit(end)
readBytes(buffer.limit())
buffer.reset()
}
fun udpWrap(packet: ByteBuffer) = ByteBuffer.allocateDirect(3 + dest.size + packet.remaining()).apply {
// header
putShort(0) // reserved
put(0) // fragment number
put(dest)
// data
put(packet)
flip()
}
fun udpReceiveBuffer(size: Int) = ByteBuffer.allocateDirect(headerReserved + size)
fun udpUnwrap(packet: ByteBuffer) {
packet.position(3)
packet.position(6 + when (packet.get()) {
Socks5Message.SOCKS_ATYP_IPV4.toByte() -> 4
Socks5Message.SOCKS_ATYP_DOMAINNAME.toByte() -> 1 + packet.get()
Socks5Message.SOCKS_ATYP_IPV6.toByte() -> 16
else -> throw IllegalStateException("Unsupported address type")
})
packet.mark()
}
}
@@ -26,10 +26,10 @@ import java.util.*
class Subnet(val address: InetAddress, val prefixSize: Int) : Comparable<Subnet> { class Subnet(val address: InetAddress, val prefixSize: Int) : Comparable<Subnet> {
companion object { companion object {
fun fromString(value: String): Subnet? { fun fromString(value: String, lengthCheck: Int = -1): Subnet? {
@Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN") val parts = value.split('/', limit = 2)
val parts = (value as java.lang.String).split("/", 2)
val addr = parts[0].parseNumericAddress() ?: return null val addr = parts[0].parseNumericAddress() ?: return null
check(lengthCheck < 0 || addr.address.size == lengthCheck)
return if (parts.size == 2) try { return if (parts.size == 2) try {
val prefixSize = parts[1].toInt() val prefixSize = parts[1].toInt()
if (prefixSize < 0 || prefixSize > addr.address.size shl 3) null else Subnet(addr, prefixSize) if (prefixSize < 0 || prefixSize > addr.address.size shl 3) null else Subnet(addr, prefixSize)
@@ -42,26 +42,43 @@ class Subnet(val address: InetAddress, val prefixSize: Int) : Comparable<Subnet>
private val addressLength get() = address.address.size shl 3 private val addressLength get() = address.address.size shl 3
init { init {
if (prefixSize < 0 || prefixSize > addressLength) throw IllegalArgumentException("prefixSize: $prefixSize") require(prefixSize in 0..addressLength) { "prefixSize $prefixSize not in 0..$addressLength" }
} }
fun matches(other: InetAddress): Boolean { class Immutable(private val a: ByteArray, private val prefixSize: Int = 0) {
if (address.javaClass != other.javaClass) return false companion object : Comparator<Immutable> {
// TODO optimize? override fun compare(a: Immutable, b: Immutable): Int {
val a = address.address check(a.a.size == b.a.size)
val b = other.address for (i in a.a.indices) {
val result = a.a[i].compareTo(b.a[i])
if (result != 0) return result
}
return 0
}
}
fun matches(b: Immutable) = matches(b.a)
fun matches(b: ByteArray): Boolean {
if (a.size != b.size) return false
var i = 0 var i = 0
while (i * 8 < prefixSize && i * 8 + 8 <= prefixSize) { while (i * 8 < prefixSize && i * 8 + 8 <= prefixSize) {
if (a[i] != b[i]) return false if (a[i] != b[i]) return false
++i ++i
} }
if (i * 8 == prefixSize) return true return i * 8 == prefixSize || a[i] == (b[i].toInt() and -(1 shl i * 8 + 8 - prefixSize)).toByte()
val mask = 256 - (1 shl (i * 8 + 8 - prefixSize))
return (a[i].toInt() and mask) == (b[i].toInt() and mask)
} }
}
fun toImmutable() = Immutable(address.address.also {
var i = prefixSize / 8
if (prefixSize % 8 > 0) {
it[i] = (it[i].toInt() and -(1 shl i * 8 + 8 - prefixSize)).toByte()
++i
}
while (i < it.size) it[i++] = 0
}, prefixSize)
override fun toString(): String = override fun toString(): String =
if (prefixSize == addressLength) address.hostAddress else address.hostAddress + '/' + prefixSize if (prefixSize == addressLength) address.hostAddress!! else address.hostAddress!! + '/' + prefixSize
private fun Byte.unsigned() = toInt() and 0xFF private fun Byte.unsigned() = toInt() and 0xFF
override fun compareTo(other: Subnet): Int { override fun compareTo(other: Subnet): Int {
@@ -69,8 +86,8 @@ class Subnet(val address: InetAddress, val prefixSize: Int) : Comparable<Subnet>
val addrThat = other.address.address val addrThat = other.address.address
var result = addrThis.size.compareTo(addrThat.size) // IPv4 address goes first var result = addrThis.size.compareTo(addrThat.size) // IPv4 address goes first
if (result != 0) return result if (result != 0) return result
for ((x, y) in addrThis zip addrThat) { for (i in addrThis.indices) {
result = x.unsigned().compareTo(y.unsigned()) // undo sign extension of signed byte result = addrThis[i].unsigned().compareTo(addrThat[i].unsigned()) // undo sign extension of signed byte
if (result != 0) return result if (result != 0) return result
} }
return prefixSize.compareTo(other.prefixSize) return prefixSize.compareTo(other.prefixSize)
@@ -1,65 +0,0 @@
/*******************************************************************************
* *
* Copyright (C) 2017 by Max Lv <max.c.lv@gmail.com> *
* Copyright (C) 2017 by Mygod Studio <contact-shadowsocks-android@mygod.be> *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* This program is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU General Public License for more details. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
*******************************************************************************/
package org.amnezia.vpn.shadowsocks.core.net
import org.amnezia.vpn.shadowsocks.core.utils.readableMessage
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeoutOrNull
import java.io.File
import java.io.IOException
object TcpFastOpen {
private const val PATH = "/proc/sys/net/ipv4/tcp_fastopen"
/**
* Is kernel version >= 3.7.1.
*/
val supported by lazy {
if (File(PATH).canRead()) return@lazy true
val match = """^(\d+)\.(\d+)\.(\d+)""".toRegex().find(System.getProperty("os.version") ?: "")
if (match == null) false else when (match.groupValues[1].toInt()) {
in Int.MIN_VALUE..2 -> false
3 -> when (match.groupValues[2].toInt()) {
in Int.MIN_VALUE..6 -> false
7 -> match.groupValues[3].toInt() >= 1
else -> true
}
else -> true
}
}
val sendEnabled: Boolean get() {
val file = File(PATH)
// File.readText doesn't work since this special file will return length 0
// on Android containers like Chrome OS, this file does not exist so we simply judge by the kernel version
return if (file.canRead()) file.bufferedReader().use { it.readText() }.trim().toInt() and 1 > 0 else supported
}
fun enable(): String? {
return try {
ProcessBuilder("su", "-c", "echo 3 > $PATH").redirectErrorStream(true).start()
.inputStream.bufferedReader().readText()
} catch (e: IOException) {
e.readableMessage
}
}
fun enableTimeout() = runBlocking { withTimeoutOrNull(1000) { enable() } }
}
@@ -18,16 +18,14 @@
* * * *
*******************************************************************************/ *******************************************************************************/
package org.amnezia.vpn.shadowsocks.core.plugin package org.amnezia.vpn.shadowsocks.plugin
import android.content.pm.ResolveInfo import android.content.pm.ResolveInfo
import android.os.Bundle
class NativePlugin(resolveInfo: ResolveInfo) : ResolvedPlugin(resolveInfo) { class NativePlugin(resolveInfo: ResolveInfo) : ResolvedPlugin(resolveInfo) {
init { init {
check(resolveInfo.providerInfo != null) check(resolveInfo.providerInfo != null)
} }
override val metaData: Bundle get() = resolveInfo.providerInfo.metaData override val componentInfo get() = resolveInfo.providerInfo!!
override val packageName: String get() = resolveInfo.providerInfo.packageName
} }
@@ -1,9 +1,8 @@
package org.amnezia.vpn.shadowsocks.core.plugin package org.amnezia.vpn.shadowsocks.plugin
import org.amnezia.vpn.shadowsocks.core.Core.app import org.amnezia.vpn.shadowsocks.core.Core.app
import org.amnezia.vpn.shadowsocks.core.R
object NoPlugin : Plugin() { object NoPlugin : Plugin() {
override val id: String get() = "" override val id: String get() = ""
override val label: CharSequence get() = app.getText(R.string.plugin_disabled) override val label: CharSequence get() = app.getText(org.amnezia.vpn.shadowsocks.core.R.string.plugin_disabled)
} }
@@ -18,15 +18,24 @@
* * * *
*******************************************************************************/ *******************************************************************************/
package org.amnezia.vpn.shadowsocks.core.plugin package org.amnezia.vpn.shadowsocks.plugin
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
abstract class Plugin { abstract class Plugin {
abstract val id: String abstract val id: String
open val idAliases get() = emptyArray<String>()
abstract val label: CharSequence abstract val label: CharSequence
open val icon: Drawable? get() = null open val icon: Drawable? get() = null
open val defaultConfig: String? get() = null open val defaultConfig: String? get() = null
open val packageName: String get() = "" open val packageName: String get() = ""
open val trusted: Boolean get() = true open val trusted: Boolean get() = true
open val directBootAware: Boolean get() = true
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
return id == (other as Plugin).id
}
override fun hashCode() = id.hashCode()
} }
@@ -18,15 +18,15 @@
* * * *
*******************************************************************************/ *******************************************************************************/
package org.amnezia.vpn.shadowsocks.core.plugin package org.amnezia.vpn.shadowsocks.plugin
import org.amnezia.vpn.shadowsocks.core.utils.Commandline import org.amnezia.vpn.shadowsocks.core.utils.Commandline
import org.amnezia.vpn.shadowsocks.plugin.PluginOptions import timber.log.Timber
import java.util.* import java.util.*
class PluginConfiguration(val pluginsOptions: Map<String, PluginOptions>, val selected: String) { class PluginConfiguration(val pluginsOptions: Map<String, PluginOptions>, val selected: String) {
private constructor(plugins: List<PluginOptions>) : this( private constructor(plugins: List<PluginOptions>) : this(
plugins.filter { it.id.isNotEmpty() }.associate { it.id to it }, plugins.filter { it.id.isNotEmpty() }.associateBy { it.id },
if (plugins.isEmpty()) "" else plugins[0].id) if (plugins.isEmpty()) "" else plugins[0].id)
constructor(plugin: String) : this(plugin.split('\n').map { line -> constructor(plugin: String) : this(plugin.split('\n').map { line ->
if (line.startsWith("kcptun ")) { if (line.startsWith("kcptun ")) {
@@ -43,19 +43,21 @@ class PluginConfiguration(val pluginsOptions: Map<String, PluginOptions>, val se
} }
} }
} catch (exc: Exception) { } catch (exc: Exception) {
Timber.w(exc)
} }
opt opt
} else PluginOptions(line) } else PluginOptions(line)
}) })
fun getOptions(id: String): PluginOptions = if (id.isEmpty()) PluginOptions() else fun getOptions(
pluginsOptions[id] ?: PluginOptions(id, PluginManager.fetchPlugins()[id]?.defaultConfig) id: String = selected,
val selectedOptions: PluginOptions get() = getOptions(selected) defaultConfig: () -> String? = { PluginManager.fetchPlugins().lookup[id]?.defaultConfig }
) = if (id.isEmpty()) PluginOptions() else pluginsOptions[id] ?: PluginOptions(id, defaultConfig())
override fun toString(): String { override fun toString(): String {
val result = LinkedList<PluginOptions>() val result = LinkedList<PluginOptions>()
for ((id, opt) in pluginsOptions) if (id == this.selected) result.addFirst(opt) else result.addLast(opt) for ((id, opt) in pluginsOptions) if (id == this.selected) result.addFirst(opt) else result.addLast(opt)
if (!pluginsOptions.contains(selected)) result.addFirst(selectedOptions) if (!pluginsOptions.contains(selected)) result.addFirst(getOptions())
return result.joinToString("\n") { it.toString(false) } return result.joinToString("\n") { it.toString(false) }
} }
} }
@@ -0,0 +1,50 @@
/*******************************************************************************
* *
* Copyright (C) 2020 by Max Lv <max.c.lv@gmail.com> *
* Copyright (C) 2020 by Mygod Studio <contact-shadowsocks-android@mygod.be> *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* This program is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU General Public License for more details. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
*******************************************************************************/
package org.amnezia.vpn.shadowsocks.plugin
import android.content.Intent
import android.content.pm.PackageManager
import android.widget.Toast
import org.amnezia.vpn.shadowsocks.core.Core.app
class PluginList : ArrayList<Plugin>() {
init {
add(NoPlugin)
addAll(app.packageManager.queryIntentContentProviders(
Intent(PluginContract.ACTION_NATIVE_PLUGIN), PackageManager.GET_META_DATA)
.filter { it.providerInfo.exported }.map { NativePlugin(it) })
}
val lookup = mutableMapOf<String, Plugin>().apply {
for (plugin in this@PluginList) {
fun check(old: Plugin?) {
if (old != null && old !== plugin) {
val packages = this@PluginList.filter { it.id == plugin.id }.joinToString { it.packageName }
val message = "Conflicting plugins found from: $packages"
Toast.makeText(app, message, Toast.LENGTH_LONG).show()
throw IllegalStateException(message)
}
}
check(put(plugin.id, plugin))
for (alias in plugin.idAliases) check(put(alias, plugin))
}
}
}
@@ -18,33 +18,36 @@
* * * *
*******************************************************************************/ *******************************************************************************/
package org.amnezia.vpn.shadowsocks.core.plugin package org.amnezia.vpn.shadowsocks.plugin
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.ContentResolver import android.content.ContentResolver
import android.content.Intent import android.content.Intent
import android.content.pm.ComponentInfo
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.content.pm.ProviderInfo
import android.content.pm.Signature import android.content.pm.Signature
import android.database.Cursor import android.database.Cursor
import android.net.Uri import android.net.Uri
import android.os.Build
import android.system.Os import android.system.Os
import android.util.Base64 import android.util.Base64
import android.widget.Toast
import androidx.core.os.bundleOf import androidx.core.os.bundleOf
import org.amnezia.vpn.shadowsocks.core.Core import org.amnezia.vpn.shadowsocks.core.Core
import org.amnezia.vpn.shadowsocks.core.Core.app import org.amnezia.vpn.shadowsocks.core.Core.app
import org.amnezia.vpn.shadowsocks.core.R import org.amnezia.vpn.shadowsocks.core.bg.BaseService
import org.amnezia.vpn.shadowsocks.core.utils.printLog import org.amnezia.vpn.shadowsocks.core.utils.listenForPackageChanges
import org.amnezia.vpn.shadowsocks.core.utils.signaturesCompat import org.amnezia.vpn.shadowsocks.core.utils.signaturesCompat
import org.amnezia.vpn.shadowsocks.plugin.PluginContract import timber.log.Timber
import org.amnezia.vpn.shadowsocks.plugin.PluginOptions
import java.io.File import java.io.File
import java.io.FileNotFoundException import java.io.FileNotFoundException
object PluginManager { object PluginManager {
class PluginNotFoundException(private val plugin: String) : FileNotFoundException(plugin) { class PluginNotFoundException(private val plugin: String) : FileNotFoundException(plugin),
override fun getLocalizedMessage() = app.getString(R.string.plugin_unknown, plugin) BaseService.ExpectedException {
override fun getLocalizedMessage() = app.getString(org.amnezia.vpn.shadowsocks.core.R.string.plugin_unknown, plugin)
} }
/** /**
@@ -96,19 +99,15 @@ object PluginManager {
} }
private var receiver: BroadcastReceiver? = null private var receiver: BroadcastReceiver? = null
private var cachedPlugins: Map<String, Plugin>? = null private var cachedPlugins: PluginList? = null
fun fetchPlugins(): Map<String, Plugin> = synchronized(this) { fun fetchPlugins() = synchronized(this) {
if (receiver == null) receiver = Core.listenForPackageChanges { if (receiver == null) receiver = app.listenForPackageChanges {
synchronized(this) { synchronized(this) {
receiver = null receiver = null
cachedPlugins = null cachedPlugins = null
} }
} }
if (cachedPlugins == null) { if (cachedPlugins == null) cachedPlugins = PluginList()
val pm = app.packageManager
cachedPlugins = (pm.queryIntentContentProviders(Intent(PluginContract.ACTION_NATIVE_PLUGIN),
PackageManager.GET_META_DATA).map { NativePlugin(it) } + NoPlugin).associate { it.id to it }
}
cachedPlugins!! cachedPlugins!!
} }
@@ -119,46 +118,89 @@ object PluginManager {
.build() .build()
fun buildIntent(id: String, action: String): Intent = Intent(action, buildUri(id)) fun buildIntent(id: String, action: String): Intent = Intent(action, buildUri(id))
data class InitResult(
val path: String,
val options: PluginOptions,
val isV2: Boolean = false,
)
// the following parts are meant to be used by :bg // the following parts are meant to be used by :bg
@Throws(Throwable::class) @Throws(Throwable::class)
fun init(options: PluginOptions): String? { fun init(configuration: PluginConfiguration): InitResult? {
if (options.id.isEmpty()) return null if (configuration.selected.isEmpty()) return null
var throwable: Throwable? = null var throwable: Throwable? = null
try { try {
val path = initNative(options) val result = initNative(configuration)
if (path != null) return path if (result != null) return result
} catch (t: Throwable) { } catch (t: Throwable) {
if (throwable == null) throwable = t else printLog(t) if (throwable == null) throwable = t else Timber.w(t)
} }
// add other plugin types here // add other plugin types here
throw throwable ?: PluginNotFoundException(options.id) throw throwable ?: PluginNotFoundException(configuration.selected)
} }
private fun initNative(options: PluginOptions): String? { private fun initNative(configuration: PluginConfiguration): InitResult? {
var flags = PackageManager.GET_META_DATA
if (Build.VERSION.SDK_INT >= 24) {
flags = flags or PackageManager.MATCH_DIRECT_BOOT_UNAWARE or PackageManager.MATCH_DIRECT_BOOT_AWARE
}
val providers = app.packageManager.queryIntentContentProviders( val providers = app.packageManager.queryIntentContentProviders(
Intent(PluginContract.ACTION_NATIVE_PLUGIN, buildUri(options.id)), 0) Intent(PluginContract.ACTION_NATIVE_PLUGIN, buildUri(configuration.selected)), flags)
.filter { it.providerInfo.exported }
if (providers.isEmpty()) return null if (providers.isEmpty()) return null
val uri = Uri.Builder() if (providers.size > 1) {
.scheme(ContentResolver.SCHEME_CONTENT) val message = "Conflicting plugins found from: ${providers.joinToString { it.providerInfo.packageName }}"
.authority(providers.single().providerInfo.authority) Toast.makeText(app, message, Toast.LENGTH_LONG).show()
.build() throw IllegalStateException(message)
val cr = app.contentResolver }
return try { val provider = providers.single().providerInfo
initNativeFast(cr, options, uri) val options = configuration.getOptions { provider.loadString(PluginContract.METADATA_KEY_DEFAULT_CONFIG) }
val isV2 = provider.applicationInfo.metaData?.getString(PluginContract.METADATA_KEY_VERSION)
?.substringBefore('.')?.toIntOrNull() ?: 0 >= 2
var failure: Throwable? = null
try {
initNativeFaster(provider)?.also { return InitResult(it, options, isV2) }
} catch (t: Throwable) { } catch (t: Throwable) {
printLog(t) Timber.w("Initializing native plugin faster mode failed")
initNativeSlow(cr, options, uri) failure = t
}
val uri = Uri.Builder().apply {
scheme(ContentResolver.SCHEME_CONTENT)
authority(provider.authority)
}.build()
try {
return initNativeFast(app.contentResolver, options, uri)?.let { InitResult(it, options, isV2) }
} catch (t: Throwable) {
Timber.w("Initializing native plugin fast mode failed")
failure?.also { t.addSuppressed(it) }
failure = t
}
try {
return initNativeSlow(app.contentResolver, options, uri)?.let { InitResult(it, options, isV2) }
} catch (t: Throwable) {
failure?.also { t.addSuppressed(it) }
throw t
} }
} }
private fun initNativeFast(cr: ContentResolver, options: PluginOptions, uri: Uri): String { private fun initNativeFaster(provider: ProviderInfo): String? {
val result = cr.call(uri, PluginContract.METHOD_GET_EXECUTABLE, null, return provider.loadString(PluginContract.METADATA_KEY_EXECUTABLE_PATH)?.let { relativePath ->
bundleOf(Pair(PluginContract.EXTRA_OPTIONS, options.id)))!!.getString(PluginContract.EXTRA_ENTRY)!! File(provider.applicationInfo.nativeLibraryDir).resolve(relativePath).apply {
check(File(result).canExecute()) check(canExecute())
return result }.absolutePath
}
}
private fun initNativeFast(cr: ContentResolver, options: PluginOptions, uri: Uri): String? {
return cr.call(uri, PluginContract.METHOD_GET_EXECUTABLE, null,
bundleOf(PluginContract.EXTRA_OPTIONS to options.id))?.getString(PluginContract.EXTRA_ENTRY)?.also {
check(File(it).canExecute())
}
} }
@SuppressLint("Recycle") @SuppressLint("Recycle")
@@ -190,4 +232,11 @@ object PluginManager {
if (!initialized) entryNotFound() if (!initialized) entryNotFound()
return File(pluginDir, options.id).absolutePath return File(pluginDir, options.id).absolutePath
} }
fun ComponentInfo.loadString(key: String) = when (val value = metaData.get(key)) {
is String -> value
is Int -> app.packageManager.getResourcesForApplication(applicationInfo).getString(value)
null -> null
else -> error("meta-data $key has invalid type ${value.javaClass}")
}
} }
@@ -18,25 +18,40 @@
* * * *
*******************************************************************************/ *******************************************************************************/
package org.amnezia.vpn.shadowsocks.core.plugin package org.amnezia.vpn.shadowsocks.plugin
import android.content.pm.ComponentInfo
import android.content.pm.ResolveInfo import android.content.pm.ResolveInfo
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.os.Bundle import android.os.Build
import org.amnezia.vpn.shadowsocks.core.Core import org.amnezia.vpn.shadowsocks.core.Core
import org.amnezia.vpn.shadowsocks.core.Core.app import org.amnezia.vpn.shadowsocks.core.Core.app
import org.amnezia.vpn.shadowsocks.plugin.PluginManager.loadString
import org.amnezia.vpn.shadowsocks.core.utils.signaturesCompat import org.amnezia.vpn.shadowsocks.core.utils.signaturesCompat
import org.amnezia.vpn.shadowsocks.plugin.PluginContract
abstract class ResolvedPlugin(protected val resolveInfo: ResolveInfo) : Plugin() { abstract class ResolvedPlugin(protected val resolveInfo: ResolveInfo) : Plugin() {
protected abstract val metaData: Bundle protected abstract val componentInfo: ComponentInfo
override val id: String by lazy { metaData.getString(PluginContract.METADATA_KEY_ID)!! } override val id by lazy { componentInfo.loadString(PluginContract.METADATA_KEY_ID)!! }
override val label: CharSequence by lazy { resolveInfo.loadLabel(app.packageManager) } override val idAliases: Array<String> by lazy {
override val icon: Drawable by lazy { resolveInfo.loadIcon(app.packageManager) } when (val value = componentInfo.metaData.get(PluginContract.METADATA_KEY_ID_ALIASES)) {
override val defaultConfig: String by lazy { metaData.getString(PluginContract.METADATA_KEY_DEFAULT_CONFIG)!! } is String -> arrayOf(value)
override val packageName: String get() = resolveInfo.resolvePackageName is Int -> app.packageManager.getResourcesForApplication(componentInfo.applicationInfo).run {
when (getResourceTypeName(value)) {
"string" -> arrayOf(getString(value))
else -> getStringArray(value)
}
}
null -> emptyArray()
else -> error("unknown type for plugin meta-data idAliases")
}
}
override val label: CharSequence get() = resolveInfo.loadLabel(app.packageManager)
override val icon: Drawable get() = resolveInfo.loadIcon(app.packageManager)
override val defaultConfig by lazy { componentInfo.loadString(PluginContract.METADATA_KEY_DEFAULT_CONFIG) }
override val packageName: String get() = componentInfo.packageName
override val trusted by lazy { override val trusted by lazy {
Core.getPackageInfo(packageName).signaturesCompat.any(PluginManager.trustedSignatures::contains) Core.getPackageInfo(packageName).signaturesCompat.any(PluginManager.trustedSignatures::contains)
} }
override val directBootAware get() = Build.VERSION.SDK_INT < 24 || componentInfo.directBootAware
} }
@@ -22,16 +22,14 @@ package org.amnezia.vpn.shadowsocks.core.preference
import android.os.Binder import android.os.Binder
import androidx.preference.PreferenceDataStore import androidx.preference.PreferenceDataStore
import org.amnezia.vpn.shadowsocks.core.BootReceiver
import org.amnezia.vpn.shadowsocks.core.Core import org.amnezia.vpn.shadowsocks.core.Core
import org.amnezia.vpn.shadowsocks.core.database.PrivateDatabase import org.amnezia.vpn.shadowsocks.core.database.PrivateDatabase
import org.amnezia.vpn.shadowsocks.core.database.PublicDatabase import org.amnezia.vpn.shadowsocks.core.database.PublicDatabase
import org.amnezia.vpn.shadowsocks.core.net.TcpFastOpen
import org.amnezia.vpn.shadowsocks.core.utils.DirectBoot import org.amnezia.vpn.shadowsocks.core.utils.DirectBoot
import org.amnezia.vpn.shadowsocks.core.utils.Key import org.amnezia.vpn.shadowsocks.core.utils.Key
import org.amnezia.vpn.shadowsocks.core.utils.parsePort import org.amnezia.vpn.shadowsocks.core.utils.parsePort
import java.net.InetSocketAddress import java.net.InetSocketAddress
import java.net.NetworkInterface
import java.net.SocketException
object DataStore : OnPreferenceDataStoreChangeListener { object DataStore : OnPreferenceDataStoreChangeListener {
val publicStore = RoomPreferenceDataStore(PublicDatabase.kvPairDao) val publicStore = RoomPreferenceDataStore(PublicDatabase.kvPairDao)
@@ -42,7 +40,7 @@ object DataStore : OnPreferenceDataStoreChangeListener {
publicStore.registerChangeListener(this) publicStore.registerChangeListener(this)
} }
override fun onPreferenceDataStoreChanged(store: PreferenceDataStore, key: String?) { override fun onPreferenceDataStoreChanged(store: PreferenceDataStore, key: String) {
when (key) { when (key) {
Key.id -> if (directBootAware) DirectBoot.update() Key.id -> if (directBootAware) DirectBoot.update()
} }
@@ -61,31 +59,12 @@ object DataStore : OnPreferenceDataStoreChangeListener {
var profileId: Long var profileId: Long
get() = publicStore.getLong(Key.id) ?: 0 get() = publicStore.getLong(Key.id) ?: 0
set(value) = publicStore.putLong(Key.id, value) set(value) = publicStore.putLong(Key.id, value)
val persistAcrossReboot get() = publicStore.getBoolean(Key.persistAcrossReboot)
?: BootReceiver.enabled.also { publicStore.putBoolean(Key.persistAcrossReboot, it) }
val canToggleLocked: Boolean get() = publicStore.getBoolean(Key.directBootAware) == true val canToggleLocked: Boolean get() = publicStore.getBoolean(Key.directBootAware) == true
val directBootAware: Boolean get() = Core.directBootSupported && canToggleLocked val directBootAware: Boolean get() = Core.directBootSupported && canToggleLocked
val tcpFastOpen: Boolean get() = TcpFastOpen.sendEnabled && publicStore.getBoolean(Key.tfo, true)
val serviceMode get() = publicStore.getString(Key.serviceMode) ?: Key.modeVpn val serviceMode get() = publicStore.getString(Key.serviceMode) ?: Key.modeVpn
val listenAddress get() = if (publicStore.getBoolean(Key.shareOverLan, false)) "0.0.0.0" else "127.0.0.1"
/**
* An alternative way to detect this interface could be checking MAC address = 00:ff:aa:00:00:55, but there is no
* reliable way of getting MAC address for now.
*/
val hasArc0 by lazy {
var retry = 0
while (retry < 5) {
try {
return@lazy NetworkInterface.getByName("arc0") != null
} catch (_: SocketException) { }
retry++
Thread.sleep(100L shl retry)
}
false
}
/**
* Binding bogus IP address 100.115.92.2 in Chrome OS directly does not seem to work reliably. It might be due to
* the IP may not be available when the device is not connected to any network.
*/
val listenAddress get() = if (publicStore.getBoolean(Key.shareOverLan, hasArc0)) "0.0.0.0" else "127.0.0.1"
var portProxy: Int var portProxy: Int
get() = getLocalPort(Key.portProxy, 1080) get() = getLocalPort(Key.portProxy, 1080)
set(value) = publicStore.putString(Key.portProxy, value.toString()) set(value) = publicStore.putString(Key.portProxy, value.toString())
@@ -101,7 +80,7 @@ object DataStore : OnPreferenceDataStoreChangeListener {
* Initialize settings that have complicated default values. * Initialize settings that have complicated default values.
*/ */
fun initGlobal() { fun initGlobal() {
if (publicStore.getBoolean(Key.tfo) == null) publicStore.putBoolean(Key.tfo, tcpFastOpen) persistAcrossReboot
if (publicStore.getString(Key.portProxy) == null) portProxy = portProxy if (publicStore.getString(Key.portProxy) == null) portProxy = portProxy
if (publicStore.getString(Key.portLocalDns) == null) portLocalDns = portLocalDns if (publicStore.getString(Key.portLocalDns) == null) portLocalDns = portLocalDns
if (publicStore.getString(Key.portTransproxy) == null) portTransproxy = portTransproxy if (publicStore.getString(Key.portTransproxy) == null) portTransproxy = portTransproxy
@@ -0,0 +1,46 @@
/*******************************************************************************
* *
* Copyright (C) 2019 by Max Lv <max.c.lv@gmail.com> *
* Copyright (C) 2019 by Mygod Studio <contact-shadowsocks-android@mygod.be> *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* This program is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU General Public License for more details. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
*******************************************************************************/
package org.amnezia.vpn.shadowsocks.core.preference
import android.graphics.Typeface
import android.text.InputFilter
import android.view.inputmethod.EditorInfo
import android.widget.EditText
import androidx.preference.EditTextPreference
object EditTextPreferenceModifiers {
object Monospace : EditTextPreference.OnBindEditTextListener {
override fun onBindEditText(editText: EditText) {
editText.typeface = Typeface.MONOSPACE
}
}
object Port : EditTextPreference.OnBindEditTextListener {
private val portLengthFilter = arrayOf(InputFilter.LengthFilter(5))
override fun onBindEditText(editText: EditText) {
editText.inputType = EditorInfo.TYPE_CLASS_NUMBER
editText.filters = portLengthFilter
editText.setSingleLine()
editText.setSelection(editText.text.length)
}
}
}
@@ -23,5 +23,5 @@ package org.amnezia.vpn.shadowsocks.core.preference
import androidx.preference.PreferenceDataStore import androidx.preference.PreferenceDataStore
interface OnPreferenceDataStoreChangeListener { interface OnPreferenceDataStoreChangeListener {
fun onPreferenceDataStoreChanged(store: PreferenceDataStore, key: String?) fun onPreferenceDataStoreChanged(store: PreferenceDataStore, key: String)
} }
@@ -0,0 +1,61 @@
/*******************************************************************************
* *
* Copyright (C) 2020 by Max Lv <max.c.lv@gmail.com> *
* Copyright (C) 2020 by Mygod Studio <contact-shadowsocks-android@mygod.be> *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* This program is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU General Public License for more details. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
*******************************************************************************/
package org.amnezia.vpn.shadowsocks.core.subscription
import androidx.recyclerview.widget.SortedList
import org.amnezia.vpn.shadowsocks.core.preference.DataStore
import org.amnezia.vpn.shadowsocks.core.utils.URLSorter
import org.amnezia.vpn.shadowsocks.core.utils.asIterable
import java.io.Reader
import java.net.URL
class Subscription {
companion object {
private const val SUBSCRIPTION = "subscription"
var instance: Subscription
get() {
val sub = Subscription()
val str = DataStore.publicStore.getString(SUBSCRIPTION)
if (str != null) sub.fromReader(str.reader())
return sub
}
set(value) = DataStore.publicStore.putString(SUBSCRIPTION, value.toString())
}
val urls = SortedList(URL::class.java, URLSorter)
fun fromReader(reader: Reader): Subscription {
urls.clear()
reader.useLines {
for (line in it) try {
urls.add(URL(line))
} catch (_: Exception) { }
}
return this
}
override fun toString(): String {
val result = StringBuilder()
result.append(urls.asIterable().joinToString("\n"))
return result.toString()
}
}
@@ -0,0 +1,209 @@
/*******************************************************************************
* *
* Copyright (C) 2020 by Max Lv <max.c.lv@gmail.com> *
* Copyright (C) 2020 by Mygod Studio <contact-shadowsocks-android@mygod.be> *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* This program is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU General Public License for more details. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
*******************************************************************************/
package org.amnezia.vpn.shadowsocks.core.subscription
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Intent
import android.content.IntentFilter
import android.os.IBinder
import android.widget.Toast
import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import androidx.lifecycle.MutableLiveData
import org.amnezia.vpn.shadowsocks.core.Core
import org.amnezia.vpn.shadowsocks.core.Core.app
import org.amnezia.vpn.shadowsocks.core.R
import org.amnezia.vpn.shadowsocks.core.database.Profile
import org.amnezia.vpn.shadowsocks.core.database.ProfileManager
import org.amnezia.vpn.shadowsocks.core.preference.DataStore
import org.amnezia.vpn.shadowsocks.core.utils.*
import com.google.gson.JsonStreamParser
import kotlinx.coroutines.*
import timber.log.Timber
import java.io.File
import java.io.InputStream
import java.net.HttpURLConnection
import java.net.URL
class SubscriptionService : Service(), CoroutineScope {
companion object {
private const val NOTIFICATION_CHANNEL = "service-subscription"
private const val NOTIFICATION_ID = 2
val idle = MutableLiveData(true)
val notificationChannel @RequiresApi(26) get() = NotificationChannel(NOTIFICATION_CHANNEL,
"", NotificationManager.IMPORTANCE_LOW)
}
override val coroutineContext = SupervisorJob() + CoroutineExceptionHandler { _, t -> Timber.w(t) }
private var worker: Job? = null
private val cancelReceiver = broadcastReceiver { _, _ -> worker?.cancel() }
private var counter = 0
private var receiverRegistered = false
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (worker == null) {
idle.value = false
if (!receiverRegistered) {
ContextCompat.registerReceiver(this, cancelReceiver, IntentFilter(Action.ABORT),
ContextCompat.RECEIVER_NOT_EXPORTED)
receiverRegistered = true
}
worker = launch {
val urls = Subscription.instance.urls
val notification = NotificationCompat.Builder(this@SubscriptionService, NOTIFICATION_CHANNEL).apply {
color = ContextCompat.getColor(this@SubscriptionService, R.color.material_primary_500)
priority = NotificationCompat.PRIORITY_LOW
addAction(NotificationCompat.Action.Builder(
R.drawable.ic_navigation_close,
getText(R.string.stop),
PendingIntent.getBroadcast(this@SubscriptionService, 0,
Intent(Action.ABORT).setPackage(packageName), PendingIntent.FLAG_IMMUTABLE)).apply {
setShowsUserInterface(false)
}.build())
setCategory(NotificationCompat.CATEGORY_PROGRESS)
//setContentTitle(getString(R.string.service_subscription_working, 0, urls.size()))
setOngoing(true)
setProgress(urls.size(), 0, false)
//setSmallIcon(R.drawable.ic_file_cloud_download)
setWhen(0)
}
Core.notification.notify(NOTIFICATION_ID, notification.build())
counter = 0
val workers = urls.asIterable().map { url -> fetchJsonAsync(url, urls.size(), notification) }
try {
val localJsons = workers.awaitAll()
withContext(Dispatchers.Main) {
Core.notification.notify(NOTIFICATION_ID, notification.apply {
//setContentTitle(getText(R.string.service_subscription_finishing))
setProgress(0, 0, true)
}.build())
createProfilesFromSubscription(localJsons.asSequence().filterNotNull().map { it.inputStream() })
}
} finally {
for (worker in workers) {
worker.cancel()
try {
worker.getCompleted()?.apply { if (!delete()) deleteOnExit() }
} catch (_: Exception) { }
}
GlobalScope.launch(Dispatchers.Main) {
Core.notification.cancel(NOTIFICATION_ID)
idle.value = true
}
check(worker != null)
worker = null
stopSelf(startId)
}
}
} else stopSelf(startId)
return START_NOT_STICKY
}
private fun fetchJsonAsync(url: URL, max: Int, notification: NotificationCompat.Builder) = async(Dispatchers.IO) {
val tempFile = File.createTempFile("subscription-", ".json", cacheDir)
try {
(url.openConnection() as HttpURLConnection).useCancellable {
tempFile.outputStream().use { out -> inputStream.copyTo(out) }
}
tempFile
} catch (e: Exception) {
Timber.d(e)
launch(Dispatchers.Main) {
Toast.makeText(this@SubscriptionService, e.readableMessage, Toast.LENGTH_LONG).show()
}
if (!tempFile.delete()) tempFile.deleteOnExit()
null
} finally {
withContext(Dispatchers.Main) {
counter += 1
Core.notification.notify(NOTIFICATION_ID, notification.apply {
setContentTitle("")
setProgress(max, counter, false)
}.build())
}
}
}
private fun createProfilesFromSubscription(jsons: Sequence<InputStream>) {
val currentId = DataStore.profileId
val profiles = ProfileManager.getAllProfiles()
val subscriptions = mutableMapOf<Pair<String?, String>, Profile>()
val toUpdate = mutableSetOf<Long>()
var feature: Profile? = null
profiles?.forEach { profile -> // preprocessing phase
if (currentId == profile.id) feature = profile
if (profile.subscription == Profile.SubscriptionStatus.UserConfigured) return@forEach
if (subscriptions.putIfAbsent(profile.name to profile.formattedAddress, profile) != null) {
ProfileManager.delProfile(profile.id)
if (currentId == profile.id) DataStore.profileId = 0
} else if (profile.subscription == Profile.SubscriptionStatus.Active) {
toUpdate.add(profile.id)
profile.subscription = Profile.SubscriptionStatus.Obsolete
}
}
for (json in jsons.asIterable()) try {
Profile.parseJson(JsonStreamParser(json.bufferedReader()).asSequence().single(), feature) {
subscriptions.compute(it.name to it.formattedAddress) { _, oldProfile ->
when (oldProfile?.subscription) {
Profile.SubscriptionStatus.Active -> {
Timber.w("Duplicate profiles detected. Please use different profile names and/or " +
"address:port for better subscription support.")
oldProfile
}
Profile.SubscriptionStatus.Obsolete -> {
toUpdate.add(oldProfile.id)
oldProfile.password = it.password
oldProfile.method = it.method
oldProfile.plugin = it.plugin
oldProfile.udpFallback = it.udpFallback
oldProfile.subscription = Profile.SubscriptionStatus.Active
oldProfile
}
else -> ProfileManager.createProfile(it.apply {
subscription = Profile.SubscriptionStatus.Active
})
}
}!!
}
} catch (e: Exception) {
Timber.d(e)
Toast.makeText(this, e.readableMessage, Toast.LENGTH_LONG).show()
}
profiles?.forEach { profile -> if (toUpdate.contains(profile.id)) ProfileManager.updateProfile(profile) }
ProfileManager.listener?.reloadProfiles()
}
override fun onDestroy() {
cancel()
if (receiverRegistered) unregisterReceiver(cancelReceiver)
super.onDestroy()
}
}
@@ -0,0 +1,66 @@
/*******************************************************************************
* *
* Copyright (C) 2020 by Max Lv <max.c.lv@gmail.com> *
* Copyright (C) 2020 by Mygod Studio <contact-shadowsocks-android@mygod.be> *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* This program is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU General Public License for more details. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
*******************************************************************************/
package org.amnezia.vpn.shadowsocks.core.utils
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.VpnService
import androidx.activity.result.contract.ActivityResultContract
import androidx.activity.result.contract.ActivityResultContracts
import org.amnezia.vpn.shadowsocks.core.Core
import org.amnezia.vpn.shadowsocks.core.preference.DataStore
import timber.log.Timber
private val jsonMimeTypes = arrayOf("application/*", "text/*")
object OpenJson : ActivityResultContracts.GetMultipleContents() {
override fun createIntent(context: Context, input: String) = super.createIntent(context,
jsonMimeTypes.first()).apply { putExtra(Intent.EXTRA_MIME_TYPES, jsonMimeTypes) }
}
object SaveJson : ActivityResultContracts.CreateDocument("application/json") {
override fun createIntent(context: Context, input: String) =
super.createIntent(context, "profiles.json")
}
class StartService : ActivityResultContract<Void?, Boolean>() {
private var cachedIntent: Intent? = null
override fun getSynchronousResult(context: Context, input: Void?): SynchronousResult<Boolean>? {
if (DataStore.serviceMode == Key.modeVpn) VpnService.prepare(context)?.let { intent ->
cachedIntent = intent
return null
}
Core.startService()
return SynchronousResult(false)
}
override fun createIntent(context: Context, input: Void?) = cachedIntent!!.also { cachedIntent = null }
override fun parseResult(resultCode: Int, intent: Intent?) = if (resultCode == Activity.RESULT_OK) {
Core.startService()
false
} else {
Timber.e("Failed to start VpnService: $intent")
true
}
}
@@ -20,9 +20,7 @@
package org.amnezia.vpn.shadowsocks.core.utils package org.amnezia.vpn.shadowsocks.core.utils
import android.content.ClipData
import androidx.recyclerview.widget.SortedList import androidx.recyclerview.widget.SortedList
import org.json.JSONArray
private sealed class ArrayIterator<out T> : Iterator<T> { private sealed class ArrayIterator<out T> : Iterator<T> {
abstract val size: Int abstract val size: Int
@@ -32,18 +30,6 @@ private sealed class ArrayIterator<out T> : Iterator<T> {
override fun next(): T = if (hasNext()) this[count++] else throw NoSuchElementException() override fun next(): T = if (hasNext()) this[count++] else throw NoSuchElementException()
} }
private class ClipDataIterator(private val data: ClipData) : ArrayIterator<ClipData.Item>() {
override val size get() = data.itemCount
override fun get(index: Int) = data.getItemAt(index)
}
fun ClipData.asIterable() = Iterable { ClipDataIterator(this) }
private class JSONArrayIterator(private val arr: JSONArray) : ArrayIterator<Any>() {
override val size get() = arr.length()
override fun get(index: Int) = arr.get(index)
}
fun JSONArray.asIterable() = Iterable { JSONArrayIterator(this) }
private class SortedListIterator<out T>(private val list: SortedList<T>) : ArrayIterator<T>() { private class SortedListIterator<out T>(private val list: SortedList<T>) : ArrayIterator<T>() {
override val size get() = list.size() override val size get() = list.size()
override fun get(index: Int) = list[index] override fun get(index: Int) = list[index]
@@ -55,16 +55,12 @@ object Commandline {
*/ */
fun toString(args: Iterable<String>?): String { fun toString(args: Iterable<String>?): String {
// empty path return empty string // empty path return empty string
if (args == null) { args ?: return ""
return ""
}
// path containing one or more elements // path containing one or more elements
val result = StringBuilder() val result = StringBuilder()
for (arg in args) { for (arg in args) {
if (result.isNotEmpty()) result.append(' ') if (result.isNotEmpty()) result.append(' ')
(0 until arg.length) arg.indices.map { arg[it] }.forEach {
.map { arg[it] }
.forEach {
when (it) { when (it) {
' ', '\\', '"', '\'' -> { ' ', '\\', '"', '\'' -> {
result.append('\\') // intentionally no break result.append('\\') // intentionally no break
@@ -115,59 +111,49 @@ object Commandline {
inQuote -> if ("\'" == nextTok) { inQuote -> if ("\'" == nextTok) {
lastTokenHasBeenQuoted = true lastTokenHasBeenQuoted = true
state = normal state = normal
} else { } else current.append(nextTok)
current.append(nextTok) inDoubleQuote -> when (nextTok) {
} "\"" -> if (lastTokenIsSlash) {
inDoubleQuote -> if ("\"" == nextTok) {
if (lastTokenIsSlash) {
current.append(nextTok) current.append(nextTok)
lastTokenIsSlash = false lastTokenIsSlash = false
} else { } else {
lastTokenHasBeenQuoted = true lastTokenHasBeenQuoted = true
state = normal state = normal
} }
} else if ("\\" == nextTok) { "\\" -> lastTokenIsSlash = if (lastTokenIsSlash) {
lastTokenIsSlash = if (lastTokenIsSlash) {
current.append(nextTok) current.append(nextTok)
false false
} else } else true
true else -> {
} else {
if (lastTokenIsSlash) { if (lastTokenIsSlash) {
current.append("\\") // unescaped current.append("\\") // unescaped
lastTokenIsSlash = false lastTokenIsSlash = false
} }
current.append(nextTok) current.append(nextTok)
} }
}
else -> { else -> {
if (lastTokenIsSlash) { when {
lastTokenIsSlash -> {
current.append(nextTok) current.append(nextTok)
lastTokenIsSlash = false lastTokenIsSlash = false
} else if ("\\" == nextTok) }
lastTokenIsSlash = true "\\" == nextTok -> lastTokenIsSlash = true
else if ("\'" == nextTok) { "\'" == nextTok -> state = inQuote
state = inQuote "\"" == nextTok -> state = inDoubleQuote
} else if ("\"" == nextTok) { " " == nextTok -> if (lastTokenHasBeenQuoted || current.isNotEmpty()) {
state = inDoubleQuote
} else if (" " == nextTok) {
if (lastTokenHasBeenQuoted || current.isNotEmpty()) {
result.add(current.toString()) result.add(current.toString())
current.setLength(0) current.setLength(0)
} }
} else { else -> current.append(nextTok)
current.append(nextTok)
} }
lastTokenHasBeenQuoted = false lastTokenHasBeenQuoted = false
} }
} }
} }
if (lastTokenHasBeenQuoted || current.isNotEmpty()) { if (lastTokenHasBeenQuoted || current.isNotEmpty()) result.add(current.toString())
result.add(current.toString()) require(state != inQuote && state != inDoubleQuote) { "unbalanced quotes in $toProcess" }
} require(!lastTokenIsSlash) { "escape character following nothing in $toProcess" }
if (state == inQuote || state == inDoubleQuote) {
throw IllegalArgumentException("unbalanced quotes in $toProcess")
}
if (lastTokenIsSlash) throw IllegalArgumentException("escape character following nothing in $toProcess")
return result.toTypedArray() return result.toTypedArray()
} }
} }
@@ -43,7 +43,7 @@ object Key {
const val route = "route" const val route = "route"
const val isAutoConnect = "isAutoConnect" const val persistAcrossReboot = "isAutoConnect"
const val directBootAware = "directBootAware" const val directBootAware = "directBootAware"
const val proxyApps = "isProxyApps" const val proxyApps = "isProxyApps"
@@ -64,7 +64,6 @@ object Key {
const val dirty = "profileDirty" const val dirty = "profileDirty"
const val tfo = "tcp_fastopen"
const val assetUpdateTime = "assetUpdateTime" const val assetUpdateTime = "assetUpdateTime"
// TV specific values // TV specific values
@@ -72,12 +71,14 @@ object Key {
const val controlImport = "control.import" const val controlImport = "control.import"
const val controlExport = "control.export" const val controlExport = "control.export"
const val about = "about" const val about = "about"
const val aboutOss = "about.ossLicenses"
} }
object Action { object Action {
const val SERVICE = "org.amnezia.vpn.shadowsocks.SERVICE" const val SERVICE = "org.amnezia.vpn.shadowsocks.core.SERVICE"
const val CLOSE = "org.amnezia.vpn.shadowsocks.CLOSE" const val CLOSE = "org.amnezia.vpn.shadowsocks.core.CLOSE"
const val RELOAD = "org.amnezia.vpn.shadowsocks.RELOAD" const val RELOAD = "org.amnezia.vpn.shadowsocks.core.RELOAD"
const val ABORT = "org.amnezia.vpn.shadowsocks.core.ABORT"
const val EXTRA_PROFILE_ID = "org.amnezia.vpn.shadowsocks.EXTRA_PROFILE_ID" const val EXTRA_PROFILE_ID = "org.amnezia.vpn.shadowsocks.core.EXTRA_PROFILE_ID"
} }
@@ -21,8 +21,8 @@ object DirectBoot : BroadcastReceiver() {
private val file = File(Core.deviceStorage.noBackupFilesDir, "directBootProfile") private val file = File(Core.deviceStorage.noBackupFilesDir, "directBootProfile")
private var registered = false private var registered = false
fun getDeviceProfile(): Pair<Profile, Profile?>? = try { fun getDeviceProfile(): ProfileManager.ExpandedProfile? = try {
ObjectInputStream(file.inputStream()).use { it.readObject() as? Pair<Profile, Profile?> } ObjectInputStream(file.inputStream()).use { it.readObject() as? ProfileManager.ExpandedProfile }
} catch (_: IOException) { null } } catch (_: IOException) { null }
fun clean() { fun clean() {
@@ -0,0 +1,41 @@
/*******************************************************************************
* *
* Copyright (C) 2020 by Max Lv <max.c.lv@gmail.com> *
* Copyright (C) 2020 by Mygod Studio <contact-shadowsocks-android@mygod.be> *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* This program is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU General Public License for more details. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
*******************************************************************************/
package org.amnezia.vpn.shadowsocks.core.utils
import androidx.recyclerview.widget.SortedList
import java.net.URL
abstract class BaseSorter<T> : SortedList.Callback<T>() {
override fun onInserted(position: Int, count: Int) { }
override fun areContentsTheSame(oldItem: T?, newItem: T?): Boolean = oldItem == newItem
override fun onMoved(fromPosition: Int, toPosition: Int) { }
override fun onChanged(position: Int, count: Int) { }
override fun onRemoved(position: Int, count: Int) { }
override fun areItemsTheSame(item1: T?, item2: T?): Boolean = item1 == item2
override fun compare(o1: T?, o2: T?): Int =
if (o1 == null) if (o2 == null) 0 else 1 else if (o2 == null) -1 else compareNonNull(o1, o2)
abstract fun compareNonNull(o1: T, o2: T): Int
}
object URLSorter : BaseSorter<URL>() {
private val ordering = compareBy<URL>({ it.host }, { it.port }, { it.file }, { it.protocol })
override fun compareNonNull(o1: URL, o2: URL): Int = ordering.compare(o1, o2)
}
@@ -20,46 +20,82 @@
package org.amnezia.vpn.shadowsocks.core.utils package org.amnezia.vpn.shadowsocks.core.utils
import android.annotation.SuppressLint
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.ContentResolver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageInfo import android.content.pm.PackageInfo
import android.content.res.Resources import android.content.res.Resources
import android.graphics.BitmapFactory
import android.graphics.ImageDecoder
import android.net.Uri
import android.os.Build import android.os.Build
import android.system.Os import android.system.Os
import android.system.OsConstants import android.system.OsConstants
import android.util.TypedValue import android.util.TypedValue
import androidx.annotation.AttrRes import androidx.annotation.AttrRes
import androidx.preference.Preference import androidx.preference.Preference
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import timber.log.Timber
import java.io.FileDescriptor
import java.net.HttpURLConnection import java.net.HttpURLConnection
import java.net.InetAddress import java.net.InetAddress
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
inline fun <T> Iterable<T>.forEachTry(action: (T) -> Unit) {
var result: Exception? = null
for (element in this) try {
action(element)
} catch (e: Exception) {
if (result == null) result = e else result.addSuppressed(e)
}
if (result != null) {
Timber.d(result)
throw result
}
}
val Throwable.readableMessage get() = localizedMessage ?: javaClass.name val Throwable.readableMessage get() = localizedMessage ?: javaClass.name
private val parseNumericAddress by lazy { /**
* https://android.googlesource.com/platform/prebuilts/runtime/+/94fec32/appcompat/hiddenapi-light-greylist.txt#9466
*/
private val getInt = FileDescriptor::class.java.getDeclaredMethod("getInt$")
val FileDescriptor.int get() = getInt.invoke(this) as Int
private val parseNumericAddress by lazy @SuppressLint("SoonBlockedPrivateApi") {
InetAddress::class.java.getDeclaredMethod("parseNumericAddress", String::class.java).apply { InetAddress::class.java.getDeclaredMethod("parseNumericAddress", String::class.java).apply {
isAccessible = true isAccessible = true
} }
} }
/** /**
* A slightly more performant variant of InetAddress.parseNumericAddress. * A slightly more performant variant of parseNumericAddress.
* *
* Bug: https://issuetracker.google.com/issues/123456213 * Bug in Android 9.0 and lower: https://issuetracker.google.com/issues/123456213
*/ */
fun String?.parseNumericAddress(): InetAddress? = Os.inet_pton(OsConstants.AF_INET, this) fun String?.parseNumericAddress(): InetAddress? = Os.inet_pton(OsConstants.AF_INET, this)
?: Os.inet_pton(OsConstants.AF_INET6, this)?.let { parseNumericAddress.invoke(null, this) as InetAddress } ?: Os.inet_pton(OsConstants.AF_INET6, this)?.let {
if (Build.VERSION.SDK_INT >= 29) it else parseNumericAddress.invoke(null, this) as InetAddress
}
fun HttpURLConnection.disconnectFromMain() { suspend fun <T> HttpURLConnection.useCancellable(block: suspend HttpURLConnection.() -> T): T {
return suspendCancellableCoroutine { cont ->
val job = GlobalScope.launch(Dispatchers.IO) {
try {
cont.resume(block())
} catch (e: Throwable) {
cont.resumeWithException(e)
}
}
cont.invokeOnCancellation {
job.cancel(it as? CancellationException)
if (Build.VERSION.SDK_INT >= 26) disconnect() else GlobalScope.launch(Dispatchers.IO) { disconnect() } if (Build.VERSION.SDK_INT >= 26) disconnect() else GlobalScope.launch(Dispatchers.IO) { disconnect() }
} }
}
}
fun parsePort(str: String?, default: Int, min: Int = 1025): Int { fun parsePort(str: String?, default: Int, min: Int = 1025): Int {
val value = str?.toIntOrNull() ?: default val value = str?.toIntOrNull() ?: default
@@ -70,9 +106,18 @@ fun broadcastReceiver(callback: (Context, Intent) -> Unit): BroadcastReceiver =
override fun onReceive(context: Context, intent: Intent) = callback(context, intent) override fun onReceive(context: Context, intent: Intent) = callback(context, intent)
} }
fun ContentResolver.openBitmap(uri: Uri) = fun Context.listenForPackageChanges(onetime: Boolean = true, callback: () -> Unit) = object : BroadcastReceiver() {
if (Build.VERSION.SDK_INT >= 28) ImageDecoder.decodeBitmap(ImageDecoder.createSource(this, uri)) override fun onReceive(context: Context, intent: Intent) {
else BitmapFactory.decodeStream(openInputStream(uri)) callback()
if (onetime) context.unregisterReceiver(this)
}
}.apply {
registerReceiver(this, IntentFilter().apply {
addAction(Intent.ACTION_PACKAGE_ADDED)
addAction(Intent.ACTION_PACKAGE_REMOVED)
addDataScheme("package")
})
}
val PackageInfo.signaturesCompat get() = val PackageInfo.signaturesCompat get() =
if (Build.VERSION.SDK_INT >= 28) signingInfo.apkContentsSigners else @Suppress("DEPRECATION") signatures if (Build.VERSION.SDK_INT >= 28) signingInfo.apkContentsSigners else @Suppress("DEPRECATION") signatures
@@ -86,10 +131,4 @@ fun Resources.Theme.resolveResourceId(@AttrRes resId: Int): Int {
return typedValue.resourceId return typedValue.resourceId
} }
val Intent.datas get() = listOfNotNull(data) + (clipData?.asIterable()?.mapNotNull { it.uri } ?: emptyList())
fun printLog(t: Throwable) {
t.printStackTrace()
}
fun Preference.remove() = parent!!.removePreference(this) fun Preference.remove() = parent!!.removePreference(this)
@@ -0,0 +1,52 @@
/*******************************************************************************
* *
* Copyright (C) 2018 by Max Lv <max.c.lv@gmail.com> *
* Copyright (C) 2018 by Mygod Studio <contact-shadowsocks-android@mygod.be> *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* This program is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU General Public License for more details. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
*******************************************************************************/
package org.amnezia.vpn.shadowsocks.core.widget
import android.content.Context
import android.graphics.Rect
import android.util.AttributeSet
import android.view.MotionEvent
import androidx.appcompat.widget.AppCompatTextView
import androidx.core.view.isGone
import timber.log.Timber
class AutoCollapseTextView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null,
defStyleAttr: Int = 0) :
AppCompatTextView(context, attrs, defStyleAttr) {
override fun onTextChanged(text: CharSequence?, start: Int, lengthBefore: Int, lengthAfter: Int) {
super.onTextChanged(text, start, lengthBefore, lengthAfter)
isGone = text.isNullOrEmpty()
}
// #1874
override fun onFocusChanged(focused: Boolean, direction: Int, previouslyFocusedRect: Rect?) = try {
super.onFocusChanged(focused, direction, previouslyFocusedRect)
} catch (e: IndexOutOfBoundsException) {
Timber.w(e)
}
override fun onTouchEvent(event: MotionEvent?) = try {
super.onTouchEvent(event)
} catch (e: IndexOutOfBoundsException) {
Timber.w(e)
false
}
}
@@ -0,0 +1,68 @@
/*******************************************************************************
* *
* Copyright (C) 2019 by Max Lv <max.c.lv@gmail.com> *
* Copyright (C) 2019 by Mygod Studio <contact-shadowsocks-android@mygod.be> *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* This program is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU General Public License for more details. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
*******************************************************************************/
package org.amnezia.vpn.shadowsocks.plugin
import android.app.Activity
import android.content.DialogInterface
import android.content.Intent
import android.os.Bundle
import android.os.Parcelable
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatDialogFragment
import androidx.fragment.app.Fragment
/**
* Based on: https://android.googlesource.com/platform/packages/apps/ExactCalculator/+/8c43f06/src/com/android/calculator2/AlertDialogFragment.java
*/
@Suppress("DEPRECATION")
@Deprecated("Related APIs are deprecated in AndroidX", ReplaceWith("fragment.AlertDialogFragment"))
abstract class AlertDialogFragment<Arg : Parcelable, Ret : Parcelable> :
AppCompatDialogFragment(), DialogInterface.OnClickListener {
companion object {
private const val KEY_ARG = "arg"
private const val KEY_RET = "ret"
fun <T : Parcelable> getRet(data: Intent) = data.extras!!.getParcelable<T>(KEY_RET)!!
}
protected abstract fun AlertDialog.Builder.prepare(listener: DialogInterface.OnClickListener)
protected val arg by lazy { requireArguments().getParcelable<Arg>(KEY_ARG)!! }
protected open fun ret(which: Int): Ret? = null
fun withArg(arg: Arg) = apply { arguments = Bundle().apply { putParcelable(KEY_ARG, arg) } }
override fun onCreateDialog(savedInstanceState: Bundle?): AlertDialog =
AlertDialog.Builder(requireContext()).also { it.prepare(this) }.create()
override fun onClick(dialog: DialogInterface?, which: Int) {
targetFragment?.onActivityResult(targetRequestCode, which, ret(which)?.let {
Intent().replaceExtras(Bundle().apply { putParcelable(KEY_RET, it) })
})
}
override fun onDismiss(dialog: DialogInterface) {
super.onDismiss(dialog)
onClick(dialog, Activity.RESULT_CANCELED)
}
fun show(target: Fragment, requestCode: Int = 0, tag: String = javaClass.simpleName) {
setTargetFragment(target, requestCode)
showAllowingStateLoss(target.fragmentManager ?: return, tag)
}
}

Some files were not shown because too many files have changed in this diff Show More