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