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

View File

@@ -22,6 +22,8 @@ buildscript {
}
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.github.ben-manes:gradle-versions-plugin:0.21.0'
classpath 'com.vanniktech:gradle-maven-publish-plugin:0.8.0'
@@ -39,7 +41,7 @@ apply plugin: 'kotlin-kapt'
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar'])
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.security:security-crypto:1.1.0-alpha03"
@@ -63,6 +65,13 @@ dependencies {
def camerax_ml_version = "1.2.0-beta02"
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("com.google.mlkit:barcode-scanning:${ml_kit_version}")
}

View File

@@ -1,14 +1,15 @@
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
//apply plugin: 'kotlin-android-extensions'
apply plugin: 'kotlin-kapt'
apply plugin: 'kotlin-parcelize'
//apply plugin: 'com.novoda.bintray-release'
android {
compileSdkVersion 30
compileSdkVersion androidCompileSdkVersion.toInteger()
defaultConfig {
minSdkVersion 24
targetSdkVersion 30
targetSdkVersion 31
versionCode 1
versionName "1.0.0"
@@ -28,6 +29,9 @@ android {
kotlinOptions {
jvmTarget = '1.8'
}
kapt {
correctErrorTypes = true
}
}
androidExtensions {
@@ -42,29 +46,44 @@ dependencies {
implementation 'androidx.lifecycle:lifecycle-common-java8:2.4.0'
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-core:1.3.0"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4"
implementation "androidx.core:core-ktx:1.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-viewmodel-ktx:2.2.0"
implementation "androidx.room:room-runtime:$roomVersion" // runtime
implementation "androidx.preference:preference:1.1.0"
implementation "androidx.room:room-runtime:2.5.0" // runtime
implementation "androidx.preference:preference:1.2.0"
implementation "androidx.work:work-runtime-ktx:2.7.1"
implementation "androidx.browser:browser:1.3.0-alpha01"
implementation "androidx.constraintlayout:constraintlayout:1.1.3"
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 "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:2.1.9"
implementation "dnsjava:dnsjava:3.5.2"
implementation "org.connectbot.jsocks:jsocks:1.0.0"
implementation "com.afollestad.material-dialogs:core:2.6.0"
// api "com.takisoft.preferencex:preferencex:1.0.0"
implementation 'com.takisoft.preferencex:preferencex:1.1.0'
api 'org.connectbot.jsocks:jsocks:1.0.0'
kapt "androidx.room:room-compiler:$roomVersion"
api 'androidx.fragment:fragment-ktx:1.5.5'
kapt "androidx.room:room-compiler:2.5.0"
kapt "androidx.lifecycle:lifecycle-compiler:2.4.0"
}
apply plugin: 'com.google.gms.google-services'

View File

@@ -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"
}

View File

@@ -0,0 +1 @@
/build

View File

@@ -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.

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.

View File

@@ -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")
}

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.

View File

@@ -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

View File

@@ -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>

View File

@@ -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)
}
}

View File

@@ -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()
}
}

View File

@@ -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) { }
}

View File

@@ -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()
}
}

View File

@@ -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()
}

View File

@@ -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()
}
}

View File

@@ -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
}
}

View File

@@ -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"
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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)
}
}

View File

@@ -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)
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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" />

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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)))
}
}

View File

@@ -7,12 +7,23 @@
android:name="android.hardware.touchscreen"
android:required="false" />
<uses-permission android:name="android.permission.ACCESS_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.INTERNET" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.ACCESS_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.INTERNET"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<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
@@ -75,6 +86,7 @@
<action android:name="android.intent.action.LOCKED_BOOT_COMPLETED" />
</intent-filter>
</receiver>
<!-- https://android.googlesource.com/platform/frameworks/support/+/androidx-master-dev/work/workmanager/src/main/AndroidManifest.xml -->
<provider
@@ -128,5 +140,18 @@
android:directBootAware="true"
android:process=":QtOnlyProcess"
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>
</manifest>

View File

@@ -25,27 +25,31 @@ import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import org.amnezia.vpn.shadowsocks.core.Core.app
import org.amnezia.vpn.shadowsocks.core.preference.DataStore
class BootReceiver : BroadcastReceiver() {
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
get() = app.packageManager.getComponentEnabledSetting(org.amnezia.vpn.shadowsocks.core.BootReceiver.Companion.componentName) ==
get() = app.packageManager.getComponentEnabledSetting(componentName) ==
PackageManager.COMPONENT_ENABLED_STATE_ENABLED
set(value) = app.packageManager.setComponentEnabledSetting(
org.amnezia.vpn.shadowsocks.core.BootReceiver.Companion.componentName,
set(value) = app.packageManager.setComponentEnabledSetting(componentName,
if (value) PackageManager.COMPONENT_ENABLED_STATE_ENABLED
else PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP)
}
override fun onReceive(context: Context, intent: Intent) {
val locked = when (intent.action) {
Intent.ACTION_BOOT_COMPLETED -> false
Intent.ACTION_LOCKED_BOOT_COMPLETED -> true // constant will be folded so no need to do version checks
else -> return
if (!DataStore.persistAcrossReboot) { // sanity check
enabled = false
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()
}
}

View File

@@ -20,60 +20,72 @@
package org.amnezia.vpn.shadowsocks.core
import android.app.Application
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.*
import android.app.admin.DevicePolicyManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.*
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.net.ConnectivityManager
import android.os.Build
import android.os.UserManager
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.annotation.VisibleForTesting
import androidx.core.content.ContextCompat
import androidx.core.content.getSystemService
import androidx.core.os.persistableBundleOf
import androidx.work.Configuration
import androidx.work.WorkManager
import org.amnezia.vpn.shadowsocks.core.acl.Acl
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.ProfileManager
import org.amnezia.vpn.shadowsocks.core.net.TcpFastOpen
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_VALUE_ON
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import timber.log.Timber
import java.io.File
import java.io.IOException
import kotlin.reflect.KClass
object Core {
const val TAG = "Core"
object Core : Configuration.Provider {
lateinit var app: Application
@VisibleForTesting set
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 deviceStorage by lazy { if (Build.VERSION.SDK_INT < 24) app else DeviceStorageApp(app) }
val directBootSupported by lazy {
Build.VERSION.SDK_INT >= 24 && app.getSystemService<DevicePolicyManager>()?.storageEncryptionStatus ==
DevicePolicyManager.ENCRYPTION_STATUS_ACTIVE_PER_USER
Build.VERSION.SDK_INT >= 24 && try {
app.getSystemService<DevicePolicyManager>()?.storageEncryptionStatus ==
DevicePolicyManager.ENCRYPTION_STATUS_ACTIVE_PER_USER
} catch (_: RuntimeException) {
false
}
}
val activeProfileIds
get() = ProfileManager.getProfile(DataStore.profileId).let {
if (it == null) emptyList() else listOfNotNull(it.id, it.udpFallback)
}
val currentProfile: Pair<Profile, Profile?>?
get() {
if (DataStore.directBootAware) DirectBoot.getDeviceProfile()?.apply { return this }
return ProfileManager.expand(ProfileManager.getProfile(DataStore.profileId)
?: return null)
}
val activeProfileIds get() = ProfileManager.getProfile(DataStore.profileId).let {
if (it == null) emptyList() else listOfNotNull(it.id, it.udpFallback)
}
val currentProfile: ProfileManager.ExpandedProfile? get() {
if (DataStore.directBootAware) DirectBoot.getDeviceProfile()?.apply { return this }
return ProfileManager.expand(ProfileManager.getProfile(DataStore.profileId) ?: return null)
}
fun switchProfile(id: Long): Profile {
val result = ProfileManager.getProfile(id) ?: ProfileManager.createProfile()
@@ -82,78 +94,93 @@ object Core {
}
fun init(app: Application, configureClass: KClass<out Any>) {
Core.app = app
configureIntent = {
PendingIntent.getActivity(it, 0,
Intent(it, configureClass.java).setFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
this.app = app
this.configureIntent = {
PendingIntent.getActivity(it, 0, Intent(it, configureClass.java)
.setFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT), PendingIntent.FLAG_IMMUTABLE)
}
if (Build.VERSION.SDK_INT >= 24) { // migrate old files
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()) {
Acl.getFile(Acl.CUSTOM_RULES).writeText(old.readText())
Acl.getFile(Acl.CUSTOM_RULES_USER).writeText(old.readText())
old.delete()
}
}
// 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)
/* 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
if (Build.VERSION.SDK_INT >= 24 && DataStore.directBootAware &&
app.getSystemService<UserManager>()?.isUserUnlocked == true) DirectBoot.flushTrafficStats()
if (DataStore.tcpFastOpen && !TcpFastOpen.sendEnabled) TcpFastOpen.enableTimeout()
if (Build.VERSION.SDK_INT >= 24 && DataStore.directBootAware && user.isUserUnlocked) {
DirectBoot.flushTrafficStats()
}
if (DataStore.publicStore.getLong(Key.assetUpdateTime, -1) != packageInfo.lastUpdateTime) {
val assetManager = app.assets
try {
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) {
printLog(e)
Timber.w(e)
}
DataStore.publicStore.putLong(Key.assetUpdateTime, packageInfo.lastUpdateTime)
}
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() {
if (Build.VERSION.SDK_INT >= 26) @RequiresApi(26) {
val nm = app.getSystemService<NotificationManager>()!!
nm.createNotificationChannels(listOf(
/* if (Build.VERSION.SDK_INT >= 26) @RequiresApi(26) {
notification.createNotificationChannels(listOf(
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),
NotificationManager.IMPORTANCE_LOW),
NotificationChannel("service-transproxy", app.getText(R.string.service_transproxy),
NotificationManager.IMPORTANCE_LOW)))
nm.deleteNotificationChannel("service-nat") // NAT mode is gone for good
}
NotificationManager.IMPORTANCE_LOW),
SubscriptionService.notificationChannel))
notification.deleteNotificationChannel("service-nat") // NAT mode is gone for good
} */
}
fun getPackageInfo(packageName: String) = app.packageManager.getPackageInfo(packageName,
if (Build.VERSION.SDK_INT >= 28) PackageManager.GET_SIGNING_CERTIFICATES
else @Suppress("DEPRECATION") PackageManager.GET_SIGNATURES)!!
fun startService() = ContextCompat.startForegroundService(app, Intent(app, ShadowsocksConnection.serviceClass))
fun reloadService() = app.sendBroadcast(Intent(Action.RELOAD))
fun stopService() = app.sendBroadcast(Intent(Action.CLOSE))
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")
})
}
override fun onReceive(context: Context, intent: Intent) {
if (intent.getBooleanExtra(Intent.EXTRA_REPLACING, false)) return
callback()
if (onetime) app.unregisterReceiver(this)
}
fun trySetPrimaryClip(clip: String, isSensitive: Boolean = false) = try {
clipboard.setPrimaryClip(ClipData.newPlainText(null, clip).apply {
if (isSensitive && Build.VERSION.SDK_INT >= 24) {
//description.extras = persistableBundleOf(ClipDescription.EXTRA_IS_SENSITIVE to true)
}
})
true
} catch (e: RuntimeException) {
Timber.d(e)
false
}
fun startService() = ContextCompat.startForegroundService(app, Intent(app, ShadowsocksConnection.serviceClass))
fun reloadService() = app.sendBroadcast(Intent(Action.RELOAD).setPackage(app.packageName))
fun stopService() = app.sendBroadcast(Intent(Action.CLOSE).setPackage(app.packageName))
}

View File

@@ -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()
}
}
}

View File

@@ -18,8 +18,7 @@ class VpnManager private constructor() {
var state = BaseService.State.Idle
private var context: Context? = null
private val handler = Handler()
private val connection = ShadowsocksConnection(handler, true)
private val connection = ShadowsocksConnection(true)
private var listener: OnStatusChangeListener? = null
private val callback: ShadowsocksConnection.Callback = object : ShadowsocksConnection.Callback {
override fun stateChanged(state: BaseService.State, profileName: String?, msg: String?) {
@@ -69,7 +68,7 @@ class VpnManager private constructor() {
}
}
fun init(context: Context){
fun init(context: Context) {
this.context=context
connect()
}

View File

@@ -24,22 +24,17 @@ import android.app.KeyguardManager
import android.content.BroadcastReceiver
import android.content.Intent
import android.content.IntentFilter
import android.net.VpnService
import android.os.Bundle
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
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.utils.Key
import org.amnezia.vpn.shadowsocks.core.utils.StartService
import org.amnezia.vpn.shadowsocks.core.utils.broadcastReceiver
class VpnRequestActivity : AppCompatActivity() {
companion object {
private const val TAG = "VpnRequestActivity"
private const val REQUEST_CONNECT = 1
}
private var receiver: BroadcastReceiver? = null
override fun onCreate(savedInstanceState: Bundle?) {
@@ -49,21 +44,13 @@ class VpnRequestActivity : AppCompatActivity() {
return
}
if (getSystemService<KeyguardManager>()!!.isKeyguardLocked) {
receiver = broadcastReceiver { _, _ -> request() }
receiver = broadcastReceiver { _, _ -> connect.launch(null) }
registerReceiver(receiver, IntentFilter(Intent.ACTION_USER_PRESENT))
} else request()
} else connect.launch(null)
}
private fun request() {
val intent = VpnService.prepare(this)
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()
}
private val connect = registerForActivityResult(StartService()) {
if (it) Toast.makeText(this, R.string.vpn_permission_denied, Toast.LENGTH_LONG).show()
finish()
}

View File

@@ -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
import android.content.Context
import androidx.recyclerview.widget.SortedList
import org.amnezia.vpn.shadowsocks.core.Core
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 kotlinx.coroutines.Job
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.runBlocking
import timber.log.Timber
import java.io.File
import java.io.IOException
import java.io.Reader
import java.net.HttpURLConnection
import java.net.URL
import java.net.URLConnection
import kotlin.coroutines.coroutineContext
class Acl {
companion object {
const val TAG = "Acl"
const val ALL = "all"
const val BYPASS_LAN = "bypass-lan"
const val BYPASS_CHN = "bypass-china"
@@ -23,48 +48,73 @@ class Acl {
const val GFWLIST = "gfwlist"
const val CHINALIST = "china-list"
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")
var customRules: Acl
get() {
val acl = Acl()
val str = DataStore.publicStore.getString(CUSTOM_RULES)
if (str != null) acl.fromReader(str.reader(), true)
val file = getFile(CUSTOM_RULES_USER)
if (file.canRead()) acl.fromReader(file.reader(), true)
if (!acl.bypass) {
acl.bypass = true
acl.subnets.clear()
}
return acl
}
set(value) = DataStore.publicStore.putString(CUSTOM_RULES,
if ((!value.bypass || value.subnets.size() == 0) && value.bypassHostnames.size() == 0 &&
value.proxyHostnames.size() == 0 && value.urls.size() == 0) null else value.toString())
set(value) = getFile(CUSTOM_RULES_USER).writeText(value.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>() {
override fun compareNonNull(o1: T, o2: T): Int = o1.compareTo(o2)
}
private object StringSorter : DefaultSorter<String>()
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 proxyHostnames = SortedList(String::class.java, StringSorter)
@@ -89,40 +139,11 @@ class Acl {
proxyHostnames.clear()
subnets.clear()
urls.clear()
bypass = defaultBypass
val bypassSubnets by lazy { SortedList(Subnet::class.java, SubnetSorter) }
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]" -> {
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 -> if (subnets != null && input.isNotEmpty()) {
val subnet = Subnet.fromString(input)
if (subnet == null) hostnames!!.add(input) else subnets!!.add(subnet)
}
}
}
val (bypass, subnets) = runBlocking {
parse(reader, bypassHostnames::add, proxyHostnames::add, urls::add, defaultBypass)
}
for (item in (if (bypass) proxySubnets else bypassSubnets).asIterable()) this.subnets.add(item)
this.bypass = bypass
for (item in subnets) this.subnets.add(item)
return this
}
@@ -132,14 +153,13 @@ class Acl {
suspend fun flatten(depth: Int, connect: suspend (URL) -> URLConnection): Acl {
if (depth > 0) for (url in urls.asIterable()) {
val child = Acl()
try {
child.fromReader(connect(url).getInputStream().bufferedReader(), bypass).flatten(depth - 1, connect)
} catch (e: IOException) {
e.printStackTrace()
continue
}
val child = Acl().fromReader(connect(url).also {
(it as? HttpURLConnection)?.instanceFollowRedirects = true
}.getInputStream().bufferedReader(), bypass)
child.flatten(depth - 1, connect)
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.bypass = bypass
}

View File

@@ -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
import android.content.Context
import android.os.Build
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.net.HttpURLConnection
import java.net.URL
import java.util.concurrent.TimeUnit
@@ -11,28 +36,29 @@ class AclSyncer(context: Context, workerParams: WorkerParameters) : CoroutineWor
companion object {
private const val KEY_ROUTE = "route"
fun schedule(route: String) = WorkManager.getInstance().enqueueUniqueWork(route, ExistingWorkPolicy.REPLACE,
OneTimeWorkRequestBuilder<AclSyncer>().run {
setInputData(Data.Builder().putString(KEY_ROUTE, route).build())
setConstraints(Constraints.Builder()
.setRequiredNetworkType(NetworkType.UNMETERED)
.setRequiresCharging(true)
.build())
setInitialDelay(10, TimeUnit.SECONDS)
build()
})
fun schedule(route: String) {
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())
setConstraints(Constraints.Builder()
.setRequiredNetworkType(NetworkType.UNMETERED)
.setRequiresCharging(true)
.build())
setInitialDelay(10, TimeUnit.SECONDS)
build()
})
}
}
override val coroutineContext get() = Dispatchers.IO
override suspend fun doWork(): Result = try {
val route = inputData.getString(KEY_ROUTE)!!
val acl = URL("https://shadowsocks.org/acl/android/v1/$route.acl").openStream().bufferedReader()
.use { it.readText() }
val connection = URL("https://shadowsocks.org/acl/android/v1/$route.acl").openConnection() as HttpURLConnection
val acl = connection.useCancellable { inputStream.bufferedReader().use { it.readText() } }
Acl.getFile(route).printWriter().use { it.write(acl) }
Result.success()
} catch (e: IOException) {
e.printStackTrace()
Result.retry()
Timber.d(e)
if (runAttemptCount > 5) Result.failure() else Result.retry()
}
}

View File

@@ -24,28 +24,27 @@ import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.DeadObjectException
import android.os.Handler
import android.os.IBinder
import android.os.RemoteException
import org.amnezia.vpn.shadowsocks.core.bg.BaseService
import org.amnezia.vpn.shadowsocks.core.bg.ProxyService
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.utils.Action
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.
*/
class ShadowsocksConnection(private val handler: Handler = Handler(),
private var listenForDeath: Boolean = false) :
ServiceConnection, IBinder.DeathRecipient {
class ShadowsocksConnection(private var listenForDeath: Boolean = false) : ServiceConnection, IBinder.DeathRecipient {
companion object {
val serviceClass get() = when (DataStore.serviceMode) {
Key.modeProxy -> ProxyService::class
Key.modeVpn -> ShadowsocksVpnService::class
Key.modeVpn -> VpnService::class
Key.modeTransproxy -> TransproxyService::class
else -> throw UnknownError()
}.java
@@ -70,38 +69,38 @@ class ShadowsocksConnection(private val handler: Handler = Handler(),
private val serviceCallback = object : IShadowsocksServiceCallback.Stub() {
override fun stateChanged(state: Int, profileName: String?, msg: String?) {
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) {
val callback = callback ?: return
handler.post {
callback.trafficUpdated(profileId, stats)
}
GlobalScope.launch(Dispatchers.Main.immediate) { callback.trafficUpdated(profileId, stats) }
}
override fun trafficPersisted(profileId: Long) {
val callback = callback ?: return
handler.post { callback.trafficPersisted(profileId) }
GlobalScope.launch(Dispatchers.Main.immediate) { callback.trafficPersisted(profileId) }
}
}
private var binder: IBinder? = null
var bandwidthTimeout = 0L
set(value) {
val service = service
if (bandwidthTimeout != value && service != null)
if (value > 0) service.startListeningForBandwidth(serviceCallback, value) else try {
service.stopListeningForBandwidth(serviceCallback)
} catch (_: DeadObjectException) { }
try {
if (value > 0) service?.startListeningForBandwidth(serviceCallback, value)
else service?.stopListeningForBandwidth(serviceCallback)
} catch (_: RemoteException) { }
field = value
}
var service: IShadowsocksService? = null
override fun onServiceConnected(name: ComponentName?, binder: IBinder) {
this.binder = binder
if (listenForDeath) binder.linkToDeath(this, 0)
val service = IShadowsocksService.Stub.asInterface(binder)!!
this.service = service
if (!callbackRegistered) try {
try {
if (listenForDeath) binder.linkToDeath(this, 0)
check(!callbackRegistered)
service.registerCallback(serviceCallback)
callbackRegistered = true
if (bandwidthTimeout > 0) service.startListeningForBandwidth(serviceCallback, bandwidthTimeout)
@@ -118,7 +117,8 @@ class ShadowsocksConnection(private val handler: Handler = Handler(),
override fun binderDied() {
service = null
callback?.also { handler.post(it::onBinderDied) }
callbackRegistered = false
callback?.also { GlobalScope.launch(Dispatchers.Main.immediate) { it.onBinderDied() } }
}
private fun unregisterCallback() {
@@ -144,9 +144,13 @@ class ShadowsocksConnection(private val handler: Handler = Handler(),
context.unbindService(this)
} catch (_: IllegalArgumentException) { } // ignore
connectionActive = false
if (listenForDeath) binder?.unlinkToDeath(this, 0)
if (listenForDeath) try {
binder?.unlinkToDeath(this, 0)
} catch (_: NoSuchElementException) { }
binder = null
service?.stopListeningForBandwidth(serviceCallback)
try {
service?.stopListeningForBandwidth(serviceCallback)
} catch (_: RemoteException) { }
service = null
callback = null
}

View File

@@ -20,9 +20,10 @@
package org.amnezia.vpn.shadowsocks.core.aidl
import android.os.Parcel
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Parcelize
data class TrafficStats(
// Bytes per second
var txRate: Long = 0L,
@@ -35,18 +36,4 @@ data class TrafficStats(
operator fun plus(other: TrafficStats) = TrafficStats(
txRate + other.txRate, rxRate + other.rxRate,
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)
}
}

View File

@@ -24,26 +24,33 @@ import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.*
import android.util.Log
import androidx.core.content.getSystemService
import kotlinx.coroutines.*
import android.os.Build
import android.os.IBinder
import android.os.RemoteCallbackList
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.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.IShadowsocksServiceCallback
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.broadcastReceiver
import org.amnezia.vpn.shadowsocks.core.utils.printLog
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.net.BindException
import java.net.InetAddress
import java.io.IOException
import java.net.URL
import java.net.UnknownHostException
/**
* 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_UDP = "shadowsocks-udp.conf"
class Data(private val service: Interface) {
interface ExpectedException
class ExpectedExceptionWrapper(e: Exception) : Exception(e.localizedMessage, e), ExpectedException
class Data (private val service: Interface) {
var state = State.Stopped
var processes: GuardedProcessPool? = null
var proxy: ProxyInstance? = null
var udpFallback: ProxyInstance? = null
var localDns: LocalDnsWorker? = null
// var notification: ServiceNotification? = null
// var notification: ServiceNotification? = null
val closeReceiver = broadcastReceiver { _, intent ->
when (intent.action) {
Intent.ACTION_SHUTDOWN -> service.persistStats()
Action.RELOAD -> service.forceLoad()
else -> service.stopRunner()
}
@@ -88,16 +100,16 @@ object BaseService {
}
}
class Binder(private var data: Data? = null) : IShadowsocksService.Stub(), AutoCloseable {
val callbacks = object : RemoteCallbackList<IShadowsocksServiceCallback>() {
class Binder(private var data: Data? = null) : IShadowsocksService.Stub(), CoroutineScope, AutoCloseable {
private val callbacks = object : RemoteCallbackList<IShadowsocksServiceCallback>() {
override fun onCallbackDied(callback: IShadowsocksServiceCallback?, cookie: Any?) {
super.onCallbackDied(callback, cookie)
stopListeningForBandwidth(callback ?: return)
}
}
private val bandwidthListeners =
mutableMapOf<IBinder, Long>() // the binder is the real identifier
private val handler = Handler()
private val bandwidthListeners = mutableMapOf<IBinder, Long>() // the binder is the real identifier
override val coroutineContext = Dispatchers.Main.immediate + Job()
private var looper: Job? = null
override fun getState(): Int = (data?.state ?: State.Idle).ordinal
override fun getProfileName(): String = data?.proxy?.profile?.name ?: "Idle"
@@ -107,63 +119,63 @@ object BaseService {
}
private fun broadcast(work: (IShadowsocksServiceCallback) -> Unit) {
repeat(callbacks.beginBroadcast()) {
try {
work(callbacks.getBroadcastItem(it))
} catch (_: DeadObjectException) {
} catch (e: Exception) {
printLog(e)
val count = callbacks.beginBroadcast()
try {
repeat(count) {
try {
work(callbacks.getBroadcastItem(it))
} catch (_: RemoteException) {
} catch (e: Exception) {
Timber.w(e)
}
}
} finally {
callbacks.finishBroadcast()
}
callbacks.finishBroadcast()
}
private fun registerTimeout() {
handler.postDelayed(this::onTimeout, bandwidthListeners.values.minOrNull() ?: return)
}
private fun onTimeout() {
val proxies = listOfNotNull(data?.proxy, data?.udpFallback)
val stats = proxies
.map { Pair(it.profile.id, it.trafficMonitor?.requestUpdate()) }
.filter { it.second != null }
.map { Triple(it.first, it.second!!.first, it.second!!.second) }
if (stats.any { it.third } && data?.state == State.Connected && bandwidthListeners.isNotEmpty()) {
val sum = stats.fold(TrafficStats()) { a, b -> a + b.second }
broadcast { item ->
if (bandwidthListeners.contains(item.asBinder())) {
stats.forEach { (id, stats) -> item.trafficUpdated(id, stats) }
item.trafficUpdated(0, sum)
private suspend fun loop() {
while (true) {
delay(bandwidthListeners.values.minOrNull() ?: return)
val proxies = listOfNotNull(data?.proxy, data?.udpFallback)
val stats = proxies
.map { Pair(it.profile.id, it.trafficMonitor?.requestUpdate()) }
.filter { it.second != null }
.map { Triple(it.first, it.second!!.first, it.second!!.second) }
if (stats.any { it.third } && data?.state == State.Connected && bandwidthListeners.isNotEmpty()) {
val sum = stats.fold(TrafficStats()) { a, b -> a + b.second }
broadcast { item ->
if (bandwidthListeners.contains(item.asBinder())) {
stats.forEach { (id, stats) -> item.trafficUpdated(id, stats) }
item.trafficUpdated(0, sum)
}
}
}
}
registerTimeout()
}
override fun startListeningForBandwidth(cb: IShadowsocksServiceCallback, timeout: Long) {
val wasEmpty = bandwidthListeners.isEmpty()
if (bandwidthListeners.put(cb.asBinder(), timeout) == null) {
if (wasEmpty) registerTimeout()
if (data?.state != State.Connected) return
launch {
if (bandwidthListeners.isEmpty() and (bandwidthListeners.put(cb.asBinder(), timeout) == null)) {
check(looper == null)
looper = launch { loop() }
}
if (data?.state != State.Connected) return@launch
var sum = TrafficStats()
val data = data
val proxy = data?.proxy ?: return
val proxy = data?.proxy ?: return@launch
proxy.trafficMonitor?.out.also { stats ->
cb.trafficUpdated(
proxy.profile.id, if (stats == null) sum else {
sum += stats
stats
}
)
cb.trafficUpdated(proxy.profile.id, if (stats == null) sum else {
sum += stats
stats
})
}
data.udpFallback?.also { udpFallback ->
udpFallback.trafficMonitor?.out.also { stats ->
cb.trafficUpdated(
udpFallback.profile.id, if (stats == null) TrafficStats() else {
sum += stats
stats
}
)
cb.trafficUpdated(udpFallback.profile.id, if (stats == null) TrafficStats() else {
sum += stats
stats
})
}
}
cb.trafficUpdated(0, sum)
@@ -171,8 +183,11 @@ object BaseService {
}
override fun stopListeningForBandwidth(cb: IShadowsocksServiceCallback) {
if (bandwidthListeners.remove(cb.asBinder()) != null && bandwidthListeners.isEmpty()) {
handler.removeCallbacksAndMessages(null)
launch {
if (bandwidthListeners.remove(cb.asBinder()) != null && bandwidthListeners.isEmpty()) {
looper!!.cancel()
looper = null
}
}
}
@@ -181,12 +196,12 @@ object BaseService {
callbacks.unregister(cb)
}
fun stateChanged(s: State, msg: String?) {
fun stateChanged(s: State, msg: String?) = launch {
val profileName = profileName
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.contains(item.asBinder())) ids.forEach(item::trafficPersisted)
}
@@ -194,7 +209,7 @@ object BaseService {
override fun close() {
callbacks.kill()
handler.removeCallbacksAndMessages(null)
cancel()
data = null
}
}
@@ -202,48 +217,36 @@ object BaseService {
interface Interface {
val data: Data
val tag: String
// fun createNotification(profileName: String): ServiceNotification
//fun createNotification(profileName: String): ServiceNotification
fun onBind(intent: Intent): IBinder? =
if (intent.action == Action.SERVICE) data.binder else null
fun onBind(intent: Intent): IBinder? = if (intent.action == Action.SERVICE) data.binder else null
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
when {
s == State.Stopped -> startRunner()
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() {
val configRoot = (if (Build.VERSION.SDK_INT < 24 || app.getSystemService<UserManager>()
?.isUserUnlocked != false
) app else Core.deviceStorage).noBackupFilesDir
val context = if (Build.VERSION.SDK_INT < 24 || Core.user.isUserUnlocked) app else Core.deviceStorage
val configRoot = context.noBackupFilesDir
val udpFallback = data.udpFallback
data.proxy!!.start(
this,
File(Core.deviceStorage.noBackupFilesDir, "stat_main"),
File(configRoot, CONFIG_FILE),
if (udpFallback == null) "-u" else null
)
check(udpFallback?.pluginPath == null) { "UDP fallback cannot have plugins" }
udpFallback?.start(
this,
File(Core.deviceStorage.noBackupFilesDir, "stat_udp"),
File(configRoot, CONFIG_FILE_UDP),
"-U"
)
data.proxy!!.start(this,
File(Core.deviceStorage.noBackupFilesDir, "stat_main"),
File(configRoot, CONFIG_FILE),
if (udpFallback == null && data.proxy?.plugin == null) "tcp_and_udp" else "tcp_and_udp")
if (udpFallback?.plugin != null) throw ExpectedExceptionWrapper(IllegalStateException(
"UDP fallback cannot have plugins"))
udpFallback?.start(this,
File(Core.deviceStorage.noBackupFilesDir, "stat_udp"),
File(configRoot, CONFIG_FILE_UDP),
"udp_only", false)
data.localDns = LocalDnsWorker(this::rawResolver).apply { start() }
}
fun startRunner() {
@@ -257,6 +260,8 @@ object BaseService {
close(scope)
data.processes = null
}
data.localDns?.shutdown(scope)
data.localDns = null
}
fun stopRunner(restart: Boolean = false, msg: String? = null) {
@@ -264,6 +269,7 @@ object BaseService {
// change the state
data.changeState(State.Stopping)
GlobalScope.launch(Dispatchers.Main.immediate) {
//Firebase.analytics.logEvent("stop") { param(FirebaseAnalytics.Param.METHOD, tag) }
data.connectingJob?.cancelAndJoin() // ensure stop connecting first
this@Interface as Service
// we use a coroutineScope here to allow clean-up in parallel
@@ -276,8 +282,8 @@ object BaseService {
data.closeReceiverRegistered = false
}
// data.notification?.destroy()
// data.notification = null
// data.notification?.destroy()
// data.notification = null
val ids = listOfNotNull(data.proxy, data.udpFallback).map {
it.shutdown(this)
@@ -292,86 +298,89 @@ object BaseService {
data.changeState(State.Stopped, msg)
// stop the service if nothing has bound to it
if (restart) {
startRunner()
} else {
Log.d("Aman", "Stop Self BaseService-------")
// stopSelf()
if (restart) startRunner() else {
BootReceiver.enabled = false
stopSelf()
}
}
}
suspend fun preInit() {}
suspend fun resolver(host: String) = InetAddress.getAllByName(host)
fun persistStats() =
listOfNotNull(data.proxy, data.udpFallback).forEach { it.trafficMonitor?.persistStats(it.profile.id) }
suspend fun preInit() { }
suspend fun rawResolver(query: ByteArray) = DnsResolverCompat.resolveRawOnActiveNetwork(query)
suspend fun openConnection(url: URL) = url.openConnection()
fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val data = data
if (data.state != State.Stopped) return Service.START_REDELIVER_INTENT
val profilePair = Core.currentProfile
if (data.state != State.Stopped) return Service.START_NOT_STICKY
val expanded = Core.currentProfile
this as Context
if (profilePair == null) {
if (expanded == null) {
// gracefully shutdown: https://stackoverflow.com/q/47337857/2245107
// data.notification = createNotification("")
// data.notification = createNotification("")
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) {
registerReceiver(data.closeReceiver, IntentFilter().apply {
ContextCompat.registerReceiver(this, data.closeReceiver, IntentFilter().apply {
addAction(Action.RELOAD)
addAction(Intent.ACTION_SHUTDOWN)
addAction(Action.CLOSE)
})
}, ContextCompat.RECEIVER_NOT_EXPORTED)
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.connectingJob = GlobalScope.launch(Dispatchers.Main) {
try {
Executable.killAll() // clean up old processes
preInit()
proxy.init(this@Interface)
data.udpFallback?.init(this@Interface)
if (profile.route == Acl.CUSTOM_RULES) try {
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 {
printLog(it)
Timber.w(it)
stopRunner(false, it.readableMessage)
}
startProcesses()
proxy.scheduleUpdate()
data.proxy!!.scheduleUpdate()
data.udpFallback?.scheduleUpdate()
data.changeState(State.Connected)
} catch (_: CancellationException) {
// if the job was cancelled, it is canceller's responsibility to call stopRunner
} catch (_: UnknownHostException) {
stopRunner(false, getString(R.string.invalid_server))
} catch (exc: Throwable) {
if (exc !is PluginManager.PluginNotFoundException &&
exc !is BindException &&
exc !is ShadowsocksVpnService.NullConnectionException
) {
printLog(exc)
}
stopRunner(
false,
"${getString(R.string.service_failed)}: ${exc.readableMessage}"
)
if (exc is ExpectedException) Timber.d(exc) else Timber.w(exc)
stopRunner(false, "${getString(R.string.service_failed)}: ${exc.readableMessage}")
} finally {
data.connectingJob = null
}
}
return Service.START_REDELIVER_INTENT
return Service.START_NOT_STICKY
}
}
}

View File

@@ -24,19 +24,19 @@ import android.system.ErrnoException
import android.system.Os
import android.system.OsConstants
import android.text.TextUtils
import timber.log.Timber
import java.io.File
import java.io.IOException
object Executable {
const val REDSOCKS = "libredsocks.so"
const val SS_LOCAL = "libss-local.so"
const val SS_LOCAL = "libsslocal.so"
const val TUN2SOCKS = "libtun2socks.so"
private val EXECUTABLES = setOf(SS_LOCAL, REDSOCKS, TUN2SOCKS)
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 {
File(process, "cmdline").inputStream().bufferedReader().readText()
} catch (_: IOException) {
@@ -46,7 +46,8 @@ object Executable {
Os.kill(process.name.toInt(), OsConstants.SIGKILL)
} catch (e: ErrnoException) {
if (e.errno != OsConstants.ESRCH) {
e.printStackTrace()
Timber.w("SIGKILL ${exe.absolutePath} (${process.name}) failed")
Timber.w(e)
}
}
}

View File

@@ -25,12 +25,13 @@ import android.os.SystemClock
import android.system.ErrnoException
import android.system.Os
import android.system.OsConstants
import android.util.Log
import androidx.annotation.MainThread
import org.amnezia.vpn.shadowsocks.core.Core
import org.amnezia.vpn.shadowsocks.core.utils.Commandline
import android.util.Log
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import timber.log.Timber
import java.io.File
import java.io.IOException
import java.io.InputStream
@@ -38,7 +39,6 @@ import kotlin.concurrent.thread
class GuardedProcessPool(private val onFatal: suspend (IOException) -> Unit) : CoroutineScope {
companion object {
private const val TAG = "GuardedProcessPool"
private val pid by lazy {
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 {
input.bufferedReader().forEachLine(logger)
} catch (_: IOException) {
} // ignore
} catch (_: IOException) { } // ignore
fun 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>()
try {
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") {
streamLogger(process.inputStream) { Log.i(cmdName, it) }
streamLogger(process.inputStream) { Log.e(cmdName, it) }
// this thread also acts as a daemon thread for waitFor
runBlocking { exitChannel.send(process.waitFor()) }
}
val startTime = SystemClock.elapsedRealtime()
val exitCode = exitChannel.receive()
running = false
if (SystemClock.elapsedRealtime() - startTime < 1000) {
throw IOException("$cmdName exits too fast (exit code: $exitCode)")
when {
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()
running = true
onRestartCallback?.invoke()
}
} catch (e: IOException) {
Log.e(cmdName, "error occurred. stop guard: ${Commandline.toString(cmd)}")
GlobalScope.launch(Dispatchers.Main) { onFatal(e) }
} finally {
if (running) withContext(NonCancellable) {
// clean-up cannot be cancelled
if (running) withContext(NonCancellable) { // clean-up cannot be cancelled
if (Build.VERSION.SDK_INT < 24) {
try {
Os.kill(pid.get(process) as Int, OsConstants.SIGTERM)
} catch (e: ErrnoException) {
if (e.errno != OsConstants.ESRCH) 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
}
@@ -105,6 +113,7 @@ class GuardedProcessPool(private val onFatal: suspend (IOException) -> Unit) : C
@MainThread
fun start(cmd: List<String>, onRestartCallback: (suspend () -> Unit)? = null) {
Log.i("GuardedProcessPool", "start process: ${Commandline.toString(cmd)}")
Guard(cmd).apply {
start() // if start fails, IOException will be thrown directly
launch { looper(onRestartCallback) }

View File

@@ -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)
}
}
}

View File

@@ -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)
}
}
}
}
}
}

View File

@@ -21,80 +21,109 @@
package org.amnezia.vpn.shadowsocks.core.bg
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.AclSyncer
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.plugin.PluginConfiguration
import org.amnezia.vpn.shadowsocks.core.plugin.PluginManager
import org.amnezia.vpn.shadowsocks.plugin.PluginConfiguration
import org.amnezia.vpn.shadowsocks.plugin.PluginManager
import org.amnezia.vpn.shadowsocks.core.preference.DataStore
import org.amnezia.vpn.shadowsocks.core.utils.DirectBoot
import org.amnezia.vpn.shadowsocks.core.utils.parseNumericAddress
import kotlinx.coroutines.*
import kotlinx.coroutines.CoroutineScope
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.io.IOException
import java.net.UnknownHostException
import java.net.URI
import java.net.URISyntaxException
/**
* This class sets up environment for ss-local.
*/
class ProxyInstance(val profile: Profile, private val route: String = profile.route) {
private var configFile: File? = null
var trafficMonitor: TrafficMonitor? = null
private val plugin = PluginConfiguration(profile.plugin ?: "").selectedOptions
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))
init {
require(profile.host.isNotEmpty() && (profile.method == "none" || profile.password.isNotEmpty())) {
app.getString(R.string.proxy_empty)
}
// 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()
}
// 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
var trafficMonitor: TrafficMonitor? = null
val plugin by lazy { PluginManager.init(PluginConfiguration(profile.plugin ?: "")) }
/**
* Sensitive shadowsocks configuration file requires extra protection. It may be stored in encrypted storage or
* device storage, depending on which is currently available.
*/
fun start(service: 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)
// init JSON config
this.configFile = configFile
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())
val cmd = service.buildAdditionalArguments(arrayListOf(
// build the command line
val cmd = arrayListOf(
File((service as Context).applicationInfo.nativeLibraryDir, Executable.SS_LOCAL).absolutePath,
"-b", DataStore.listenAddress,
"-l", DataStore.portProxy.toString(),
"-t", "600",
"-S", stat.absolutePath,
"-c", configFile.absolutePath))
if (extraFlag != null) cmd.add(extraFlag)
"--stat-path", stat.absolutePath,
"-c", configFile.absolutePath,
)
if (service.isVpnService) cmd += "--vpn"
cmd += "--tcp-fast-open"
if (route != Acl.ALL) {
cmd += "--acl"
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)
}
@@ -105,22 +134,7 @@ class ProxyInstance(val profile: Profile, private val route: String = profile.ro
fun shutdown(scope: CoroutineScope) {
trafficMonitor?.apply {
thread.shutdown(scope)
// 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()
}
persistStats(profile.id) // Make sure update total traffic when stopping the runner
}
trafficMonitor = null
configFile?.delete() // remove old config possibly in device storage

View File

@@ -29,8 +29,8 @@ import android.content.Intent
class ProxyService : Service(), BaseService.Interface {
override val data = BaseService.Data(this)
override val tag: String get() = "ShadowsocksProxyService"
// override fun createNotification(profileName: String): ServiceNotification =
// ServiceNotification(this, profileName, "service-proxy", true)
fun createNotification(profileName: String): ServiceNotification =
ServiceNotification(this, profileName, "service-proxy", true)
override fun onBind(intent: Intent) = super.onBind(intent)
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int =

View File

@@ -1,145 +1,124 @@
///*******************************************************************************
// * *
// * 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 android.app.KeyguardManager
//import android.app.NotificationManager
//import android.app.PendingIntent
//import android.app.Service
//import android.content.Context
//import android.content.Intent
//import android.content.IntentFilter
//import android.os.Build
//import android.os.PowerManager
//import android.text.format.Formatter
//import androidx.core.app.NotificationCompat
//import androidx.core.content.ContextCompat
//import androidx.core.content.getSystemService
//import org.amnezia.vpn.shadowsocks.core.Core
//import org.amnezia.vpn.shadowsocks.core.aidl.IShadowsocksServiceCallback
//import org.amnezia.vpn.shadowsocks.core.aidl.TrafficStats
//import org.amnezia.vpn.shadowsocks.core.R
//import org.amnezia.vpn.shadowsocks.core.utils.Action
//import org.amnezia.vpn.shadowsocks.core.utils.broadcastReceiver
//
///**
// * Android < 8 VPN: always invisible because of VPN notification/icon
// * Android < 8 other: only invisible in (possibly unsecure) lockscreen
// * Android 8+: always visible due to system limitations
// * (user can choose to hide the notification in secure lockscreen or anywhere)
// */
//class ServiceNotification(private val service: BaseService.Interface, profileName: String,
// channel: String, private val visible: Boolean = false) {
// private val keyGuard = (service as Context).getSystemService<KeyguardManager>()!!
// private val nm by lazy { (service as Context).getSystemService<NotificationManager>()!! }
// private val callback: IShadowsocksServiceCallback by lazy {
// object : IShadowsocksServiceCallback.Stub() {
// override fun stateChanged(state: Int, profileName: String?, msg: String?) {
// when (state) {
// BaseService.State.Connected.ordinal -> {
// builder.setContentText("VPN Connected")
// }
// BaseService.State.Stopped.ordinal -> {
// builder.setContentText("VPN Disconnected")
// }
// }
// } // ignore
// override fun trafficUpdated(profileId: Long, stats: TrafficStats) {
//// if (profileId != 0L) return
//// service as Context
//// val txr = service.getString(R.string.speed, Formatter.formatFileSize(service, stats.txRate))
//// val rxr = service.getString(R.string.speed, Formatter.formatFileSize(service, stats.rxRate))
//// builder.setContentText("$txr↑\t$rxr↓")
//// style.bigText(service.getString(R.string.stat_summary, txr, rxr,
//// Formatter.formatFileSize(service, stats.txTotal),
//// Formatter.formatFileSize(service, stats.rxTotal)))
//// show()
// }
// override fun trafficPersisted(profileId: Long) { }
// }
// }
//// private val lockReceiver = broadcastReceiver { _, intent -> update(intent.action) }
// private var callbackRegistered = false
//
// private val builder = NotificationCompat.Builder(service as Context, channel)
// .setWhen(0)
// .setColor(ContextCompat.getColor(service, R.color.material_primary_500))
// .setTicker(service.getString(R.string.forward_success))
// .setContentTitle("AmneziaVPN -- testing")
// .setContentIntent(Core.configureIntent(service))
// .setSmallIcon(R.drawable.ic_amnezia_round)
// private val style = NotificationCompat.BigTextStyle(builder).bigText("")
// private var isVisible = true
//
// init {
// service as Context
//// if (Build.VERSION.SDK_INT < 24) builder.addAction(R.drawable.ic_navigation_close,
//// service.getString(R.string.stop), PendingIntent.getBroadcast(service, 0, Intent(Action.CLOSE), 0))
//// update(if (service.getSystemService<PowerManager>()?.isInteractive != false)
//// Intent.ACTION_SCREEN_ON else Intent.ACTION_SCREEN_OFF, true)
//// service.registerReceiver(lockReceiver, IntentFilter().apply {
//// addAction(Intent.ACTION_SCREEN_ON)
//// addAction(Intent.ACTION_SCREEN_OFF)
//// if (visible && Build.VERSION.SDK_INT < 26) addAction(Intent.ACTION_USER_PRESENT)
//// })
// }
//
//// private fun update(action: String?, forceShow: Boolean = false) {
//// if (forceShow || service.data.state == BaseService.State.Connected) when (action) {
//// Intent.ACTION_SCREEN_OFF -> {
//// setVisible(false, forceShow)
//// unregisterCallback() // unregister callback to save battery
//// }
//// Intent.ACTION_SCREEN_ON -> {
//// setVisible(visible && !keyGuard.isKeyguardLocked, forceShow)
//// service.data.binder.registerCallback(callback)
//// service.data.binder.startListeningForBandwidth(callback, 1000)
//// callbackRegistered = true
//// }
//// Intent.ACTION_USER_PRESENT -> setVisible(true, forceShow)
//// }
//// }
//
// private fun unregisterCallback() {
// if (callbackRegistered) {
// 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)
// }
//}
/*******************************************************************************
* *
* 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 android.app.PendingIntent
import android.app.Service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Build
import android.os.PowerManager
import android.text.format.Formatter
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import androidx.core.content.getSystemService
import org.amnezia.vpn.shadowsocks.core.Core
import org.amnezia.vpn.shadowsocks.core.aidl.IShadowsocksServiceCallback
import org.amnezia.vpn.shadowsocks.core.aidl.TrafficStats
import org.amnezia.vpn.shadowsocks.core.R
import org.amnezia.vpn.shadowsocks.core.utils.Action
/**
* User can customize visibility of notification since Android 8.
* The default visibility:
*
* Android 8.x: always visible due to system limitations
* VPN: always invisible because of VPN notification/icon
* Other: always visible
*
* See also: https://github.com/aosp-mirror/platform_frameworks_base/commit/070d142993403cc2c42eca808ff3fafcee220ac4
*/
class ServiceNotification(private val service: BaseService.Interface, profileName: String,
channel: String, visible: Boolean = false) : BroadcastReceiver() {
private val callback: IShadowsocksServiceCallback by lazy {
object : IShadowsocksServiceCallback.Stub() {
override fun stateChanged(state: Int, profileName: String?, msg: String?) { } // ignore
override fun trafficUpdated(profileId: Long, stats: TrafficStats) {
if (profileId != 0L) return
builder.apply {
// setContentText((service as Context).getString(R.string.traffic,
// 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,
// Formatter.formatFileSize(service, stats.txTotal),
// Formatter.formatFileSize(service, stats.rxTotal)))
}
show()
}
override fun trafficPersisted(profileId: Long) { }
}
}
private var callbackRegistered = false
private val builder = NotificationCompat.Builder(service as Context, channel)
.setWhen(0)
.setColor(ContextCompat.getColor(service, R.color.material_primary_500))
.setTicker(service.getString(R.string.forward_success))
.setContentTitle(profileName)
.setContentIntent(Core.configureIntent(service))
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setPriority(if (visible) NotificationCompat.PRIORITY_LOW else NotificationCompat.PRIORITY_MIN)
init {
service as Context
val closeAction = NotificationCompat.Action.Builder(
R.drawable.ic_navigation_close,
service.getText(R.string.stop),
PendingIntent.getBroadcast(service, 0, Intent(Action.CLOSE).setPackage(service.packageName),
PendingIntent.FLAG_IMMUTABLE)).apply {
setAuthenticationRequired(true)
setShowsUserInterface(false)
}.build()
if (Build.VERSION.SDK_INT < 24) builder.addAction(closeAction) else builder.addInvisibleAction(closeAction)
updateCallback(service.getSystemService<PowerManager>()?.isInteractive != false)
service.registerReceiver(this, IntentFilter().apply {
addAction(Intent.ACTION_SCREEN_ON)
addAction(Intent.ACTION_SCREEN_OFF)
})
show()
}
override fun onReceive(context: Context, intent: Intent) {
if (service.data.state == BaseService.State.Connected) updateCallback(intent.action == Intent.ACTION_SCREEN_ON)
}
private fun updateCallback(screenOn: Boolean) {
if (screenOn) {
service.data.binder.registerCallback(callback)
service.data.binder.startListeningForBandwidth(callback, 1000)
callbackRegistered = true
} else if (callbackRegistered) { // unregister callback to save battery
service.data.binder.unregisterCallback(callback)
callbackRegistered = false
}
}
private fun show() = (service as Service).startForeground(1, builder.build())
fun destroy() {
(service as Service).unregisterReceiver(this)
updateCallback(false)
ServiceCompat.stopForeground(service, ServiceCompat.STOP_FOREGROUND_REMOVE)
}
}

View File

@@ -23,7 +23,10 @@ package org.amnezia.vpn.shadowsocks.core.bg
import android.net.LocalSocket
import android.os.SystemClock
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.preference.DataStore
import org.amnezia.vpn.shadowsocks.core.utils.DirectBoot
import java.io.File
import java.io.IOException
import java.nio.ByteBuffer
@@ -34,7 +37,11 @@ class TrafficMonitor(statFile: File) {
private val buffer = ByteArray(16)
private val stat = ByteBuffer.wrap(buffer).order(ByteOrder.LITTLE_ENDIAN)
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 rx = stat.getLong(8)
if (current.txTotal != tx) {
@@ -52,6 +59,7 @@ class TrafficMonitor(statFile: File) {
var out = TrafficStats()
private var timestampLast = 0L
private var dirty = false
private var persisted: TrafficStats? = null
fun requestUpdate(): Pair<TrafficStats, Boolean> {
val now = SystemClock.elapsedRealtime()
@@ -79,4 +87,25 @@ class TrafficMonitor(statFile: File) {
}
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()
}
}
}

View File

@@ -26,19 +26,18 @@ import org.amnezia.vpn.shadowsocks.core.Core
import org.amnezia.vpn.shadowsocks.core.preference.DataStore
import java.io.File
class TransproxyService : Service(), LocalDnsService.Interface {
class TransproxyService : Service(), BaseService.Interface {
override val data = BaseService.Data(this)
override val tag: String get() = "ShadowsocksTransproxyService"
// override fun createNotification(profileName: String): ServiceNotification =
// ServiceNotification(this, profileName, "service-transproxy", true)
fun createNotification(profileName: String): ServiceNotification =
ServiceNotification(this, profileName, "service-transproxy", true)
override fun onBind(intent: Intent) = super.onBind(intent)
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() {
File(Core.deviceStorage.noBackupFilesDir, "redsocks.conf").writeText(
"""base {
File(Core.deviceStorage.noBackupFilesDir, "redsocks.conf").writeText("""base {
log_debug = off;
log_info = off;
log = stderr;
@@ -52,15 +51,9 @@ redsocks {
port = ${DataStore.portProxy};
type = socks5;
}
"""
)
data.processes!!.start(
listOf(
File(applicationInfo.nativeLibraryDir, Executable.REDSOCKS).absolutePath,
"-c",
"redsocks.conf"
)
)
""")
data.processes!!.start(listOf(
File(applicationInfo.nativeLibraryDir, Executable.REDSOCKS).absolutePath, "-c", "redsocks.conf"))
}
override suspend fun startProcesses() {

View File

@@ -30,29 +30,29 @@ import android.os.Build
import android.os.ParcelFileDescriptor
import android.system.ErrnoException
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.R
import org.amnezia.vpn.shadowsocks.core.VpnRequestActivity
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.DefaultNetworkListener
import org.amnezia.vpn.shadowsocks.core.net.DnsResolverCompat
import org.amnezia.vpn.shadowsocks.core.net.Subnet
import org.amnezia.vpn.shadowsocks.core.preference.DataStore
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.delay
import kotlinx.coroutines.launch
import java.io.Closeable
import timber.log.Timber
import java.io.File
import java.io.FileDescriptor
import java.io.IOException
import java.net.URL
import java.util.*
import android.net.VpnService as BaseVpnService
open class ShadowsocksVpnService : BaseVpnService(), LocalDnsService.Interface {
class VpnService : BaseVpnService(), BaseService.Interface {
companion object {
private const val VPN_MTU = 1500
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_ROUTER = "fdfe:dcba:9876::2"
/**
* https://android.googlesource.com/platform/prebuilts/runtime/+/94fec32/appcompat/hiddenapi-light-greylist.txt#9466
*/
private val getInt = FileDescriptor::class.java.getDeclaredMethod("getInt$")
}
class CloseableFd(val fd: FileDescriptor) : Closeable {
override fun close() = Os.close(fd)
private fun <T> FileDescriptor.use(block: (FileDescriptor) -> T) = try {
block(this)
} finally {
try {
Os.close(this)
} catch (_: ErrnoException) { }
}
}
private inner class ProtectWorker : ConcurrentLocalSocketListener("ShadowsocksVpnThread",
File(Core.deviceStorage.noBackupFilesDir, "protect_path")) {
override fun acceptInternal(socket: LocalSocket) {
socket.inputStream.read()
val fd = socket.ancillaryFileDescriptors!!.single()!!
CloseableFd(fd).use {
socket.outputStream.write(if (underlyingNetwork.let { network ->
if (network != null && Build.VERSION.SDK_INT >= 23) try {
network.bindSocket(fd)
true
} catch (e: IOException) {
// suppress ENONET (Machine is not on the network)
if ((e.cause as? ErrnoException)?.errno != 64) printLog(e)
false
} else protect(getInt.invoke(fd) as Int)
}) 0 else 1)
if (socket.inputStream.read() == -1) return
val success = socket.ancillaryFileDescriptors!!.single()!!.use { fd ->
underlyingNetwork.let { network ->
if (network != null) try {
network.bindSocket(fd)
return@let true
} catch (e: IOException) {
when ((e.cause as? ErrnoException)?.errno) {
OsConstants.EPERM, OsConstants.EACCES, 64 -> Timber.d(e)
else -> Timber.w(e)
}
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 val data = BaseService.Data(this)
override val tag: String get() = "ShadowsocksVpnService"
val NOTIFICATION_CHANNEL_ID = "com.amnezia.vpnNotification"
// override fun createNotification(profileName: String): ServiceNotification =
// ServiceNotification(this, profileName, NOTIFICATION_CHANNEL_ID)
fun createNotification(profileName: String): ServiceNotification =
ServiceNotification(this, profileName, "service-vpn")
private var conn: ParcelFileDescriptor? = null
private var worker: ProtectWorker? = null
private var active = false
private var metered = false
@Volatile
private var underlyingNetwork: Network? = null
set(value) {
field = value
if (active && Build.VERSION.SDK_INT >= 22) setUnderlyingNetworks(underlyingNetworks)
if (active) setUnderlyingNetworks(underlyingNetworks)
}
private val underlyingNetworks
get() =
// clearing underlyingNetworks makes Android 9+ consider the network to be metered
if (Build.VERSION.SDK_INT >= 28 && metered) null else underlyingNetwork?.let { arrayOf(it) }
private val underlyingNetworks get() =
// clearing underlyingNetworks makes Android 9 consider the network to be metered
if (Build.VERSION.SDK_INT == 28 && metered) null else underlyingNetwork?.let { arrayOf(it) }
override fun onBind(intent: Intent) = when (intent.action) {
SERVICE_INTERFACE -> super<BaseVpnService>.onBind(intent)
else -> super<LocalDnsService.Interface>.onBind(intent)
else -> super<BaseService.Interface>.onBind(intent)
}
override fun onRevoke() {
stopRunner()
}
override fun onRevoke() = stopRunner()
override fun killProcesses(scope: CoroutineScope) {
super.killProcesses(scope)
@@ -138,14 +138,17 @@ open class ShadowsocksVpnService : BaseVpnService(), LocalDnsService.Interface {
if (DataStore.serviceMode == Key.modeVpn) {
if (prepare(this) != null) {
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()
return Service.START_STICKY
return Service.START_NOT_STICKY
}
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 startProcesses() {
@@ -154,10 +157,7 @@ open class ShadowsocksVpnService : BaseVpnService(), LocalDnsService.Interface {
sendFd(startVpn())
}
override fun buildAdditionalArguments(cmd: ArrayList<String>): ArrayList<String> {
cmd += "-V"
return cmd
}
override val isVpnService get() = true
private suspend fun startVpn(): FileDescriptor {
val profile = data.proxy!!.profile
@@ -168,12 +168,10 @@ open class ShadowsocksVpnService : BaseVpnService(), LocalDnsService.Interface {
.addAddress(PRIVATE_VLAN4_CLIENT, 30)
.addDnsServer(PRIVATE_VLAN4_ROUTER)
if (profile.ipv6) {
builder.addAddress(PRIVATE_VLAN6_CLIENT, 126)
builder.addRoute("::", 0)
}
val me = packageName
if (profile.ipv6) builder.addAddress(PRIVATE_VLAN6_CLIENT, 126)
if (profile.proxyApps) {
val me = packageName
profile.individual.split('\n')
.filter { it != me }
.forEach {
@@ -181,30 +179,32 @@ open class ShadowsocksVpnService : BaseVpnService(), LocalDnsService.Interface {
if (profile.bypass) builder.addDisallowedApplication(it)
else builder.addAllowedApplication(it)
} catch (ex: PackageManager.NameNotFoundException) {
printLog(ex)
Timber.w(ex)
}
}
if (profile.bypass) {
builder.addDisallowedApplication(me)
}
} else {
builder.addDisallowedApplication(me)
if (!profile.bypass) builder.addAllowedApplication(me)
}
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 -> {
resources.getStringArray(R.array.bypass_private_route).forEach {
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)
// https://issuetracker.google.com/issues/149636790
if (profile.ipv6) builder.addRoute("2000::", 3)
}
}
metered = profile.metered
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()
this.conn = conn
@@ -225,7 +225,6 @@ open class ShadowsocksVpnService : BaseVpnService(), LocalDnsService.Interface {
try {
sendFd(conn.fileDescriptor)
} catch (e: ErrnoException) {
e.printStackTrace()
stopRunner(false, e.message)
}
})

View File

@@ -118,8 +118,10 @@ class KeyValuePair() {
fun put(value: Set<String>): KeyValuePair {
valueType = TYPE_STRING_SET
val stream = ByteArrayOutputStream()
val intBuffer = ByteBuffer.allocate(4)
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())
}
this.value = stream.toByteArray()

View File

@@ -23,25 +23,32 @@ package org.amnezia.vpn.shadowsocks.core.database
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import org.amnezia.vpn.shadowsocks.core.Core.app
import org.amnezia.vpn.shadowsocks.core.database.migration.RecreateSchemaMigration
import org.amnezia.vpn.shadowsocks.core.utils.Key
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
@Database(entities = [Profile::class, KeyValuePair::class], version = 29)
@TypeConverters(Profile.SubscriptionStatus::class)
abstract class PrivateDatabase : RoomDatabase() {
companion object {
private val instance by lazy {
Room.databaseBuilder(app, PrivateDatabase::class.java, Key.DB_PROFILE)
.addMigrations(
Migration26,
Migration27,
Migration28
)
.fallbackToDestructiveMigration()
.allowMainThreadQueries()
.build()
Room.databaseBuilder(app, PrivateDatabase::class.java, Key.DB_PROFILE).apply {
addMigrations(
Migration26,
Migration27,
Migration28,
Migration29
)
allowMainThreadQueries()
enableMultiInstanceInvalidation()
fallbackToDestructiveMigration()
setQueryExecutor { GlobalScope.launch { it.run() } }
}.build()
}
val profileDao get() = instance.profileDao()
@@ -66,4 +73,9 @@ abstract class PrivateDatabase : RoomDatabase() {
override fun migrate(database: SupportSQLiteDatabase) =
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)
}
}

View File

@@ -24,20 +24,22 @@ import android.annotation.TargetApi
import android.net.Uri
import android.os.Parcelable
import android.util.Base64
import android.util.Log
import android.util.LongSparseArray
import androidx.core.net.toUri
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.core.preference.DataStore
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 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.JSONObject
import org.json.JSONTokener
import timber.log.Timber
import java.io.Serializable
import java.net.URI
import java.net.URISyntaxException
@@ -48,34 +50,59 @@ import java.util.*
data class Profile(
@PrimaryKey(autoGenerate = true)
var id: Long = 0,
// user configurable fields
var name: String? = "",
var host: String = "155.94.174.51",
var remotePort: Int = 444,
var password: String = "789456123",
var host: String = "example.shadowsocks.org",
var remotePort: Int = 8388,
var password: String = "u1rRWTssNv0p",
var method: String = "aes-256-cfb",
var route: String = "all",
var remoteDns: String = "dns.google",
var proxyApps: Boolean = false,
var bypass: Boolean = false,
var udpdns: Boolean = false,
var ipv6: Boolean = true,
var ipv6: Boolean = false,
@TargetApi(28)
var metered: Boolean = false,
var individual: String = "",
var plugin: String? = null,
var udpFallback: Long? = null,
// managed fields
var subscription: SubscriptionStatus = SubscriptionStatus.UserConfigured,
var tx: Long = 0,
var rx: Long = 0,
var userOrder: Long = 0,
var plugin: String? = null,
var udpFallback: Long? = null,
@Ignore // not persisted in db, only used by direct boot
var dirty: Boolean = false
) : 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 {
private const val TAG = "ShadowParser"
private const val serialVersionUID = 1L
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 legacyPattern = "^(.+?):(.*)@(.+?):(\\d+?)$".toRegex()
@@ -87,7 +114,7 @@ data class Profile(
if (match != null) {
val profile = Profile()
feature?.copyFeatureSettingsTo(profile)
profile.method = match.groupValues[1].toLowerCase()
profile.method = match.groupValues[1].lowercase(Locale.ENGLISH)
profile.password = match.groupValues[2]
profile.host = match.groupValues[3]
profile.remotePort = match.groupValues[4].toInt()
@@ -95,7 +122,7 @@ data class Profile(
profile.name = uri.fragment
profile
} else {
Log.e(TAG, "Unrecognized URI: ${it.value}")
Timber.e("Unrecognized URI: ${it.value}")
null
}
} else {
@@ -110,38 +137,50 @@ data class Profile(
try {
val javaURI = URI(it.value)
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.remotePort = javaURI.port
profile.plugin = uri.getQueryParameter(Key.plugin)
profile.name = uri.fragment ?: ""
profile
} catch (e: URISyntaxException) {
Log.e(TAG, "Invalid URI: ${it.value}")
Timber.e("Invalid URI: ${it.value}")
null
}
} else {
Log.e(TAG, "Unknown user info: ${it.value}")
Timber.e("Unknown user info: ${it.value}")
null
}
}
} catch (e: IllegalArgumentException) {
Log.e(TAG, "Invalid base64 detected: ${it.value}")
Timber.e("Invalid base64 detected: ${it.value}")
null
}
}.filterNotNull()
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? {
val host = json.optString("server")
private val JsonElement?.optString get() = (this as? JsonPrimitive)?.asString
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
val remotePort = json.optInt("server_port")
if (remotePort <= 0) return null
val password = json.optString("password")
val remotePort = json["server_port"]?.optInt
if (remotePort == null || remotePort <= 0) return null
val password = json["password"].optString
if (password.isNullOrEmpty()) return null
val method = json.optString("method")
val method = json["method"].optString
if (method.isNullOrEmpty()) return null
return Profile().also {
it.host = host
@@ -150,37 +189,39 @@ data class Profile(
it.method = method
}.apply {
feature?.copyFeatureSettingsTo(this)
val id = json.optString("plugin")
val id = json["plugin"].optString
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")
route = json.optString("route", route)
name = json["remarks"].optString
route = json["route"].optString ?: route
if (fallback) return@apply
remoteDns = json.optString("remote_dns", remoteDns)
ipv6 = json.optBoolean("ipv6", ipv6)
metered = json.optBoolean("metered", metered)
json.optJSONObject("proxy_apps")?.also {
proxyApps = it.optBoolean("enabled", proxyApps)
bypass = it.optBoolean("bypass", bypass)
individual = it.optJSONArray("android_list")?.asIterable()?.joinToString("\n") ?: individual
remoteDns = json["remote_dns"].optString ?: remoteDns
ipv6 = json["ipv6"].optBoolean ?: ipv6
metered = json["metered"].optBoolean ?: metered
(json["proxy_apps"] as? JsonObject)?.also {
proxyApps = it["enabled"].optBoolean ?: proxyApps
bypass = it["bypass"].optBoolean ?: bypass
individual = (it["android_list"] as? JsonArray)?.asIterable()?.mapNotNull { it.optString }
?.joinToString("\n") ?: individual
}
udpdns = json.optBoolean("udpdns", udpdns)
json.optJSONObject("udp_fallback")?.let { tryParse(it, true) }?.also { fallbackMap[this] = it }
udpdns = json["udpdns"].optBoolean ?: udpdns
(json["udp_fallback"] as? JsonObject)?.let { tryParse(it, true) }?.also { fallbackMap[this] = it }
}
}
fun process(json: Any) {
fun process(json: JsonElement?) {
when (json) {
is JSONObject -> {
is JsonObject -> {
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
}
}
fun finalize(create: (Profile) -> Unit) {
fun finalize(create: (Profile) -> Profile) {
val profiles = ProfileManager.getAllProfiles() ?: emptyList()
for ((profile, fallback) in fallbackMap) {
val match = profiles.firstOrNull {
@@ -188,18 +229,22 @@ data class Profile(
fallback.password == it.password && fallback.method == it.method &&
it.plugin.isNullOrEmpty()
}
profile.udpFallback = if (match == null) {
create(fallback)
fallback.id
} else match.id
profile.udpFallback = (match ?: create(fallback)).id
ProfileManager.updateProfile(profile)
}
}
}
fun parseJson(json: String, feature: Profile? = null, create: (Profile) -> Unit) = JsonParser(feature).run {
process(JSONTokener(json).nextValue())
for (profile in this) create(profile)
finalize(create)
fun parseJson(json: JsonElement, feature: Profile? = null, create: (Profile) -> 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)
}
}
}
@@ -208,8 +253,11 @@ data class Profile(
@Query("SELECT * FROM `Profile` WHERE `id` = :id")
operator fun get(id: Long): Profile?
@Query("SELECT * FROM `Profile` ORDER BY `userOrder`")
fun list(): List<Profile>
@Query("SELECT * FROM `Profile` WHERE `Subscription` != 2 ORDER BY `userOrder`")
fun listActive(): List<Profile>
@Query("SELECT * FROM `Profile`")
fun listAll(): List<Profile>
@Query("SELECT MAX(`userOrder`) + 1 FROM `Profile`")
fun nextOrder(): Long?
@@ -251,11 +299,13 @@ data class Profile(
.scheme("ss")
.encodedAuthority("$auth@$wrappedHost:$remotePort")
val configuration = PluginConfiguration(plugin ?: "")
if (configuration.selected.isNotEmpty())
builder.appendQueryParameter(Key.plugin, configuration.selectedOptions.toString(false))
if (configuration.selected.isNotEmpty()) {
builder.appendQueryParameter(Key.plugin, configuration.getOptions().toString(false))
}
if (!name.isNullOrEmpty()) builder.fragment(name)
return builder.build()
}
override fun toString() = toUri().toString()
fun toJson(profiles: LongSparseArray<Profile>? = null): JSONObject = JSONObject().apply {
@@ -264,7 +314,7 @@ data class Profile(
put("password", password)
put("method", method)
if (profiles == null) return@apply
PluginConfiguration(plugin ?: "").selectedOptions.also {
PluginConfiguration(plugin ?: "").getOptions().also {
if (it.id.isNotEmpty()) {
put("plugin", it.id)
put("plugin_opts", it.toString())
@@ -307,12 +357,14 @@ data class Profile(
DataStore.udpFallback = udpFallback
DataStore.privateStore.remove(Key.dirty)
}
fun deserialize() {
check(id == 0L || DataStore.editingId == id)
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
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)
password = DataStore.privateStore.getString(Key.password) ?: ""
method = DataStore.privateStore.getString(Key.method) ?: ""

View File

@@ -25,10 +25,13 @@ import android.util.LongSparseArray
import org.amnezia.vpn.shadowsocks.core.Core
import org.amnezia.vpn.shadowsocks.core.preference.DataStore
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 timber.log.Timber
import java.io.IOException
import java.io.InputStream
import java.io.Serializable
import java.sql.SQLException
/**
@@ -40,9 +43,18 @@ object ProfileManager {
fun onAdd(profile: Profile)
fun onRemove(profileId: Long)
fun onCleared()
fun reloadProfiles()
}
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)
fun createProfile(profile: Profile = Profile()): Profile {
profile.id = 0
@@ -56,11 +68,10 @@ object ProfileManager {
val profiles = if (replace) getAllProfiles()?.associateBy { it.formattedAddress } else null
val feature = if (replace) {
profiles?.values?.singleOrNull { it.id == DataStore.profileId }
} else Core.currentProfile?.first
} else Core.currentProfile?.main
val lazyClear = lazy { clear() }
var result: Exception? = null
for (json in jsons) try {
Profile.parseJson(json.bufferedReader().readText(), feature) {
jsons.asIterable().forEachTry { json ->
Profile.parseJson(JsonStreamParser(json.bufferedReader()).asSequence().single(), feature) {
if (replace) {
lazyClear.value
// 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)
}
} 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
val lookup = LongSparseArray<Profile>(profiles.size).apply { profiles.forEach { put(it.id, it) } }
return JSONArray(profiles.map { it.toJson(lookup) }.toTypedArray())
@@ -94,12 +103,12 @@ object ProfileManager {
} catch (ex: SQLiteCantOpenDatabaseException) {
throw IOException(ex)
} catch (ex: SQLException) {
printLog(ex)
Timber.w(ex)
null
}
@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)
fun delProfile(id: Long) {
@@ -122,19 +131,29 @@ object ProfileManager {
} catch (ex: SQLiteCantOpenDatabaseException) {
throw IOException(ex)
} catch (ex: SQLException) {
printLog(ex)
Timber.w(ex)
false
}
if (!nonEmpty) DataStore.profileId = createProfile().id
}
@Throws(IOException::class)
fun getAllProfiles(): List<Profile>? = try {
PrivateDatabase.profileDao.list()
fun getActiveProfiles(): List<Profile>? = try {
PrivateDatabase.profileDao.listActive()
} catch (ex: SQLiteCantOpenDatabaseException) {
throw IOException(ex)
} 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
}
}

View File

@@ -26,18 +26,22 @@ import androidx.room.RoomDatabase
import org.amnezia.vpn.shadowsocks.core.Core
import org.amnezia.vpn.shadowsocks.core.database.migration.RecreateSchemaMigration
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() {
companion object {
private val instance by lazy {
Room.databaseBuilder(Core.deviceStorage, PublicDatabase::class.java, Key.DB_PUBLIC)
.allowMainThreadQueries()
.addMigrations(
Migration3
)
.fallbackToDestructiveMigration()
.build()
Room.databaseBuilder(Core.deviceStorage, PublicDatabase::class.java, Key.DB_PUBLIC).apply {
addMigrations(
Migration3
)
allowMainThreadQueries()
enableMultiInstanceInvalidation()
fallbackToDestructiveMigration()
setQueryExecutor { GlobalScope.launch { it.run() } }
}.build()
}
val kvPairDao get() = instance.keyValuePairDao()

View File

@@ -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()
}
}
}

View File

@@ -21,13 +21,13 @@
package org.amnezia.vpn.shadowsocks.core.net
import android.net.LocalSocket
import org.amnezia.vpn.shadowsocks.core.utils.printLog
import kotlinx.coroutines.*
import timber.log.Timber
import java.io.File
abstract class ConcurrentLocalSocketListener(name: String, socketFile: File) : LocalSocketListener(name, socketFile),
CoroutineScope {
override val coroutineContext = Dispatchers.IO + SupervisorJob() + CoroutineExceptionHandler { _, t -> printLog(t) }
override val coroutineContext = Dispatchers.IO + SupervisorJob() + CoroutineExceptionHandler { _, t -> Timber.w(t) }
override fun accept(socket: LocalSocket) {
launch { super.accept(socket) }

View File

@@ -26,11 +26,14 @@ import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.os.Build
import androidx.core.content.getSystemService
import org.amnezia.vpn.shadowsocks.core.Core.app
import kotlinx.coroutines.*
import android.os.Handler
import android.os.Looper
import org.amnezia.vpn.shadowsocks.core.Core
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.channels.actor
import kotlinx.coroutines.runBlocking
import java.net.UnknownHostException
object DefaultNetworkListener {
@@ -45,7 +48,6 @@ object DefaultNetworkListener {
class Update(val network: Network) : NetworkMessage()
class Lost(val network: Network) : NetworkMessage()
}
@ObsoleteCoroutinesApi
private val networkActor = GlobalScope.actor<NetworkMessage>(Dispatchers.Unconfined) {
val listeners = mutableMapOf<Any, (Network?) -> Unit>()
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) {
connectivity.activeNetwork ?: throw UnknownHostException() // failed to listen, return current if available
} else DefaultNetworkListener.NetworkMessage.Get().run {
Core.connectivity.activeNetwork ?: throw UnknownHostException() // failed to listen, return current if available
} else NetworkMessage.Get().run {
networkActor.send(this)
response.await()
}
suspend fun stop(key: Any) = networkActor.send(DefaultNetworkListener.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))
}
}
}
suspend fun stop(key: Any) = networkActor.send(NetworkMessage.Stop(key))
// NB: this runs in ConnectivityThread, and this behavior cannot be changed until API 26
// private object Callback : ConnectivityManager.NetworkCallback() {
// override fun onAvailable(network: Network) = runBlocking { networkActor.send(DefaultNetworkListener.NetworkMessage.Put(network)) }
// override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities?) {
// // it's a good idea to refresh capabilities
// runBlocking { networkActor.send(DefaultNetworkListener.NetworkMessage.Update(network)) }
// }
// override fun onLost(network: Network) = runBlocking { networkActor.send(DefaultNetworkListener.NetworkMessage.Lost(network)) }
// }
private object Callback : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) = runBlocking { networkActor.send(NetworkMessage.Put(network)) }
override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) {
// it's a good idea to refresh capabilities
runBlocking { networkActor.send(NetworkMessage.Update(network)) }
}
override fun onLost(network: Network) = runBlocking { networkActor.send(NetworkMessage.Lost(network)) }
}
private var fallback = false
private val connectivity = app.getSystemService<ConnectivityManager>()!!
private val request = NetworkRequest.Builder().apply {
addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED)
if (Build.VERSION.SDK_INT == 23) { // workarounds for OEM bugs
removeCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
removeCapability(NetworkCapabilities.NET_CAPABILITY_CAPTIVE_PORTAL)
}
}.build()
private val mainHandler = Handler(Looper.getMainLooper())
/**
* Unfortunately registerDefaultNetworkCallback is going to return VPN interface since Android P DP1:
* https://android.googlesource.com/platform/frameworks/base/+/dda156ab0c5d66ad82bdcf76cda07cbc0a9c8a2e
@@ -139,16 +121,39 @@ object DefaultNetworkListener {
*
* Source: https://android.googlesource.com/platform/frameworks/base/+/2df4c7d/services/core/java/com/android/server/ConnectivityService.java#887
*/
private fun register() {
/* 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() {
if (Build.VERSION.SDK_INT in 24..27) @TargetApi(24) {
connectivity.registerDefaultNetworkCallback(Callback)
Core.connectivity.registerDefaultNetworkCallback(Callback)
} else try {
fallback = false
// we want REQUEST here instead of LISTEN
connectivity.requestNetwork(request, Callback)
Core.connectivity.requestNetwork(request, Callback)
} catch (e: SecurityException) {
fallback = true
}
}
private fun unregister() = connectivity.unregisterNetworkCallback(Callback)
private fun unregister() = Core.connectivity.unregisterNetworkCallback(Callback)
}

View File

@@ -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)
}
}

View File

@@ -24,14 +24,15 @@ import android.os.Build
import android.os.SystemClock
import androidx.lifecycle.MutableLiveData
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.acl.Acl
import org.amnezia.vpn.shadowsocks.core.R
import org.amnezia.vpn.shadowsocks.core.preference.DataStore
import org.amnezia.vpn.shadowsocks.core.utils.Key
import org.amnezia.vpn.shadowsocks.core.utils.disconnectFromMain
import kotlinx.coroutines.*
import org.amnezia.vpn.shadowsocks.core.utils.useCancellable
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import java.io.IOException
import java.net.HttpURLConnection
import java.net.Proxy
@@ -75,42 +76,38 @@ class HttpsTest : ViewModel() {
}
}
private var running: Pair<HttpURLConnection, Job>? = null
val status = MutableLiveData<Status>().apply { value = Status.Idle }
private var running: Job? = null
val status = MutableLiveData<Status>(Status.Idle)
fun testConnection() {
cancelTest()
status.value = Status.Testing
val url = URL("https", when ((Core.currentProfile ?: return).first.route) {
Acl.CHINALIST -> "www.qualcomm.cn"
else -> "www.google.com"
}, "/generate_204")
val url = URL("https://cp.cloudflare.com")
val conn = (if (DataStore.serviceMode != Key.modeVpn) {
url.openConnection(Proxy(Proxy.Type.SOCKS, DataStore.proxyAddress))
} else url.openConnection()) as HttpURLConnection
conn.setRequestProperty("Connection", "close")
conn.instanceFollowRedirects = false
conn.useCaches = false
running = conn to GlobalScope.launch(Dispatchers.Main.immediate) {
status.value = withContext(Dispatchers.IO) {
running = GlobalScope.launch(Dispatchers.Main.immediate) {
status.value = conn.useCancellable {
try {
val start = SystemClock.elapsedRealtime()
val code = conn.responseCode
val code = responseCode
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)
} catch (e: IOException) {
Status.Error.IOFailure(e)
} finally {
conn.disconnect()
disconnect()
}
}
}
}
private fun cancelTest() = running?.let { (conn, job) ->
job.cancel() // ensure job is cancelled before interrupting
conn.disconnectFromMain()
private fun cancelTest() {
running?.cancel()
running = null
}

View File

@@ -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() } }
}
}

View File

@@ -20,17 +20,19 @@
package org.amnezia.vpn.shadowsocks.core.net
import android.annotation.SuppressLint
import android.net.LocalServerSocket
import android.net.LocalSocket
import android.net.LocalSocketAddress
import android.system.ErrnoException
import android.system.Os
import android.system.OsConstants
import org.amnezia.vpn.shadowsocks.core.utils.printLog
import kotlinx.coroutines.CoroutineScope
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 timber.log.Timber
import java.io.File
import java.io.IOException
@@ -55,14 +57,15 @@ abstract class LocalSocketListener(name: String, socketFile: File) : Thread(name
try {
accept(serverSocket.accept())
} catch (e: IOException) {
if (running) printLog(e)
if (running) Timber.w(e)
continue
}
}
}
closeChannel.sendBlocking(Unit)
closeChannel.trySendBlocking(Unit).onFailure { throw it!! }
}
@SuppressLint("NewApi")
open fun shutdown(scope: CoroutineScope) {
running = false
localSocket.fileDescriptor?.apply {
@@ -71,7 +74,7 @@ abstract class LocalSocketListener(name: String, socketFile: File) : Thread(name
Os.shutdown(this, OsConstants.SHUT_RDWR)
} catch (e: ErrnoException) {
// suppress fd inactive or already closed
if (e.errno != OsConstants.EBADF && e.errno != OsConstants.ENOTCONN) throw IOException(e)
if (e.errno != OsConstants.EBADF && e.errno != OsConstants.ENOTCONN) throw e.rethrowAsSocketException()
}
}
scope.launch { closeChannel.receive() }

View File

@@ -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()
}
}

View File

@@ -26,10 +26,10 @@ import java.util.*
class Subnet(val address: InetAddress, val prefixSize: Int) : Comparable<Subnet> {
companion object {
fun fromString(value: String): Subnet? {
@Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN")
val parts = (value as java.lang.String).split("/", 2)
fun fromString(value: String, lengthCheck: Int = -1): Subnet? {
val parts = value.split('/', limit = 2)
val addr = parts[0].parseNumericAddress() ?: return null
check(lengthCheck < 0 || addr.address.size == lengthCheck)
return if (parts.size == 2) try {
val prefixSize = parts[1].toInt()
if (prefixSize < 0 || prefixSize > addr.address.size shl 3) null else Subnet(addr, prefixSize)
@@ -42,35 +42,52 @@ class Subnet(val address: InetAddress, val prefixSize: Int) : Comparable<Subnet>
private val addressLength get() = address.address.size shl 3
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 {
if (address.javaClass != other.javaClass) return false
// TODO optimize?
val a = address.address
val b = other.address
var i = 0
while (i * 8 < prefixSize && i * 8 + 8 <= prefixSize) {
if (a[i] != b[i]) return false
class Immutable(private val a: ByteArray, private val prefixSize: Int = 0) {
companion object : Comparator<Immutable> {
override fun compare(a: Immutable, b: Immutable): Int {
check(a.a.size == b.a.size)
for (i in a.a.indices) {
val result = a.a[i].compareTo(b.a[i])
if (result != 0) return result
}
return 0
}
}
fun matches(b: Immutable) = matches(b.a)
fun matches(b: ByteArray): Boolean {
if (a.size != b.size) return false
var i = 0
while (i * 8 < prefixSize && i * 8 + 8 <= prefixSize) {
if (a[i] != b[i]) return false
++i
}
return i * 8 == prefixSize || a[i] == (b[i].toInt() and -(1 shl i * 8 + 8 - prefixSize)).toByte()
}
}
fun toImmutable() = Immutable(address.address.also {
var i = prefixSize / 8
if (prefixSize % 8 > 0) {
it[i] = (it[i].toInt() and -(1 shl i * 8 + 8 - prefixSize)).toByte()
++i
}
if (i * 8 == prefixSize) return true
val mask = 256 - (1 shl (i * 8 + 8 - prefixSize))
return (a[i].toInt() and mask) == (b[i].toInt() and mask)
}
while (i < it.size) it[i++] = 0
}, prefixSize)
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
override fun compareTo(other: Subnet): Int {
val addrThis = address.address
val addrThat = other.address.address
var result = addrThis.size.compareTo(addrThat.size) // IPv4 address goes first
var result = addrThis.size.compareTo(addrThat.size) // IPv4 address goes first
if (result != 0) return result
for ((x, y) in addrThis zip addrThat) {
result = x.unsigned().compareTo(y.unsigned()) // undo sign extension of signed byte
for (i in addrThis.indices) {
result = addrThis[i].unsigned().compareTo(addrThat[i].unsigned()) // undo sign extension of signed byte
if (result != 0) return result
}
return prefixSize.compareTo(other.prefixSize)

View File

@@ -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() } }
}

View File

@@ -18,16 +18,14 @@
* *
*******************************************************************************/
package org.amnezia.vpn.shadowsocks.core.plugin
package org.amnezia.vpn.shadowsocks.plugin
import android.content.pm.ResolveInfo
import android.os.Bundle
class NativePlugin(resolveInfo: ResolveInfo) : ResolvedPlugin(resolveInfo) {
init {
check(resolveInfo.providerInfo != null)
}
override val metaData: Bundle get() = resolveInfo.providerInfo.metaData
override val packageName: String get() = resolveInfo.providerInfo.packageName
override val componentInfo get() = resolveInfo.providerInfo!!
}

View File

@@ -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.R
object NoPlugin : Plugin() {
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)
}

View File

@@ -18,15 +18,24 @@
* *
*******************************************************************************/
package org.amnezia.vpn.shadowsocks.core.plugin
package org.amnezia.vpn.shadowsocks.plugin
import android.graphics.drawable.Drawable
abstract class Plugin {
abstract val id: String
open val idAliases get() = emptyArray<String>()
abstract val label: CharSequence
open val icon: Drawable? get() = null
open val defaultConfig: String? get() = null
open val packageName: String get() = ""
open val trusted: Boolean get() = true
open val directBootAware: Boolean get() = true
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
return id == (other as Plugin).id
}
override fun hashCode() = id.hashCode()
}

View File

@@ -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.plugin.PluginOptions
import timber.log.Timber
import java.util.*
class PluginConfiguration(val pluginsOptions: Map<String, PluginOptions>, val selected: String) {
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)
constructor(plugin: String) : this(plugin.split('\n').map { line ->
if (line.startsWith("kcptun ")) {
@@ -43,19 +43,21 @@ class PluginConfiguration(val pluginsOptions: Map<String, PluginOptions>, val se
}
}
} catch (exc: Exception) {
Timber.w(exc)
}
opt
} else PluginOptions(line)
})
fun getOptions(id: String): PluginOptions = if (id.isEmpty()) PluginOptions() else
pluginsOptions[id] ?: PluginOptions(id, PluginManager.fetchPlugins()[id]?.defaultConfig)
val selectedOptions: PluginOptions get() = getOptions(selected)
fun getOptions(
id: String = selected,
defaultConfig: () -> String? = { PluginManager.fetchPlugins().lookup[id]?.defaultConfig }
) = if (id.isEmpty()) PluginOptions() else pluginsOptions[id] ?: PluginOptions(id, defaultConfig())
override fun toString(): String {
val result = LinkedList<PluginOptions>()
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) }
}
}

View File

@@ -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))
}
}
}

View File

@@ -18,33 +18,36 @@
* *
*******************************************************************************/
package org.amnezia.vpn.shadowsocks.core.plugin
package org.amnezia.vpn.shadowsocks.plugin
import android.annotation.SuppressLint
import android.content.BroadcastReceiver
import android.content.ContentResolver
import android.content.Intent
import android.content.pm.ComponentInfo
import android.content.pm.PackageManager
import android.content.pm.ProviderInfo
import android.content.pm.Signature
import android.database.Cursor
import android.net.Uri
import android.os.Build
import android.system.Os
import android.util.Base64
import android.widget.Toast
import androidx.core.os.bundleOf
import 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.utils.printLog
import org.amnezia.vpn.shadowsocks.core.bg.BaseService
import org.amnezia.vpn.shadowsocks.core.utils.listenForPackageChanges
import org.amnezia.vpn.shadowsocks.core.utils.signaturesCompat
import org.amnezia.vpn.shadowsocks.plugin.PluginContract
import org.amnezia.vpn.shadowsocks.plugin.PluginOptions
import timber.log.Timber
import java.io.File
import java.io.FileNotFoundException
object PluginManager {
class PluginNotFoundException(private val plugin: String) : FileNotFoundException(plugin) {
override fun getLocalizedMessage() = app.getString(R.string.plugin_unknown, plugin)
class PluginNotFoundException(private val plugin: String) : FileNotFoundException(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 cachedPlugins: Map<String, Plugin>? = null
fun fetchPlugins(): Map<String, Plugin> = synchronized(this) {
if (receiver == null) receiver = Core.listenForPackageChanges {
private var cachedPlugins: PluginList? = null
fun fetchPlugins() = synchronized(this) {
if (receiver == null) receiver = app.listenForPackageChanges {
synchronized(this) {
receiver = null
cachedPlugins = null
}
}
if (cachedPlugins == null) {
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 }
}
if (cachedPlugins == null) cachedPlugins = PluginList()
cachedPlugins!!
}
@@ -119,46 +118,89 @@ object PluginManager {
.build()
fun buildIntent(id: String, action: String): Intent = Intent(action, buildUri(id))
data class InitResult(
val path: String,
val options: PluginOptions,
val isV2: Boolean = false,
)
// the following parts are meant to be used by :bg
@Throws(Throwable::class)
fun init(options: PluginOptions): String? {
if (options.id.isEmpty()) return null
fun init(configuration: PluginConfiguration): InitResult? {
if (configuration.selected.isEmpty()) return null
var throwable: Throwable? = null
try {
val path = initNative(options)
if (path != null) return path
val result = initNative(configuration)
if (result != null) return result
} 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
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(
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
val uri = Uri.Builder()
.scheme(ContentResolver.SCHEME_CONTENT)
.authority(providers.single().providerInfo.authority)
.build()
val cr = app.contentResolver
return try {
initNativeFast(cr, options, uri)
if (providers.size > 1) {
val message = "Conflicting plugins found from: ${providers.joinToString { it.providerInfo.packageName }}"
Toast.makeText(app, message, Toast.LENGTH_LONG).show()
throw IllegalStateException(message)
}
val provider = providers.single().providerInfo
val options = configuration.getOptions { provider.loadString(PluginContract.METADATA_KEY_DEFAULT_CONFIG) }
val isV2 = provider.applicationInfo.metaData?.getString(PluginContract.METADATA_KEY_VERSION)
?.substringBefore('.')?.toIntOrNull() ?: 0 >= 2
var failure: Throwable? = null
try {
initNativeFaster(provider)?.also { return InitResult(it, options, isV2) }
} catch (t: Throwable) {
printLog(t)
initNativeSlow(cr, options, uri)
Timber.w("Initializing native plugin faster mode failed")
failure = t
}
val uri = Uri.Builder().apply {
scheme(ContentResolver.SCHEME_CONTENT)
authority(provider.authority)
}.build()
try {
return initNativeFast(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 {
val result = cr.call(uri, PluginContract.METHOD_GET_EXECUTABLE, null,
bundleOf(Pair(PluginContract.EXTRA_OPTIONS, options.id)))!!.getString(PluginContract.EXTRA_ENTRY)!!
check(File(result).canExecute())
return result
private fun initNativeFaster(provider: ProviderInfo): String? {
return provider.loadString(PluginContract.METADATA_KEY_EXECUTABLE_PATH)?.let { relativePath ->
File(provider.applicationInfo.nativeLibraryDir).resolve(relativePath).apply {
check(canExecute())
}.absolutePath
}
}
private fun initNativeFast(cr: ContentResolver, options: PluginOptions, uri: Uri): String? {
return cr.call(uri, PluginContract.METHOD_GET_EXECUTABLE, null,
bundleOf(PluginContract.EXTRA_OPTIONS to options.id))?.getString(PluginContract.EXTRA_ENTRY)?.also {
check(File(it).canExecute())
}
}
@SuppressLint("Recycle")
@@ -190,4 +232,11 @@ object PluginManager {
if (!initialized) entryNotFound()
return File(pluginDir, options.id).absolutePath
}
fun ComponentInfo.loadString(key: String) = when (val value = metaData.get(key)) {
is String -> value
is Int -> app.packageManager.getResourcesForApplication(applicationInfo).getString(value)
null -> null
else -> error("meta-data $key has invalid type ${value.javaClass}")
}
}

View File

@@ -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.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.app
import org.amnezia.vpn.shadowsocks.plugin.PluginManager.loadString
import org.amnezia.vpn.shadowsocks.core.utils.signaturesCompat
import org.amnezia.vpn.shadowsocks.plugin.PluginContract
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 label: CharSequence by lazy { resolveInfo.loadLabel(app.packageManager) }
override val icon: Drawable by lazy { resolveInfo.loadIcon(app.packageManager) }
override val defaultConfig: String by lazy { metaData.getString(PluginContract.METADATA_KEY_DEFAULT_CONFIG)!! }
override val packageName: String get() = resolveInfo.resolvePackageName
override val id by lazy { componentInfo.loadString(PluginContract.METADATA_KEY_ID)!! }
override val idAliases: Array<String> by lazy {
when (val value = componentInfo.metaData.get(PluginContract.METADATA_KEY_ID_ALIASES)) {
is String -> arrayOf(value)
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 {
Core.getPackageInfo(packageName).signaturesCompat.any(PluginManager.trustedSignatures::contains)
}
override val directBootAware get() = Build.VERSION.SDK_INT < 24 || componentInfo.directBootAware
}

View File

@@ -22,16 +22,14 @@ package org.amnezia.vpn.shadowsocks.core.preference
import android.os.Binder
import androidx.preference.PreferenceDataStore
import org.amnezia.vpn.shadowsocks.core.BootReceiver
import org.amnezia.vpn.shadowsocks.core.Core
import org.amnezia.vpn.shadowsocks.core.database.PrivateDatabase
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.Key
import org.amnezia.vpn.shadowsocks.core.utils.parsePort
import java.net.InetSocketAddress
import java.net.NetworkInterface
import java.net.SocketException
object DataStore : OnPreferenceDataStoreChangeListener {
val publicStore = RoomPreferenceDataStore(PublicDatabase.kvPairDao)
@@ -42,7 +40,7 @@ object DataStore : OnPreferenceDataStoreChangeListener {
publicStore.registerChangeListener(this)
}
override fun onPreferenceDataStoreChanged(store: PreferenceDataStore, key: String?) {
override fun onPreferenceDataStoreChanged(store: PreferenceDataStore, key: String) {
when (key) {
Key.id -> if (directBootAware) DirectBoot.update()
}
@@ -61,31 +59,12 @@ object DataStore : OnPreferenceDataStoreChangeListener {
var profileId: Long
get() = publicStore.getLong(Key.id) ?: 0
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 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
/**
* 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"
val listenAddress get() = if (publicStore.getBoolean(Key.shareOverLan, false)) "0.0.0.0" else "127.0.0.1"
var portProxy: Int
get() = getLocalPort(Key.portProxy, 1080)
set(value) = publicStore.putString(Key.portProxy, value.toString())
@@ -101,7 +80,7 @@ object DataStore : OnPreferenceDataStoreChangeListener {
* Initialize settings that have complicated default values.
*/
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.portLocalDns) == null) portLocalDns = portLocalDns
if (publicStore.getString(Key.portTransproxy) == null) portTransproxy = portTransproxy

View File

@@ -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)
}
}
}

View File

@@ -23,5 +23,5 @@ package org.amnezia.vpn.shadowsocks.core.preference
import androidx.preference.PreferenceDataStore
interface OnPreferenceDataStoreChangeListener {
fun onPreferenceDataStoreChanged(store: PreferenceDataStore, key: String?)
fun onPreferenceDataStoreChanged(store: PreferenceDataStore, key: String)
}

View File

@@ -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()
}
}

View File

@@ -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()
}
}

View File

@@ -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
}
}

View File

@@ -20,9 +20,7 @@
package org.amnezia.vpn.shadowsocks.core.utils
import android.content.ClipData
import androidx.recyclerview.widget.SortedList
import org.json.JSONArray
private sealed class ArrayIterator<out T> : Iterator<T> {
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()
}
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>() {
override val size get() = list.size()
override fun get(index: Int) = list[index]

View File

@@ -55,24 +55,20 @@ object Commandline {
*/
fun toString(args: Iterable<String>?): String {
// empty path return empty string
if (args == null) {
return ""
}
args ?: return ""
// path containing one or more elements
val result = StringBuilder()
for (arg in args) {
if (result.isNotEmpty()) result.append(' ')
(0 until arg.length)
.map { arg[it] }
.forEach {
when (it) {
' ', '\\', '"', '\'' -> {
result.append('\\') // intentionally no break
result.append(it)
}
else -> result.append(it)
}
arg.indices.map { arg[it] }.forEach {
when (it) {
' ', '\\', '"', '\'' -> {
result.append('\\') // intentionally no break
result.append(it)
}
else -> result.append(it)
}
}
}
return result.toString()
}
@@ -115,59 +111,49 @@ object Commandline {
inQuote -> if ("\'" == nextTok) {
lastTokenHasBeenQuoted = true
state = normal
} else {
current.append(nextTok)
}
inDoubleQuote -> if ("\"" == nextTok) {
if (lastTokenIsSlash) {
} else current.append(nextTok)
inDoubleQuote -> when (nextTok) {
"\"" -> if (lastTokenIsSlash) {
current.append(nextTok)
lastTokenIsSlash = false
} else {
lastTokenHasBeenQuoted = true
state = normal
}
} else if ("\\" == nextTok) {
lastTokenIsSlash = if (lastTokenIsSlash) {
"\\" -> lastTokenIsSlash = if (lastTokenIsSlash) {
current.append(nextTok)
false
} else
true
} else {
if (lastTokenIsSlash) {
current.append("\\") // unescaped
lastTokenIsSlash = false
} else true
else -> {
if (lastTokenIsSlash) {
current.append("\\") // unescaped
lastTokenIsSlash = false
}
current.append(nextTok)
}
current.append(nextTok)
}
else -> {
if (lastTokenIsSlash) {
current.append(nextTok)
lastTokenIsSlash = false
} else if ("\\" == nextTok)
lastTokenIsSlash = true
else if ("\'" == nextTok) {
state = inQuote
} else if ("\"" == nextTok) {
state = inDoubleQuote
} else if (" " == nextTok) {
if (lastTokenHasBeenQuoted || current.isNotEmpty()) {
when {
lastTokenIsSlash -> {
current.append(nextTok)
lastTokenIsSlash = false
}
"\\" == nextTok -> lastTokenIsSlash = true
"\'" == nextTok -> state = inQuote
"\"" == nextTok -> state = inDoubleQuote
" " == nextTok -> if (lastTokenHasBeenQuoted || current.isNotEmpty()) {
result.add(current.toString())
current.setLength(0)
}
} else {
current.append(nextTok)
else -> current.append(nextTok)
}
lastTokenHasBeenQuoted = false
}
}
}
if (lastTokenHasBeenQuoted || current.isNotEmpty()) {
result.add(current.toString())
}
if (state == inQuote || state == inDoubleQuote) {
throw IllegalArgumentException("unbalanced quotes in $toProcess")
}
if (lastTokenIsSlash) throw IllegalArgumentException("escape character following nothing in $toProcess")
if (lastTokenHasBeenQuoted || current.isNotEmpty()) result.add(current.toString())
require(state != inQuote && state != inDoubleQuote) { "unbalanced quotes in $toProcess" }
require(!lastTokenIsSlash) { "escape character following nothing in $toProcess" }
return result.toTypedArray()
}
}

View File

@@ -43,7 +43,7 @@ object Key {
const val route = "route"
const val isAutoConnect = "isAutoConnect"
const val persistAcrossReboot = "isAutoConnect"
const val directBootAware = "directBootAware"
const val proxyApps = "isProxyApps"
@@ -64,7 +64,6 @@ object Key {
const val dirty = "profileDirty"
const val tfo = "tcp_fastopen"
const val assetUpdateTime = "assetUpdateTime"
// TV specific values
@@ -72,12 +71,14 @@ object Key {
const val controlImport = "control.import"
const val controlExport = "control.export"
const val about = "about"
const val aboutOss = "about.ossLicenses"
}
object Action {
const val SERVICE = "org.amnezia.vpn.shadowsocks.SERVICE"
const val CLOSE = "org.amnezia.vpn.shadowsocks.CLOSE"
const val RELOAD = "org.amnezia.vpn.shadowsocks.RELOAD"
const val SERVICE = "org.amnezia.vpn.shadowsocks.core.SERVICE"
const val CLOSE = "org.amnezia.vpn.shadowsocks.core.CLOSE"
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"
}

View File

@@ -21,8 +21,8 @@ object DirectBoot : BroadcastReceiver() {
private val file = File(Core.deviceStorage.noBackupFilesDir, "directBootProfile")
private var registered = false
fun getDeviceProfile(): Pair<Profile, Profile?>? = try {
ObjectInputStream(file.inputStream()).use { it.readObject() as? Pair<Profile, Profile?> }
fun getDeviceProfile(): ProfileManager.ExpandedProfile? = try {
ObjectInputStream(file.inputStream()).use { it.readObject() as? ProfileManager.ExpandedProfile }
} catch (_: IOException) { null }
fun clean() {

View File

@@ -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)
}

View File

@@ -20,45 +20,81 @@
package org.amnezia.vpn.shadowsocks.core.utils
import android.annotation.SuppressLint
import android.content.BroadcastReceiver
import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageInfo
import android.content.res.Resources
import android.graphics.BitmapFactory
import android.graphics.ImageDecoder
import android.net.Uri
import android.os.Build
import android.system.Os
import android.system.OsConstants
import android.util.TypedValue
import androidx.annotation.AttrRes
import androidx.preference.Preference
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import timber.log.Timber
import java.io.FileDescriptor
import java.net.HttpURLConnection
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
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 {
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)
?: 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() {
if (Build.VERSION.SDK_INT >= 26) disconnect() else GlobalScope.launch(Dispatchers.IO) { disconnect() }
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() }
}
}
}
fun parsePort(str: String?, default: Int, min: Int = 1025): Int {
@@ -70,9 +106,18 @@ fun broadcastReceiver(callback: (Context, Intent) -> Unit): BroadcastReceiver =
override fun onReceive(context: Context, intent: Intent) = callback(context, intent)
}
fun ContentResolver.openBitmap(uri: Uri) =
if (Build.VERSION.SDK_INT >= 28) ImageDecoder.decodeBitmap(ImageDecoder.createSource(this, uri))
else BitmapFactory.decodeStream(openInputStream(uri))
fun Context.listenForPackageChanges(onetime: Boolean = true, callback: () -> Unit) = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
callback()
if (onetime) context.unregisterReceiver(this)
}
}.apply {
registerReceiver(this, IntentFilter().apply {
addAction(Intent.ACTION_PACKAGE_ADDED)
addAction(Intent.ACTION_PACKAGE_REMOVED)
addDataScheme("package")
})
}
val PackageInfo.signaturesCompat get() =
if (Build.VERSION.SDK_INT >= 28) signingInfo.apkContentsSigners else @Suppress("DEPRECATION") signatures
@@ -86,10 +131,4 @@ fun Resources.Theme.resolveResourceId(@AttrRes resId: Int): Int {
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)

View File

@@ -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
}
}

View File

@@ -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