diff --git a/app/.gitignore b/app/.gitignore
new file mode 100644
index 0000000..2abde4a
--- /dev/null
+++ b/app/.gitignore
@@ -0,0 +1,2 @@
+/build
+/google-services.json
diff --git a/app/build.gradle b/app/build.gradle
new file mode 100644
index 0000000..4864b3c
--- /dev/null
+++ b/app/build.gradle
@@ -0,0 +1,126 @@
+plugins {
+ id 'com.android.application'
+ id 'org.jetbrains.kotlin.android'
+}
+
+
+android {
+ compileSdkVersion Integer.parseInt("$compileSdkVer")
+ buildToolsVersion "$buildToolsVer"
+
+ compileOptions {
+ sourceCompatibility = "8"
+ targetCompatibility = "8"
+ }
+
+ defaultConfig {
+ applicationId "com.v2ray.ang"
+ minSdkVersion 21
+ targetSdk 33
+ multiDexEnabled true
+ versionCode 560
+ versionName "1.8.23-cuynu"
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled false
+ zipAlignEnabled false
+ shrinkResources false
+ ndk.abiFilters 'x86', 'x86_64', 'armeabi-v7a', 'arm64-v8a'
+ }
+ debug {
+ minifyEnabled false
+ zipAlignEnabled false
+ shrinkResources false
+ ndk.abiFilters 'x86', 'x86_64', 'armeabi-v7a', 'arm64-v8a'
+ }
+ }
+
+ sourceSets {
+ main {
+ jniLibs.srcDirs = ['libs']
+ java.srcDirs += 'src/main/kotlin'
+ }
+ }
+
+ kotlinOptions {
+ jvmTarget = JavaVersion.VERSION_1_8
+ }
+
+ splits {
+ abi {
+ enable true
+ reset()
+ include 'x86', 'x86_64', 'armeabi-v7a', 'arm64-v8a' //select ABIs to build APKs for
+ universalApk true //generate an additional APK that contains all the ABIs
+ }
+ }
+
+ // map for the version code
+ project.ext.versionCodes = ['armeabi-v7a': 1, 'arm64-v8a': 2, 'x86': 3, 'x86_64': 4]
+
+ android.applicationVariants.all { variant ->
+ // assign different version code for each output
+ variant.outputs.each { output ->
+ output.outputFileName = "v2rayNG_" + variant.versionName + "_" + output.getFilter(com.android.build.OutputFile.ABI) + ".apk"
+
+ output.versionCodeOverride =
+ project.ext.versionCodes.get(output.getFilter(com.android.build.OutputFile.ABI), 0) *
+ 1000000 + android.defaultConfig.versionCode
+ }
+ }
+
+ buildFeatures {
+ viewBinding true
+ }
+ namespace 'com.v2ray.ang'
+ testNamespace 'com.v2ray.angTest'
+}
+
+dependencies {
+ implementation fileTree(dir: 'libs', include: ['*.aar', '*.jar'], exclude: [])
+ testImplementation 'junit:junit:4.13.2'
+
+ // Androidx
+ implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
+ implementation 'androidx.legacy:legacy-support-v4:1.0.0'
+ implementation 'androidx.appcompat:appcompat:1.6.1'
+ implementation 'com.google.android.material:material:1.8.0'
+ implementation 'androidx.cardview:cardview:1.0.0'
+ implementation 'androidx.preference:preference-ktx:1.2.0'
+ implementation 'androidx.recyclerview:recyclerview:1.3.0'
+ implementation 'androidx.fragment:fragment-ktx:1.5.6'
+ implementation 'androidx.multidex:multidex:2.0.1'
+ implementation 'androidx.viewpager2:viewpager2:1.1.0-beta01'
+
+ // Androidx ktx
+ implementation 'androidx.activity:activity-ktx:1.7.0'
+ implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1'
+ implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.6.1'
+ implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.1'
+
+ //kotlin
+ implementation "org.jetbrains.kotlin:kotlin-reflect:1.8.0"
+ implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4"
+ implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4"
+
+ implementation 'com.tencent:mmkv-static:1.2.15'
+ implementation 'com.google.code.gson:gson:2.10.1'
+ implementation 'io.reactivex:rxjava:1.3.8'
+ implementation 'io.reactivex:rxandroid:1.2.1'
+ implementation 'com.tbruyelle.rxpermissions:rxpermissions:0.9.4@aar'
+ implementation 'com.github.jorgecastilloprz:fabprogresscircle:1.01@aar'
+ implementation 'me.drakeet.support:toastcompat:1.1.0'
+ implementation 'com.blacksquircle.ui:editorkit:2.8.0'
+ implementation 'com.blacksquircle.ui:language-base:2.8.0'
+ implementation 'com.blacksquircle.ui:language-json:2.8.0'
+ implementation 'io.github.g00fy2.quickie:quickie-bundled:1.6.0'
+ implementation 'com.google.zxing:core:3.5.1'
+
+ def work_version = "2.8.1"
+
+ implementation "androidx.work:work-runtime-ktx:$work_version"
+ implementation "androidx.work:work-multiprocess:$work_version"
+}
+
diff --git a/app/libs/arm64-v8a/libtun2socks.so b/app/libs/arm64-v8a/libtun2socks.so
new file mode 100644
index 0000000..3dbdde7
Binary files /dev/null and b/app/libs/arm64-v8a/libtun2socks.so differ
diff --git a/app/libs/armeabi-v7a/libtun2socks.so b/app/libs/armeabi-v7a/libtun2socks.so
new file mode 100644
index 0000000..79e3a96
Binary files /dev/null and b/app/libs/armeabi-v7a/libtun2socks.so differ
diff --git a/app/libs/libv2ray.aar b/app/libs/libv2ray.aar
new file mode 100644
index 0000000..238e1bc
Binary files /dev/null and b/app/libs/libv2ray.aar differ
diff --git a/app/libs/x86/libtun2socks.so b/app/libs/x86/libtun2socks.so
new file mode 100644
index 0000000..30a48fc
Binary files /dev/null and b/app/libs/x86/libtun2socks.so differ
diff --git a/app/libs/x86_64/libtun2socks.so b/app/libs/x86_64/libtun2socks.so
new file mode 100644
index 0000000..ce965f0
Binary files /dev/null and b/app/libs/x86_64/libtun2socks.so differ
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
new file mode 100644
index 0000000..e69de29
diff --git a/app/src/androidTest/java/com/v2ray/ang/ApplicationTest.java b/app/src/androidTest/java/com/v2ray/ang/ApplicationTest.java
new file mode 100644
index 0000000..e221e71
--- /dev/null
+++ b/app/src/androidTest/java/com/v2ray/ang/ApplicationTest.java
@@ -0,0 +1,13 @@
+package com.v2ray.ang;
+
+import android.app.Application;
+import android.test.ApplicationTestCase;
+
+/**
+ * Testing Fundamentals
+ */
+public class ApplicationTest extends ApplicationTestCase {
+ public ApplicationTest() {
+ super(Application.class);
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..747fcc4
--- /dev/null
+++ b/app/src/main/AndroidManifest.xml
@@ -0,0 +1,205 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/assets/custom_routing_block b/app/src/main/assets/custom_routing_block
new file mode 100644
index 0000000..a6ebf39
--- /dev/null
+++ b/app/src/main/assets/custom_routing_block
@@ -0,0 +1 @@
+geosite:category-ads-all,
\ No newline at end of file
diff --git a/app/src/main/assets/custom_routing_direct b/app/src/main/assets/custom_routing_direct
new file mode 100644
index 0000000..5408992
--- /dev/null
+++ b/app/src/main/assets/custom_routing_direct
@@ -0,0 +1,132 @@
+domain:12306.com,
+domain:51ym.me,
+domain:52pojie.cn,
+domain:8686c.com,
+domain:abercrombie.com,
+domain:adobesc.com,
+domain:air-matters.com,
+domain:air-matters.io,
+domain:airtable.com,
+domain:akadns.net,
+domain:apache.org,
+domain:api.crisp.chat,
+domain:api.termius.com,
+domain:appshike.com,
+domain:appstore.com,
+domain:aweme.snssdk.com,
+domain:bababian.com,
+domain:battle.net,
+domain:beatsbydre.com,
+domain:bet365.com,
+domain:bilibili.cn,
+domain:ccgslb.com,
+domain:ccgslb.net,
+domain:chunbo.com,
+domain:chunboimg.com,
+domain:clashroyaleapp.com,
+domain:cloudsigma.com,
+domain:cloudxns.net,
+domain:cmfu.com,
+domain:culturedcode.com,
+domain:dct-cloud.com,
+domain:didialift.com,
+domain:douyutv.com,
+domain:duokan.com,
+domain:dytt8.net,
+domain:easou.com,
+domain:ecitic.net,
+domain:eclipse.org,
+domain:eudic.net,
+domain:ewqcxz.com,
+domain:fir.im,
+domain:frdic.com,
+domain:fresh-ideas.cc,
+domain:godic.net,
+domain:goodread.com,
+domain:haibian.com,
+domain:hdslb.net,
+domain:hollisterco.com,
+domain:hongxiu.com,
+domain:hxcdn.net,
+domain:images.unsplash.com,
+domain:img4me.com,
+domain:ipify.org,
+domain:ixdzs.com,
+domain:jd.hk,
+domain:jianshuapi.com,
+domain:jomodns.com,
+domain:jsboxbbs.com,
+domain:knewone.com,
+domain:kuaidi100.com,
+domain:lemicp.com,
+domain:letvcloud.com,
+domain:lizhi.io,
+domain:localizecdn.com,
+domain:lucifr.com,
+domain:luoo.net,
+domain:mai.tn,
+domain:maven.org,
+domain:miwifi.com,
+domain:moji.com,
+domain:moke.com,
+domain:mtalk.google.com,
+domain:mxhichina.com,
+domain:myqcloud.com,
+domain:myunlu.com,
+domain:netease.com,
+domain:nfoservers.com,
+domain:nssurge.com,
+domain:nuomi.com,
+domain:ourdvs.com,
+domain:overcast.fm,
+domain:paypal.com,
+domain:paypalobjects.com,
+domain:pgyer.com,
+domain:qdaily.com,
+domain:qdmm.com,
+domain:qin.io,
+domain:qingmang.me,
+domain:qingmang.mobi,
+domain:qqurl.com,
+domain:rarbg.to,
+domain:rrmj.tv,
+domain:ruguoapp.com,
+domain:sm.ms,
+domain:snwx.com,
+domain:soku.com,
+domain:startssl.com,
+domain:store.steampowered.com,
+domain:symcd.com,
+domain:teamviewer.com,
+domain:tmzvps.com,
+domain:trello.com,
+domain:trellocdn.com,
+domain:ttmeiju.com,
+domain:udache.com,
+domain:uxengine.net,
+domain:weather.bjango.com,
+domain:weather.com,
+domain:webqxs.com,
+domain:weico.cc,
+domain:wenku8.net,
+domain:werewolf.53site.com,
+domain:windowsupdate.com,
+domain:wkcdn.com,
+domain:workflowy.com,
+domain:xdrig.com,
+domain:xiaojukeji.com,
+domain:xiaomi.net,
+domain:xiaomicp.com,
+domain:ximalaya.com,
+domain:xitek.com,
+domain:xmcdn.com,
+domain:xslb.net,
+domain:xteko.com,
+domain:yach.me,
+domain:yixia.com,
+domain:yunjiasu-cdn.net,
+domain:zealer.com,
+domain:zgslb.net,
+domain:zimuzu.tv,
+domain:zmz002.com,
+domain:samsungdm.com,
\ No newline at end of file
diff --git a/app/src/main/assets/custom_routing_proxy b/app/src/main/assets/custom_routing_proxy
new file mode 100644
index 0000000..d67a623
--- /dev/null
+++ b/app/src/main/assets/custom_routing_proxy
@@ -0,0 +1,33 @@
+geosite:google,
+geosite:github,
+geosite:netflix,
+geosite:steam,
+geosite:telegram,
+geosite:tumblr,
+geosite:speedtest,
+geosite:bbc,
+domain:gvt1.com,
+domain:textnow.com,
+domain:twitch.tv,
+domain:wikileaks.org,
+domain:naver.com,
+91.108.4.0/22,
+91.108.8.0/22,
+91.108.12.0/22,
+91.108.20.0/22,
+91.108.36.0/23,
+91.108.38.0/23,
+91.108.56.0/22,
+149.154.160.0/20,
+149.154.164.0/22,
+149.154.172.0/22,
+74.125.0.0/16,
+173.194.0.0/16,
+172.217.0.0/16,
+216.58.200.0/24,
+216.58.220.0/24,
+91.108.56.116,
+91.108.56.0/24,
+109.239.140.0/24,
+149.154.167.0/24,
+149.154.175.0/24,
\ No newline at end of file
diff --git a/app/src/main/assets/proxy_packagename.txt b/app/src/main/assets/proxy_packagename.txt
new file mode 100644
index 0000000..e69de29
diff --git a/app/src/main/assets/v2ray_config.json b/app/src/main/assets/v2ray_config.json
new file mode 100755
index 0000000..b0163e5
--- /dev/null
+++ b/app/src/main/assets/v2ray_config.json
@@ -0,0 +1,105 @@
+{
+ "stats":{},
+ "log": {
+ "loglevel": "warning"
+ },
+ "policy":{
+ "levels": {
+ "8": {
+ "handshake": 4,
+ "connIdle": 300,
+ "uplinkOnly": 1,
+ "downlinkOnly": 1
+ }
+ },
+ "system": {
+ "statsOutboundUplink": true,
+ "statsOutboundDownlink": true
+ }
+ },
+ "inbounds": [{
+ "tag": "socks",
+ "port": 10808,
+ "protocol": "socks",
+ "settings": {
+ "auth": "noauth",
+ "udp": true,
+ "userLevel": 8
+ },
+ "sniffing": {
+ "enabled": true,
+ "destOverride": [
+ "http",
+ "tls"
+ ]
+ }
+ },
+ {
+ "tag": "http",
+ "port": 10809,
+ "protocol": "http",
+ "settings": {
+ "userLevel": 8
+ }
+ }
+],
+ "outbounds": [{
+ "tag": "proxy",
+ "protocol": "vmess",
+ "settings": {
+ "vnext": [
+ {
+ "address": "v2ray.cool",
+ "port": 10086,
+ "users": [
+ {
+ "id": "a3482e88-686a-4a58-8126-99c9df64b7bf",
+ "alterId": 0,
+ "security": "auto",
+ "level": 8
+ }
+ ]
+ }
+ ],
+ "servers": [
+ {
+ "address": "v2ray.cool",
+ "method": "chacha20",
+ "ota": false,
+ "password": "123456",
+ "port": 10086,
+ "level": 8
+ }
+ ]
+ },
+ "streamSettings": {
+ "network": "tcp"
+ },
+ "mux": {
+ "enabled": true
+ }
+ },
+ {
+ "protocol": "freedom",
+ "settings": {},
+ "tag": "direct"
+ },
+ {
+ "protocol": "blackhole",
+ "tag": "block",
+ "settings": {
+ "response": {
+ "type": "http"
+ }
+ }
+ }
+ ],
+ "routing": {
+ "domainStrategy": "IPIfNonMatch",
+ "rules": []
+ },
+ "dns": {
+ "hosts": {},
+ "servers": []
+ }
+}
diff --git a/app/src/main/ic_launcher-web.png b/app/src/main/ic_launcher-web.png
new file mode 100644
index 0000000..03a4ce8
Binary files /dev/null and b/app/src/main/ic_launcher-web.png differ
diff --git a/app/src/main/java/com/v2ray/ang/helper/ItemTouchHelperAdapter.java b/app/src/main/java/com/v2ray/ang/helper/ItemTouchHelperAdapter.java
new file mode 100644
index 0000000..2de0c74
--- /dev/null
+++ b/app/src/main/java/com/v2ray/ang/helper/ItemTouchHelperAdapter.java
@@ -0,0 +1,60 @@
+/*
+ * Copyright (C) 2015 Paul Burke
+ *
+ * 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.
+ */
+
+package com.v2ray.ang.helper;
+
+import androidx.recyclerview.widget.RecyclerView;
+import androidx.recyclerview.widget.ItemTouchHelper;
+
+/**
+ * Interface to listen for a move or dismissal event from a {@link ItemTouchHelper.Callback}.
+ *
+ * @author Paul Burke (ipaulpro)
+ */
+public interface ItemTouchHelperAdapter {
+
+ /**
+ * Called when an item has been dragged far enough to trigger a move. This is called every time
+ * an item is shifted, and not at the end of a "drop" event.
+ *
+ * Implementations should call {@link RecyclerView.Adapter#notifyItemMoved(int, int)} after
+ * adjusting the underlying data to reflect this move.
+ *
+ * @param fromPosition The start position of the moved item.
+ * @param toPosition Then resolved position of the moved item.
+ * @return True if the item was moved to the new adapter position.
+ *
+ * @see RecyclerView#getAdapterPositionFor(RecyclerView.ViewHolder)
+ * @see RecyclerView.ViewHolder#getAdapterPosition()
+ */
+ boolean onItemMove(int fromPosition, int toPosition);
+
+
+ void onItemMoveCompleted();
+
+ /**
+ * Called when an item has been dismissed by a swipe.
+ *
+ * Implementations should call {@link RecyclerView.Adapter#notifyItemRemoved(int)} after
+ * adjusting the underlying data to reflect this removal.
+ *
+ * @param position The position of the item dismissed.
+ *
+ * @see RecyclerView#getAdapterPositionFor(RecyclerView.ViewHolder)
+ * @see RecyclerView.ViewHolder#getAdapterPosition()
+ */
+ void onItemDismiss(int position);
+}
diff --git a/app/src/main/java/com/v2ray/ang/helper/ItemTouchHelperViewHolder.java b/app/src/main/java/com/v2ray/ang/helper/ItemTouchHelperViewHolder.java
new file mode 100644
index 0000000..149768f
--- /dev/null
+++ b/app/src/main/java/com/v2ray/ang/helper/ItemTouchHelperViewHolder.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright (C) 2015 Paul Burke
+ *
+ * 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.
+ */
+
+package com.v2ray.ang.helper;
+
+import androidx.recyclerview.widget.ItemTouchHelper;
+
+/**
+ * Interface to notify an item ViewHolder of relevant callbacks from {@link
+ * ItemTouchHelper.Callback}.
+ *
+ * @author Paul Burke (ipaulpro)
+ */
+public interface ItemTouchHelperViewHolder {
+
+ /**
+ * Called when the {@link ItemTouchHelper} first registers an item as being moved or swiped.
+ * Implementations should update the item view to indicate it's active state.
+ */
+ void onItemSelected();
+
+
+ /**
+ * Called when the {@link ItemTouchHelper} has completed the move or swipe, and the active item
+ * state should be cleared.
+ */
+ void onItemClear();
+}
diff --git a/app/src/main/java/com/v2ray/ang/helper/OnStartDragListener.java b/app/src/main/java/com/v2ray/ang/helper/OnStartDragListener.java
new file mode 100644
index 0000000..a6407b6
--- /dev/null
+++ b/app/src/main/java/com/v2ray/ang/helper/OnStartDragListener.java
@@ -0,0 +1,33 @@
+/*
+ * Copyright (C) 2015 Paul Burke
+ *
+ * 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.
+ */
+
+package com.v2ray.ang.helper;
+
+import androidx.recyclerview.widget.RecyclerView;
+
+/**
+ * Listener for manual initiation of a drag.
+ */
+public interface OnStartDragListener {
+
+ /**
+ * Called when a view is requesting a start of a drag.
+ *
+ * @param viewHolder The holder of the view to drag.
+ */
+ void onStartDrag(RecyclerView.ViewHolder viewHolder);
+
+}
diff --git a/app/src/main/java/com/v2ray/ang/helper/SimpleItemTouchHelperCallback.java b/app/src/main/java/com/v2ray/ang/helper/SimpleItemTouchHelperCallback.java
new file mode 100644
index 0000000..97fceab
--- /dev/null
+++ b/app/src/main/java/com/v2ray/ang/helper/SimpleItemTouchHelperCallback.java
@@ -0,0 +1,128 @@
+/*
+ * Copyright (C) 2015 Paul Burke
+ *
+ * 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.
+ */
+
+package com.v2ray.ang.helper;
+
+import android.graphics.Canvas;
+import androidx.recyclerview.widget.GridLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+import androidx.recyclerview.widget.ItemTouchHelper;
+
+import org.jetbrains.annotations.NotNull;
+
+/**
+ * An implementation of {@link ItemTouchHelper.Callback} that enables basic drag & drop and
+ * swipe-to-dismiss. Drag events are automatically started by an item long-press.
+ *
+ * Expects the RecyclerView.Adapter to listen for {@link
+ * ItemTouchHelperAdapter} callbacks and the RecyclerView.ViewHolder to implement
+ * {@link ItemTouchHelperViewHolder}.
+ *
+ * @author Paul Burke (ipaulpro)
+ */
+public class SimpleItemTouchHelperCallback extends ItemTouchHelper.Callback {
+
+ public static final float ALPHA_FULL = 1.0f;
+
+ private final ItemTouchHelperAdapter mAdapter;
+
+ public SimpleItemTouchHelperCallback(ItemTouchHelperAdapter adapter) {
+ mAdapter = adapter;
+ }
+
+ @Override
+ public boolean isLongPressDragEnabled() {
+ return true;
+ }
+
+ @Override
+ public boolean isItemViewSwipeEnabled() {
+ return false;
+ }
+
+ @Override
+ public int getMovementFlags(RecyclerView recyclerView, @NotNull RecyclerView.ViewHolder viewHolder) {
+ // Set movement flags based on the layout manager
+ if (recyclerView.getLayoutManager() instanceof GridLayoutManager) {
+ final int dragFlags = ItemTouchHelper.UP | ItemTouchHelper.DOWN | ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT;
+ final int swipeFlags = 0;
+ return makeMovementFlags(dragFlags, swipeFlags);
+ } else {
+ final int dragFlags = ItemTouchHelper.UP | ItemTouchHelper.DOWN;
+ final int swipeFlags = ItemTouchHelper.START | ItemTouchHelper.END;
+ return makeMovementFlags(dragFlags, swipeFlags);
+ }
+ }
+
+ @Override
+ public boolean onMove(@NotNull RecyclerView recyclerView, RecyclerView.ViewHolder source, RecyclerView.ViewHolder target) {
+ if (source.getItemViewType() != target.getItemViewType()) {
+ return false;
+ }
+
+ // Notify the adapter of the move
+ mAdapter.onItemMove(source.getBindingAdapterPosition(), target.getBindingAdapterPosition());
+ return true;
+ }
+
+ @Override
+ public void onSwiped(RecyclerView.ViewHolder viewHolder, int i) {
+ // Notify the adapter of the dismissal
+ mAdapter.onItemDismiss(viewHolder.getBindingAdapterPosition());
+ }
+
+ @Override
+ public void onChildDraw(@NotNull Canvas c, @NotNull RecyclerView recyclerView, @NotNull RecyclerView.ViewHolder viewHolder, float dX,
+ float dY, int actionState, boolean isCurrentlyActive) {
+ if (actionState == ItemTouchHelper.ACTION_STATE_SWIPE) {
+ // Fade out the view as it is swiped out of the parent's bounds
+ final float alpha = ALPHA_FULL - Math.abs(dX) / (float) viewHolder.itemView.getWidth();
+ viewHolder.itemView.setAlpha(alpha);
+ viewHolder.itemView.setTranslationX(dX);
+ } else {
+ super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive);
+ }
+ }
+
+ @Override
+ public void onSelectedChanged(RecyclerView.ViewHolder viewHolder, int actionState) {
+ // We only want the active item to change
+ if (actionState != ItemTouchHelper.ACTION_STATE_IDLE) {
+ if (viewHolder instanceof ItemTouchHelperViewHolder) {
+ // Let the view holder know that this item is being moved or dragged
+ ItemTouchHelperViewHolder itemViewHolder = (ItemTouchHelperViewHolder) viewHolder;
+ itemViewHolder.onItemSelected();
+ }
+ }
+
+ super.onSelectedChanged(viewHolder, actionState);
+ }
+
+ @Override
+ public void clearView(@NotNull RecyclerView recyclerView, @NotNull RecyclerView.ViewHolder viewHolder) {
+ super.clearView(recyclerView, viewHolder);
+
+ mAdapter.onItemMoveCompleted();
+
+ viewHolder.itemView.setAlpha(ALPHA_FULL);
+
+ if (viewHolder instanceof ItemTouchHelperViewHolder) {
+ // Tell the view holder it's time to restore the idle state
+ ItemTouchHelperViewHolder itemViewHolder = (ItemTouchHelperViewHolder) viewHolder;
+ itemViewHolder.onItemClear();
+ }
+ }
+}
diff --git a/app/src/main/kotlin/com/v2ray/ang/AngApplication.kt b/app/src/main/kotlin/com/v2ray/ang/AngApplication.kt
new file mode 100644
index 0000000..ed6bbae
--- /dev/null
+++ b/app/src/main/kotlin/com/v2ray/ang/AngApplication.kt
@@ -0,0 +1,42 @@
+package com.v2ray.ang
+
+import android.content.Context
+import androidx.multidex.MultiDexApplication
+import androidx.preference.PreferenceManager
+import com.tencent.mmkv.MMKV
+import androidx.work.Configuration
+
+class AngApplication : MultiDexApplication(), Configuration.Provider {
+ companion object {
+ const val PREF_LAST_VERSION = "pref_last_version"
+ lateinit var application: AngApplication
+ }
+
+ override fun attachBaseContext(base: Context?) {
+ super.attachBaseContext(base)
+ application = this
+ }
+
+ var firstRun = false
+ private set
+
+ override fun onCreate() {
+ super.onCreate()
+
+// LeakCanary.install(this)
+
+ val defaultSharedPreferences = PreferenceManager.getDefaultSharedPreferences(this)
+ firstRun = defaultSharedPreferences.getInt(PREF_LAST_VERSION, 0) != BuildConfig.VERSION_CODE
+ if (firstRun)
+ defaultSharedPreferences.edit().putInt(PREF_LAST_VERSION, BuildConfig.VERSION_CODE).apply()
+
+ //Logger.init().logLevel(if (BuildConfig.DEBUG) LogLevel.FULL else LogLevel.NONE)
+ MMKV.initialize(this)
+ }
+
+ override fun getWorkManagerConfiguration(): Configuration {
+ return Configuration.Builder()
+ .setDefaultProcessName("${BuildConfig.APPLICATION_ID}:bg")
+ .build()
+ }
+}
diff --git a/app/src/main/kotlin/com/v2ray/ang/AppConfig.kt b/app/src/main/kotlin/com/v2ray/ang/AppConfig.kt
new file mode 100644
index 0000000..36106ec
--- /dev/null
+++ b/app/src/main/kotlin/com/v2ray/ang/AppConfig.kt
@@ -0,0 +1,96 @@
+package com.v2ray.ang
+
+/**
+ *
+ * App Config Const
+ */
+object AppConfig {
+ const val ANG_PACKAGE = BuildConfig.APPLICATION_ID
+ const val DIR_ASSETS = "assets"
+
+ // legacy
+ const val ANG_CONFIG = "ang_config"
+ const val PREF_ROUTING_CUSTOM = "pref_routing_custom"
+
+ // Preferences mapped to MMKV
+ const val PREF_MODE = "pref_mode"
+ const val PREF_SPEED_ENABLED = "pref_speed_enabled"
+ const val PREF_SNIFFING_ENABLED = "pref_sniffing_enabled"
+ const val PREF_MUX_ENABLED = "pref_mux_enabled"
+ const val PREF_MUX_CONCURRENCY = "pref_mux_concurrency"
+ const val PREF_PROXY_SHARING = "pref_proxy_sharing_enabled"
+ const val PREF_LOCAL_DNS_ENABLED = "pref_local_dns_enabled"
+ const val PREF_FAKE_DNS_ENABLED = "pref_fake_dns_enabled"
+ const val PREF_VPN_DNS = "pref_vpn_dns"
+ const val PREF_REMOTE_DNS = "pref_remote_dns"
+ const val PREF_DOMESTIC_DNS = "pref_domestic_dns"
+ const val PREF_LOCAL_DNS_PORT = "pref_local_dns_port"
+ const val PREF_ALLOW_INSECURE = "pref_allow_insecure"
+ const val PREF_SOCKS_PORT = "pref_socks_port"
+ const val PREF_HTTP_PORT = "pref_http_port"
+ const val PREF_LOGLEVEL = "pref_core_loglevel"
+ const val PREF_LANGUAGE = "pref_language"
+ const val PREF_PREFER_IPV6 = "pref_prefer_ipv6"
+ const val PREF_ROUTING_DOMAIN_STRATEGY = "pref_routing_domain_strategy"
+ const val PREF_ROUTING_MODE = "pref_routing_mode"
+ const val PREF_V2RAY_ROUTING_AGENT = "pref_v2ray_routing_agent"
+ const val PREF_V2RAY_ROUTING_DIRECT = "pref_v2ray_routing_direct"
+ const val PREF_V2RAY_ROUTING_BLOCKED = "pref_v2ray_routing_blocked"
+ const val PREF_PER_APP_PROXY = "pref_per_app_proxy"
+ const val PREF_PER_APP_PROXY_SET = "pref_per_app_proxy_set"
+ const val PREF_START_SCAN_IMMEDIATE = "pref_start_scan_immediate"
+ const val PREF_BYPASS_APPS = "pref_bypass_apps"
+ const val PREF_CONFIRM_REMOVE = "pref_confirm_remove"
+
+ const val HTTP_PROTOCOL: String = "http://"
+ const val HTTPS_PROTOCOL: String = "https://"
+
+ const val BROADCAST_ACTION_SERVICE = "com.v2ray.ang.action.service"
+ const val BROADCAST_ACTION_ACTIVITY = "com.v2ray.ang.action.activity"
+ const val BROADCAST_ACTION_WIDGET_CLICK = "com.v2ray.ang.action.widget.click"
+
+ const val TASKER_EXTRA_BUNDLE = "com.twofortyfouram.locale.intent.extra.BUNDLE"
+ const val TASKER_EXTRA_STRING_BLURB = "com.twofortyfouram.locale.intent.extra.BLURB"
+ const val TASKER_EXTRA_BUNDLE_SWITCH = "tasker_extra_bundle_switch"
+ const val TASKER_EXTRA_BUNDLE_GUID = "tasker_extra_bundle_guid"
+ const val TASKER_DEFAULT_GUID = "Default"
+
+ const val TAG_AGENT = "proxy"
+ const val TAG_DIRECT = "direct"
+ const val TAG_BLOCKED = "block"
+
+ const val androidpackagenamelistUrl = "https://raw.githubusercontent.com/2dust/androidpackagenamelist/master/proxy.txt"
+ const val v2rayCustomRoutingListUrl = "https://raw.githubusercontent.com/2dust/v2rayCustomRoutingList/master/"
+ const val v2rayNGIssues = "https://gitlab.com/cuynu/v2rayvn/issues"
+ const val geoUrl = "https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/";
+
+ const val DNS_AGENT = "8.8.8.8"
+ const val DNS_DIRECT = "223.5.5.5"
+
+ const val PORT_LOCAL_DNS = "10853"
+ const val PORT_SOCKS = "10808"
+ const val PORT_HTTP = "10809"
+
+ const val MUX_CONCURRENCY = "8"
+ const val MSG_REGISTER_CLIENT = 1
+ const val MSG_STATE_RUNNING = 11
+ const val MSG_STATE_NOT_RUNNING = 12
+ const val MSG_UNREGISTER_CLIENT = 2
+ const val MSG_STATE_START = 3
+ const val MSG_STATE_START_SUCCESS = 31
+ const val MSG_STATE_START_FAILURE = 32
+ const val MSG_STATE_STOP = 4
+ const val MSG_STATE_STOP_SUCCESS = 41
+ const val MSG_STATE_RESTART = 5
+ const val MSG_MEASURE_DELAY = 6
+ const val MSG_MEASURE_DELAY_SUCCESS = 61
+ const val MSG_MEASURE_CONFIG = 7
+ const val MSG_MEASURE_CONFIG_SUCCESS = 71
+ const val MSG_MEASURE_CONFIG_CANCEL = 72
+
+ // subscription settings (https://github.com/2dust/v2rayNG/pull/2471/commits/062c0d8ddbe56da3ca1db9a7969a487706bb441f)
+ const val SUBSCRIPTION_AUTO_UPDATE = "pref_auto_update_subscription"
+ const val SUBSCRIPTION_AUTO_UPDATE_INTERVAL = "pref_auto_update_interval"
+ const val DEFAULT_UPDATE_INTERVAL = "1440" // 24 hours
+ const val UPDATE_TASK_NAME = "subscription-updater"
+}
diff --git a/app/src/main/kotlin/com/v2ray/ang/dto/AngConfig.kt b/app/src/main/kotlin/com/v2ray/ang/dto/AngConfig.kt
new file mode 100644
index 0000000..01f2bdd
--- /dev/null
+++ b/app/src/main/kotlin/com/v2ray/ang/dto/AngConfig.kt
@@ -0,0 +1,32 @@
+package com.v2ray.ang.dto
+
+data class AngConfig(
+ var index: Int,
+ var vmess: ArrayList,
+ var subItem: ArrayList
+) {
+ data class VmessBean(var guid: String = "123456",
+ var address: String = "v2ray.cool",
+ var port: Int = 10086,
+ var id: String = "a3482e88-686a-4a58-8126-99c9df64b7bf",
+ var alterId: Int = 64,
+ var security: String = "aes-128-cfb",
+ var network: String = "tcp",
+ var remarks: String = "def",
+ var headerType: String = "",
+ var requestHost: String = "",
+ var path: String = "",
+ var streamSecurity: String = "",
+ var allowInsecure: String = "",
+ var configType: Int = 1,
+ var configVersion: Int = 1,
+ var testResult: String = "",
+ var subid: String = "",
+ var flow: String = "",
+ var sni: String = "")
+
+ data class SubItemBean(var id: String = "",
+ var remarks: String = "",
+ var url: String = "",
+ var enabled: Boolean = true)
+}
diff --git a/app/src/main/kotlin/com/v2ray/ang/dto/AppInfo.kt b/app/src/main/kotlin/com/v2ray/ang/dto/AppInfo.kt
new file mode 100644
index 0000000..f99655a
--- /dev/null
+++ b/app/src/main/kotlin/com/v2ray/ang/dto/AppInfo.kt
@@ -0,0 +1,9 @@
+package com.v2ray.ang.dto
+
+import android.graphics.drawable.Drawable
+
+data class AppInfo(val appName: String,
+ val packageName: String,
+ val appIcon: Drawable,
+ val isSystemApp: Boolean,
+ var isSelected: Int)
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/v2ray/ang/dto/EConfigType.kt b/app/src/main/kotlin/com/v2ray/ang/dto/EConfigType.kt
new file mode 100644
index 0000000..5204c0c
--- /dev/null
+++ b/app/src/main/kotlin/com/v2ray/ang/dto/EConfigType.kt
@@ -0,0 +1,15 @@
+package com.v2ray.ang.dto
+
+enum class EConfigType(val value: Int, val protocolScheme: String) {
+ VMESS(1, "vmess://"),
+ CUSTOM(2, ""),
+ SHADOWSOCKS(3, "ss://"),
+ SOCKS(4, "socks://"),
+ VLESS(5, "vless://"),
+ TROJAN(6, "trojan://"),
+ WIREGUARD(7, "wireguard://");
+
+ companion object {
+ fun fromInt(value: Int) = values().firstOrNull { it.value == value }
+ }
+}
diff --git a/app/src/main/kotlin/com/v2ray/ang/dto/ERoutingMode.kt b/app/src/main/kotlin/com/v2ray/ang/dto/ERoutingMode.kt
new file mode 100644
index 0000000..96d7739
--- /dev/null
+++ b/app/src/main/kotlin/com/v2ray/ang/dto/ERoutingMode.kt
@@ -0,0 +1,9 @@
+package com.v2ray.ang.dto
+
+enum class ERoutingMode(val value: String ) {
+ GLOBAL_PROXY("0"),
+ BYPASS_LAN("1"),
+ BYPASS_MAINLAND("2"),
+ BYPASS_LAN_MAINLAND("3"),
+ GLOBAL_DIRECT("4");
+}
diff --git a/app/src/main/kotlin/com/v2ray/ang/dto/ServerAffiliationInfo.kt b/app/src/main/kotlin/com/v2ray/ang/dto/ServerAffiliationInfo.kt
new file mode 100644
index 0000000..bb4d20b
--- /dev/null
+++ b/app/src/main/kotlin/com/v2ray/ang/dto/ServerAffiliationInfo.kt
@@ -0,0 +1,10 @@
+package com.v2ray.ang.dto
+
+data class ServerAffiliationInfo(var testDelayMillis: Long = 0L) {
+ fun getTestDelayString(): String {
+ if (testDelayMillis == 0L) {
+ return ""
+ }
+ return testDelayMillis.toString() + "ms"
+ }
+}
diff --git a/app/src/main/kotlin/com/v2ray/ang/dto/ServerConfig.kt b/app/src/main/kotlin/com/v2ray/ang/dto/ServerConfig.kt
new file mode 100644
index 0000000..60b8b8d
--- /dev/null
+++ b/app/src/main/kotlin/com/v2ray/ang/dto/ServerConfig.kt
@@ -0,0 +1,71 @@
+package com.v2ray.ang.dto
+
+import com.v2ray.ang.AppConfig.TAG_AGENT
+import com.v2ray.ang.AppConfig.TAG_BLOCKED
+import com.v2ray.ang.AppConfig.TAG_DIRECT
+import com.v2ray.ang.util.Utils
+
+data class ServerConfig(
+ val configVersion: Int = 3,
+ val configType: EConfigType,
+ var subscriptionId: String = "",
+ val addedTime: Long = System.currentTimeMillis(),
+ var remarks: String = "",
+ val outboundBean: V2rayConfig.OutboundBean? = null,
+ var fullConfig: V2rayConfig? = null
+) {
+ companion object {
+ fun create(configType: EConfigType): ServerConfig {
+ when(configType) {
+ EConfigType.VMESS, EConfigType.VLESS ->
+ return ServerConfig(
+ configType = configType,
+ outboundBean = V2rayConfig.OutboundBean(
+ mux = V2rayConfig.OutboundBean.MuxBean(),
+ protocol = configType.name.lowercase(),
+ settings = V2rayConfig.OutboundBean.OutSettingsBean(
+ vnext = listOf(V2rayConfig.OutboundBean.OutSettingsBean.VnextBean(
+ users = listOf(V2rayConfig.OutboundBean.OutSettingsBean.VnextBean.UsersBean())))),
+ streamSettings = V2rayConfig.OutboundBean.StreamSettingsBean()))
+ EConfigType.CUSTOM, EConfigType.WIREGUARD ->
+ return ServerConfig(configType = configType)
+ EConfigType.SHADOWSOCKS, EConfigType.SOCKS, EConfigType.TROJAN ->
+ return ServerConfig(
+ configType = configType,
+ outboundBean = V2rayConfig.OutboundBean(
+ mux = V2rayConfig.OutboundBean.MuxBean(),
+ protocol = configType.name.lowercase(),
+ settings = V2rayConfig.OutboundBean.OutSettingsBean(
+ servers = listOf(V2rayConfig.OutboundBean.OutSettingsBean.ServersBean())),
+ streamSettings = V2rayConfig.OutboundBean.StreamSettingsBean()))
+ }
+ }
+ }
+
+ fun getProxyOutbound(): V2rayConfig.OutboundBean? {
+ if (configType != EConfigType.CUSTOM) {
+ return outboundBean
+ }
+ return fullConfig?.getProxyOutbound()
+ }
+
+ fun getAllOutboundTags(): MutableList {
+ if (configType != EConfigType.CUSTOM) {
+ return mutableListOf(TAG_AGENT, TAG_DIRECT, TAG_BLOCKED)
+ }
+ fullConfig?.let { config ->
+ return config.outbounds.map { it.tag }.toMutableList()
+ }
+ return mutableListOf()
+ }
+
+ fun getV2rayPointDomainAndPort(): String {
+ val address = getProxyOutbound()?.getServerAddress().orEmpty()
+ val port = getProxyOutbound()?.getServerPort()
+ return if (Utils.isIpv6Address(address)) {
+ String.format("[%s]:%s", address, port)
+ } else {
+ String.format("%s:%s", address, port)
+ }
+ }
+}
diff --git a/app/src/main/kotlin/com/v2ray/ang/dto/ServersCache.kt b/app/src/main/kotlin/com/v2ray/ang/dto/ServersCache.kt
new file mode 100644
index 0000000..e686b46
--- /dev/null
+++ b/app/src/main/kotlin/com/v2ray/ang/dto/ServersCache.kt
@@ -0,0 +1,4 @@
+package com.v2ray.ang.dto
+
+data class ServersCache(val guid: String,
+ val config: ServerConfig)
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/v2ray/ang/dto/SubscriptionItem.kt b/app/src/main/kotlin/com/v2ray/ang/dto/SubscriptionItem.kt
new file mode 100644
index 0000000..3d54f4e
--- /dev/null
+++ b/app/src/main/kotlin/com/v2ray/ang/dto/SubscriptionItem.kt
@@ -0,0 +1,11 @@
+package com.v2ray.ang.dto
+
+data class SubscriptionItem(
+ var remarks: String = "",
+ var url: String = "",
+ var enabled: Boolean = true,
+ val addedTime: Long = System.currentTimeMillis(),
+ var lastUpdated: Long = -1,
+ var autoUpdate: Boolean = false,
+ val updateInterval: Int? = null,
+)
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/v2ray/ang/dto/V2rayConfig.kt b/app/src/main/kotlin/com/v2ray/ang/dto/V2rayConfig.kt
new file mode 100644
index 0000000..779be76
--- /dev/null
+++ b/app/src/main/kotlin/com/v2ray/ang/dto/V2rayConfig.kt
@@ -0,0 +1,471 @@
+package com.v2ray.ang.dto
+
+import android.text.TextUtils
+import com.google.gson.GsonBuilder
+import com.google.gson.JsonPrimitive
+import com.google.gson.JsonSerializationContext
+import com.google.gson.JsonSerializer
+import com.google.gson.annotations.SerializedName
+import com.google.gson.reflect.TypeToken
+import java.lang.reflect.Type
+
+data class V2rayConfig(
+ var stats: Any? = null,
+ val log: LogBean,
+ var policy: PolicyBean?,
+ val inbounds: ArrayList,
+ var outbounds: ArrayList,
+ var dns: DnsBean,
+ val routing: RoutingBean,
+ val api: Any? = null,
+ val transport: Any? = null,
+ val reverse: Any? = null,
+ var fakedns: Any? = null,
+ val browserForwarder: Any? = null) {
+ companion object {
+ const val DEFAULT_PORT = 443
+ const val DEFAULT_SECURITY = "auto"
+ const val DEFAULT_LEVEL = 8
+ const val DEFAULT_NETWORK = "tcp"
+
+ const val TLS = "tls"
+ const val REALITY = "reality"
+ const val HTTP = "http"
+ }
+
+ data class LogBean(val access: String,
+ val error: String,
+ var loglevel: String?,
+ val dnsLog: Boolean? = null)
+
+ data class InboundBean(
+ var tag: String,
+ var port: Int,
+ var protocol: String,
+ var listen: String? = null,
+ val settings: Any? = null,
+ val sniffing: SniffingBean?,
+ val streamSettings: Any? = null,
+ val allocate: Any? = null) {
+
+ data class InSettingsBean(val auth: String? = null,
+ val udp: Boolean? = null,
+ val userLevel: Int? = null,
+ val address: String? = null,
+ val port: Int? = null,
+ val network: String? = null)
+
+ data class SniffingBean(var disabled: Boolean,
+ val destOverride: ArrayList,
+ val metadataOnly: Boolean? = null)
+ }
+
+ data class OutboundBean(val tag: String = "proxy",
+ var protocol: String,
+ var settings: OutSettingsBean? = null,
+ var streamSettings: StreamSettingsBean? = null,
+ val proxySettings: Any? = null,
+ val sendThrough: String? = null,
+ val mux: MuxBean?) {
+
+ data class OutSettingsBean(var vnext: List? = null,
+ var servers: List? = null,
+ /*Blackhole*/
+ var response: Response? = null,
+ /*DNS*/
+ val network: String? = null,
+ val address: Any? = null,
+ val port: Int? = null,
+ /*Freedom*/
+ var domainStrategy: String? = null,
+ val redirect: String? = null,
+ val userLevel: Int? = null,
+ /*Loopback*/
+ val inboundTag: String? = null,
+ /*Wireguard*/
+ val secretKey: String? = null,
+ val peers: List? = null,
+ ) {
+
+ data class VnextBean(var address: String = "",
+ var port: Int = DEFAULT_PORT,
+ var users: List) {
+
+ data class UsersBean(var id: String = "",
+ var alterId: Int? = null,
+ var security: String = DEFAULT_SECURITY,
+ var level: Int = DEFAULT_LEVEL,
+ var encryption: String = "",
+ var flow: String = "")
+ }
+
+ data class ServersBean(var address: String = "",
+ var method: String = "chacha20-poly1305",
+ var ota: Boolean = false,
+ var password: String = "",
+ var port: Int = DEFAULT_PORT,
+ var level: Int = DEFAULT_LEVEL,
+ val email: String? = null,
+ var flow: String? = null,
+ val ivCheck: Boolean? = null,
+ var users: List? = null) {
+
+
+ data class SocksUsersBean(var user: String = "",
+ var pass: String = "",
+ var level: Int = DEFAULT_LEVEL)
+ }
+
+ data class Response(var type: String)
+
+ data class WireGuardBean(var publicKey: String = "",
+ var endpoint: String = "")
+ }
+
+ data class StreamSettingsBean(var network: String = DEFAULT_NETWORK,
+ var security: String = "",
+ var tcpSettings: TcpSettingsBean? = null,
+ var kcpSettings: KcpSettingsBean? = null,
+ var wsSettings: WsSettingsBean? = null,
+ var httpSettings: HttpSettingsBean? = null,
+ var tlsSettings: TlsSettingsBean? = null,
+ var quicSettings: QuicSettingBean? = null,
+ var realitySettings: TlsSettingsBean? = null,
+ var grpcSettings: GrpcSettingsBean? = null,
+ val dsSettings: Any? = null,
+ val sockopt: Any? = null
+ ) {
+
+ data class TcpSettingsBean(var header: HeaderBean = HeaderBean(),
+ val acceptProxyProtocol: Boolean? = null) {
+ data class HeaderBean(var type: String = "none",
+ var request: RequestBean? = null,
+ var response: Any? = null) {
+ data class RequestBean(var path: List = ArrayList(),
+ var headers: HeadersBean = HeadersBean(),
+ val version: String? = null,
+ val method: String? = null) {
+ data class HeadersBean(var Host: List = ArrayList(),
+ @SerializedName("User-Agent")
+ val userAgent: List? = null,
+ @SerializedName("Accept-Encoding")
+ val acceptEncoding: List? = null,
+ val Connection: List? = null,
+ val Pragma: String? = null)
+ }
+ }
+ }
+
+ data class KcpSettingsBean(var mtu: Int = 1350,
+ var tti: Int = 50,
+ var uplinkCapacity: Int = 12,
+ var downlinkCapacity: Int = 100,
+ var congestion: Boolean = false,
+ var readBufferSize: Int = 1,
+ var writeBufferSize: Int = 1,
+ var header: HeaderBean = HeaderBean(),
+ var seed: String? = null) {
+ data class HeaderBean(var type: String = "none")
+ }
+
+ data class WsSettingsBean(var path: String = "",
+ var headers: HeadersBean = HeadersBean(),
+ val maxEarlyData: Int? = null,
+ val useBrowserForwarding: Boolean? = null,
+ val acceptProxyProtocol: Boolean? = null) {
+ data class HeadersBean(var Host: String = "")
+ }
+
+ data class HttpSettingsBean(var host: List = ArrayList(),
+ var path: String = "")
+
+ data class TlsSettingsBean(var allowInsecure: Boolean = false,
+ var serverName: String = "",
+ val alpn: List? = null,
+ val minVersion: String? = null,
+ val maxVersion: String? = null,
+ val preferServerCipherSuites: Boolean? = null,
+ val cipherSuites: String? = null,
+ val fingerprint: String? = null,
+ val certificates: List? = null,
+ val disableSystemRoot: Boolean? = null,
+ val enableSessionResumption: Boolean? = null,
+ // REALITY settings
+ val show: Boolean = false,
+ var publicKey: String? = null,
+ var shortId: String? = null,
+ var spiderX: String? = null)
+
+ data class QuicSettingBean(var security: String = "none",
+ var key: String = "",
+ var header: HeaderBean = HeaderBean()) {
+ data class HeaderBean(var type: String = "none")
+ }
+
+ data class GrpcSettingsBean(var serviceName: String = "",
+ var multiMode: Boolean? = null)
+
+ fun populateTransportSettings(transport: String, headerType: String?, host: String?, path: String?, seed: String?,
+ quicSecurity: String?, key: String?, mode: String?, serviceName: String?): String {
+ var sni = ""
+ network = transport
+ when (network) {
+ "tcp" -> {
+ val tcpSetting = TcpSettingsBean()
+ if (headerType == HTTP) {
+ tcpSetting.header.type = HTTP
+ if (!TextUtils.isEmpty(host) || !TextUtils.isEmpty(path)) {
+ val requestObj = TcpSettingsBean.HeaderBean.RequestBean()
+ requestObj.headers.Host = (host ?: "").split(",").map { it.trim() }.filter { it.isNotEmpty() }
+ requestObj.path = (path ?: "").split(",").map { it.trim() }.filter { it.isNotEmpty() }
+ tcpSetting.header.request = requestObj
+ sni = requestObj.headers.Host.getOrNull(0) ?: sni
+ }
+ } else {
+ tcpSetting.header.type = "none"
+ sni = host ?: ""
+ }
+ tcpSettings = tcpSetting
+ }
+ "kcp" -> {
+ val kcpsetting = KcpSettingsBean()
+ kcpsetting.header.type = headerType ?: "none"
+ if (seed.isNullOrEmpty()) {
+ kcpsetting.seed = null
+ } else {
+ kcpsetting.seed = seed
+ }
+ kcpSettings = kcpsetting
+ }
+ "ws" -> {
+ val wssetting = WsSettingsBean()
+ wssetting.headers.Host = host ?: ""
+ sni = wssetting.headers.Host
+ wssetting.path = path ?: "/"
+ wsSettings = wssetting
+ }
+ "h2", "http" -> {
+ network = "h2"
+ val h2Setting = HttpSettingsBean()
+ h2Setting.host = (host ?: "").split(",").map { it.trim() }.filter { it.isNotEmpty() }
+ sni = h2Setting.host.getOrNull(0) ?: sni
+ h2Setting.path = path ?: "/"
+ httpSettings = h2Setting
+ }
+ "quic" -> {
+ val quicsetting = QuicSettingBean()
+ quicsetting.security = quicSecurity ?: "none"
+ quicsetting.key = key ?: ""
+ quicsetting.header.type = headerType ?: "none"
+ quicSettings = quicsetting
+ }
+ "grpc" -> {
+ val grpcSetting = GrpcSettingsBean()
+ grpcSetting.multiMode = mode == "multi"
+ grpcSetting.serviceName = serviceName ?: ""
+ sni = host ?: ""
+ grpcSettings = grpcSetting
+ }
+ }
+ return sni
+ }
+
+ fun populateTlsSettings(streamSecurity: String, allowInsecure: Boolean, sni: String, fingerprint: String?, alpns: String?,
+ publicKey: String?, shortId: String?, spiderX: String?) {
+ security = streamSecurity
+ val tlsSetting = TlsSettingsBean(
+ allowInsecure = allowInsecure,
+ serverName = sni,
+ fingerprint = fingerprint,
+ alpn = if (alpns.isNullOrEmpty()) null else alpns.split(",").map { it.trim() }.filter { it.isNotEmpty() },
+ publicKey = publicKey,
+ shortId = shortId,
+ spiderX = spiderX
+ )
+ if (security == TLS) {
+ tlsSettings = tlsSetting
+ realitySettings = null
+ } else if (security == REALITY) {
+ tlsSettings = null
+ realitySettings = tlsSetting
+ }
+ }
+ }
+
+ data class MuxBean(var enabled: Boolean? = null, var concurrency: Int? = null)
+
+ fun getServerAddress(): String? {
+ if (protocol.equals(EConfigType.VMESS.name, true)
+ || protocol.equals(EConfigType.VLESS.name, true)) {
+ return settings?.vnext?.get(0)?.address
+ } else if (protocol.equals(EConfigType.SHADOWSOCKS.name, true)
+ || protocol.equals(EConfigType.SOCKS.name, true)
+ || protocol.equals(EConfigType.TROJAN.name, true)) {
+ return settings?.servers?.get(0)?.address
+ } else if (protocol.equals(EConfigType.WIREGUARD.name, true)) {
+ return settings?.peers?.get(0)?.endpoint?.substringBeforeLast(":")
+ }
+ return null
+ }
+
+ fun getServerPort(): Int? {
+ if (protocol.equals(EConfigType.VMESS.name, true)
+ || protocol.equals(EConfigType.VLESS.name, true)) {
+ return settings?.vnext?.get(0)?.port
+ } else if (protocol.equals(EConfigType.SHADOWSOCKS.name, true)
+ || protocol.equals(EConfigType.SOCKS.name, true)
+ || protocol.equals(EConfigType.TROJAN.name, true)) {
+ return settings?.servers?.get(0)?.port
+ } else if (protocol.equals(EConfigType.WIREGUARD.name, true)) {
+ return settings?.peers?.get(0)?.endpoint?.substringAfterLast(":")?.toInt()
+ }
+ return null
+ }
+
+ fun getPassword(): String? {
+ if (protocol.equals(EConfigType.VMESS.name, true)
+ || protocol.equals(EConfigType.VLESS.name, true)) {
+ return settings?.vnext?.get(0)?.users?.get(0)?.id
+ } else if (protocol.equals(EConfigType.SHADOWSOCKS.name, true)
+ || protocol.equals(EConfigType.TROJAN.name, true)) {
+ return settings?.servers?.get(0)?.password
+ } else if (protocol.equals(EConfigType.SOCKS.name, true)) {
+ return settings?.servers?.get(0)?.users?.get(0)?.pass
+ } else if (protocol.equals(EConfigType.WIREGUARD.name, true)) {
+ return settings?.secretKey
+ }
+ return null
+ }
+
+ fun getSecurityEncryption(): String? {
+ return when {
+ protocol.equals(EConfigType.VMESS.name, true) -> settings?.vnext?.get(0)?.users?.get(0)?.security
+ protocol.equals(EConfigType.VLESS.name, true) -> settings?.vnext?.get(0)?.users?.get(0)?.encryption
+ protocol.equals(EConfigType.SHADOWSOCKS.name, true) -> settings?.servers?.get(0)?.method
+ else -> null
+ }
+ }
+
+ fun getTransportSettingDetails(): List? {
+ if (protocol.equals(EConfigType.VMESS.name, true)
+ || protocol.equals(EConfigType.VLESS.name, true)
+ || protocol.equals(EConfigType.TROJAN.name, true)) {
+ val transport = streamSettings?.network ?: return null
+ return when (transport) {
+ "tcp" -> {
+ val tcpSetting = streamSettings?.tcpSettings ?: return null
+ listOf(tcpSetting.header.type,
+ tcpSetting.header.request?.headers?.Host?.joinToString().orEmpty(),
+ tcpSetting.header.request?.path?.joinToString().orEmpty())
+ }
+ "kcp" -> {
+ val kcpSetting = streamSettings?.kcpSettings ?: return null
+ listOf(kcpSetting.header.type,
+ "",
+ kcpSetting.seed.orEmpty())
+ }
+ "ws" -> {
+ val wsSetting = streamSettings?.wsSettings ?: return null
+ listOf("",
+ wsSetting.headers.Host,
+ wsSetting.path)
+ }
+ "h2" -> {
+ val h2Setting = streamSettings?.httpSettings ?: return null
+ listOf("",
+ h2Setting.host.joinToString(),
+ h2Setting.path)
+ }
+ "quic" -> {
+ val quicSetting = streamSettings?.quicSettings ?: return null
+ listOf(quicSetting.header.type,
+ quicSetting.security,
+ quicSetting.key)
+ }
+ "grpc" -> {
+ val grpcSetting = streamSettings?.grpcSettings ?: return null
+ listOf(if (grpcSetting.multiMode == true) "multi" else "gun",
+ "",
+ grpcSetting.serviceName)
+ }
+ else -> null
+ }
+ }
+ return null
+ }
+ }
+
+ data class DnsBean(var servers: ArrayList? = null,
+ var hosts: Map? = null,
+ val clientIp: String? = null,
+ val disableCache: Boolean? = null,
+ val queryStrategy: String? = null,
+ val tag: String? = null
+ ) {
+ data class ServersBean(var address: String = "",
+ var port: Int? = null,
+ var domains: List? = null,
+ var expectIPs: List? = null,
+ val clientIp: String? = null)
+ }
+
+ data class RoutingBean(var domainStrategy: String,
+ var domainMatcher: String? = null,
+ var rules: ArrayList,
+ val balancers: List? = null) {
+
+ data class RulesBean(var type: String = "",
+ var ip: ArrayList? = null,
+ var domain: ArrayList? = null,
+ var outboundTag: String = "",
+ var balancerTag: String? = null,
+ var port: String? = null,
+ val sourcePort: String? = null,
+ val network: String? = null,
+ val source: List? = null,
+ val user: List? = null,
+ var inboundTag: List? = null,
+ val protocol: List? = null,
+ val attrs: String? = null,
+ val domainMatcher: String? = null
+ )
+ }
+
+ data class PolicyBean(var levels: Map,
+ var system: Any? = null) {
+ data class LevelBean(
+ var handshake: Int? = null,
+ var connIdle: Int? = null,
+ var uplinkOnly: Int? = null,
+ var downlinkOnly: Int? = null,
+ val statsUserUplink: Boolean? = null,
+ val statsUserDownlink: Boolean? = null,
+ var bufferSize: Int? = null)
+ }
+
+ data class FakednsBean(var ipPool: String = "198.18.0.0/15",
+ var poolSize: Int = 10000) // roughly 10 times smaller than total ip pool
+
+ fun getProxyOutbound(): OutboundBean? {
+ outbounds.forEach { outbound ->
+ EConfigType.values().forEach {
+ if (outbound.protocol.equals(it.name, true)) {
+ return outbound
+ }
+ }
+ }
+ return null
+ }
+
+ fun toPrettyPrinting(): String {
+ return GsonBuilder()
+ .setPrettyPrinting()
+ .disableHtmlEscaping()
+ .registerTypeAdapter( // custom serialiser is needed here since JSON by default parse number as Double, core will fail to start
+ object : TypeToken() {}.type,
+ JsonSerializer { src: Double?, _: Type?, _: JsonSerializationContext? -> JsonPrimitive(src?.toInt()) }
+ )
+ .create()
+ .toJson(this)
+ }
+}
diff --git a/app/src/main/kotlin/com/v2ray/ang/dto/VmessQRCode.kt b/app/src/main/kotlin/com/v2ray/ang/dto/VmessQRCode.kt
new file mode 100644
index 0000000..54b7dd7
--- /dev/null
+++ b/app/src/main/kotlin/com/v2ray/ang/dto/VmessQRCode.kt
@@ -0,0 +1,17 @@
+package com.v2ray.ang.dto
+
+data class VmessQRCode(var v: String = "",
+ var ps: String = "",
+ var add: String = "",
+ var port: String = "",
+ var id: String = "",
+ var aid: String = "0",
+ var scy: String = "",
+ var net: String = "",
+ var type: String = "",
+ var host: String = "",
+ var path: String = "",
+ var tls: String = "",
+ var sni: String = "",
+ var alpn: String = "",
+ var fp: String = "")
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/v2ray/ang/extension/_Ext.kt b/app/src/main/kotlin/com/v2ray/ang/extension/_Ext.kt
new file mode 100644
index 0000000..fb0bff6
--- /dev/null
+++ b/app/src/main/kotlin/com/v2ray/ang/extension/_Ext.kt
@@ -0,0 +1,80 @@
+package com.v2ray.ang.extension
+
+import android.content.Context
+import android.os.Build
+import android.widget.Toast
+import com.v2ray.ang.AngApplication
+import me.drakeet.support.toast.ToastCompat
+import org.json.JSONObject
+import java.net.URI
+import java.net.URLConnection
+
+/**
+ * Some extensions
+ */
+
+val Context.v2RayApplication: AngApplication
+ get() = applicationContext as AngApplication
+
+fun Context.toast(message: Int): Toast = ToastCompat
+ .makeText(this, message, Toast.LENGTH_SHORT)
+ .apply {
+ show()
+ }
+
+fun Context.toast(message: CharSequence): Toast = ToastCompat
+ .makeText(this, message, Toast.LENGTH_SHORT)
+ .apply {
+ show()
+ }
+
+fun JSONObject.putOpt(pair: Pair) = putOpt(pair.first, pair.second)
+fun JSONObject.putOpt(pairs: Map) = pairs.forEach { putOpt(it.key to it.value) }
+
+const val threshold = 1000
+const val divisor = 1024F
+
+fun Long.toSpeedString() = toTrafficString() + "/s"
+
+fun Long.toTrafficString(): String {
+ if (this == 0L)
+ return "\t\t\t0\t B"
+
+ if (this < threshold)
+ return "${this.toFloat().toShortString()}\t B"
+
+ val kib = this / divisor
+ if (kib < threshold)
+ return "${kib.toShortString()}\t KB"
+
+ val mib = kib / divisor
+ if (mib < threshold)
+ return "${mib.toShortString()}\t MB"
+
+ val gib = mib / divisor
+ if (gib < threshold)
+ return "${gib.toShortString()}\t GB"
+
+ val tib = gib / divisor
+ if (tib < threshold)
+ return "${tib.toShortString()}\t TB"
+
+ val pib = tib / divisor
+ if (pib < threshold)
+ return "${pib.toShortString()}\t PB"
+
+ return "∞"
+}
+
+private fun Float.toShortString(): String {
+ val s = "%.2f".format(this)
+ if (s.length <= 4)
+ return s
+ return s.substring(0, 4).removeSuffix(".")
+}
+
+val URLConnection.responseLength: Long
+ get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) contentLengthLong else contentLength.toLong()
+
+val URI.idnHost: String
+ get() = (host!!).replace("[", "").replace("]", "")
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/v2ray/ang/receiver/TaskerReceiver.kt b/app/src/main/kotlin/com/v2ray/ang/receiver/TaskerReceiver.kt
new file mode 100644
index 0000000..bf52242
--- /dev/null
+++ b/app/src/main/kotlin/com/v2ray/ang/receiver/TaskerReceiver.kt
@@ -0,0 +1,40 @@
+package com.v2ray.ang.receiver
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.text.TextUtils
+import com.tencent.mmkv.MMKV
+import com.v2ray.ang.AppConfig
+import com.v2ray.ang.service.V2RayServiceManager
+import com.v2ray.ang.util.MmkvManager
+
+import com.v2ray.ang.util.Utils
+
+class TaskerReceiver : BroadcastReceiver() {
+ private val mainStorage by lazy { MMKV.mmkvWithID(MmkvManager.ID_MAIN, MMKV.MULTI_PROCESS_MODE) }
+
+ override fun onReceive(context: Context, intent: Intent?) {
+
+ try {
+ val bundle = intent?.getBundleExtra(AppConfig.TASKER_EXTRA_BUNDLE)
+ val switch = bundle?.getBoolean(AppConfig.TASKER_EXTRA_BUNDLE_SWITCH, false)
+ val guid = bundle?.getString(AppConfig.TASKER_EXTRA_BUNDLE_GUID, "")
+
+ if (switch == null || guid == null || TextUtils.isEmpty(guid)) {
+ return
+ } else if (switch) {
+ if (guid == AppConfig.TASKER_DEFAULT_GUID) {
+ Utils.startVServiceFromToggle(context)
+ } else {
+ mainStorage?.encode(MmkvManager.KEY_SELECTED_SERVER, guid)
+ V2RayServiceManager.startV2Ray(context)
+ }
+ } else {
+ Utils.stopVService(context)
+ }
+ } catch (e: Exception) {
+ e.printStackTrace()
+ }
+ }
+}
diff --git a/app/src/main/kotlin/com/v2ray/ang/receiver/WidgetProvider.kt b/app/src/main/kotlin/com/v2ray/ang/receiver/WidgetProvider.kt
new file mode 100644
index 0000000..826bf24
--- /dev/null
+++ b/app/src/main/kotlin/com/v2ray/ang/receiver/WidgetProvider.kt
@@ -0,0 +1,85 @@
+package com.v2ray.ang.receiver
+
+import android.app.PendingIntent
+import android.appwidget.AppWidgetManager
+import android.appwidget.AppWidgetProvider
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import android.widget.RemoteViews
+import com.v2ray.ang.R
+import com.v2ray.ang.AppConfig
+import com.v2ray.ang.service.V2RayServiceManager
+import com.v2ray.ang.util.Utils
+
+class WidgetProvider : AppWidgetProvider() {
+ /**
+ * 每次窗口小部件被更新都调用一次该方法
+ */
+ override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
+ super.onUpdate(context, appWidgetManager, appWidgetIds)
+ updateWidgetBackground(context, appWidgetManager, appWidgetIds, V2RayServiceManager.v2rayPoint.isRunning)
+ }
+
+
+ private fun updateWidgetBackground(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray, isRunning: Boolean) {
+ val remoteViews = RemoteViews(context.packageName, R.layout.widget_switch)
+ val intent = Intent(context, WidgetProvider::class.java)
+ intent.action = AppConfig.BROADCAST_ACTION_WIDGET_CLICK
+ val pendingIntent = PendingIntent.getBroadcast(
+ context,
+ R.id.layout_switch,
+ intent,
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
+ } else {
+ PendingIntent.FLAG_UPDATE_CURRENT
+ })
+ remoteViews.setOnClickPendingIntent(R.id.layout_switch, pendingIntent)
+ if (isRunning) {
+ remoteViews.setInt(
+ R.id.layout_switch,
+ "setBackgroundResource",
+ R.drawable.ic_rounded_corner_theme
+ )
+ } else {
+ remoteViews.setInt(
+ R.id.layout_switch,
+ "setBackgroundResource",
+ R.drawable.ic_rounded_corner_grey
+ )
+ }
+
+ for (appWidgetId in appWidgetIds) {
+ appWidgetManager.updateAppWidget(appWidgetId, remoteViews)
+ }
+ }
+
+ /**
+ * 接收窗口小部件发送的广播
+ */
+ override fun onReceive(context: Context, intent: Intent) {
+ super.onReceive(context, intent)
+ if (AppConfig.BROADCAST_ACTION_WIDGET_CLICK == intent.action) {
+ if (V2RayServiceManager.v2rayPoint.isRunning) {
+ Utils.stopVService(context)
+ } else {
+ Utils.startVServiceFromToggle(context)
+ }
+ } else if (AppConfig.BROADCAST_ACTION_ACTIVITY == intent.action) {
+ AppWidgetManager.getInstance(context)?.let { manager ->
+ when (intent.getIntExtra("key", 0)) {
+ AppConfig.MSG_STATE_RUNNING, AppConfig.MSG_STATE_START_SUCCESS -> {
+ updateWidgetBackground(context, manager, manager.getAppWidgetIds(ComponentName(context, WidgetProvider::class.java)),
+ true)
+ }
+ AppConfig.MSG_STATE_NOT_RUNNING, AppConfig.MSG_STATE_START_FAILURE, AppConfig.MSG_STATE_STOP_SUCCESS -> {
+ updateWidgetBackground(context, manager, manager.getAppWidgetIds(ComponentName(context, WidgetProvider::class.java)),
+ false)
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/kotlin/com/v2ray/ang/service/QSTileService.kt b/app/src/main/kotlin/com/v2ray/ang/service/QSTileService.kt
new file mode 100644
index 0000000..176b0fb
--- /dev/null
+++ b/app/src/main/kotlin/com/v2ray/ang/service/QSTileService.kt
@@ -0,0 +1,87 @@
+package com.v2ray.ang.service
+
+import android.annotation.TargetApi
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.graphics.drawable.Icon
+import android.os.Build
+import android.service.quicksettings.Tile
+import android.service.quicksettings.TileService
+import com.v2ray.ang.AppConfig
+import com.v2ray.ang.R
+import com.v2ray.ang.util.MessageUtil
+import com.v2ray.ang.util.Utils
+import java.lang.ref.SoftReference
+
+@TargetApi(Build.VERSION_CODES.N)
+class QSTileService : TileService() {
+
+ fun setState(state: Int) {
+ if (state == Tile.STATE_INACTIVE) {
+ qsTile?.state = Tile.STATE_INACTIVE
+ qsTile?.label = getString(R.string.app_name)
+ qsTile?.icon = Icon.createWithResource(applicationContext, R.drawable.ic_stat_name)
+ } else if (state == Tile.STATE_ACTIVE) {
+ qsTile?.state = Tile.STATE_ACTIVE
+ qsTile?.label = V2RayServiceManager.currentConfig?.remarks
+ qsTile?.icon = Icon.createWithResource(applicationContext, R.drawable.ic_stat_name)
+ }
+
+ qsTile?.updateTile()
+ }
+
+ override fun onStartListening() {
+ super.onStartListening()
+ setState(Tile.STATE_INACTIVE)
+ mMsgReceive = ReceiveMessageHandler(this)
+ registerReceiver(mMsgReceive, IntentFilter(AppConfig.BROADCAST_ACTION_ACTIVITY))
+ MessageUtil.sendMsg2Service(this, AppConfig.MSG_REGISTER_CLIENT, "")
+ }
+
+ override fun onStopListening() {
+ super.onStopListening()
+
+ unregisterReceiver(mMsgReceive)
+ mMsgReceive = null
+ }
+
+ override fun onClick() {
+ super.onClick()
+ when (qsTile.state) {
+ Tile.STATE_INACTIVE -> {
+ Utils.startVServiceFromToggle(this)
+ }
+ Tile.STATE_ACTIVE -> {
+ Utils.stopVService(this)
+ }
+ }
+ }
+
+ private var mMsgReceive: BroadcastReceiver? = null
+
+ private class ReceiveMessageHandler(context: QSTileService) : BroadcastReceiver() {
+ internal var mReference: SoftReference = SoftReference(context)
+ override fun onReceive(ctx: Context?, intent: Intent?) {
+ val context = mReference.get()
+ when (intent?.getIntExtra("key", 0)) {
+ AppConfig.MSG_STATE_RUNNING -> {
+ context?.setState(Tile.STATE_ACTIVE)
+ }
+ AppConfig.MSG_STATE_NOT_RUNNING -> {
+ context?.setState(Tile.STATE_INACTIVE)
+ }
+ AppConfig.MSG_STATE_START_SUCCESS -> {
+ context?.setState(Tile.STATE_ACTIVE)
+ }
+ AppConfig.MSG_STATE_START_FAILURE -> {
+ context?.setState(Tile.STATE_INACTIVE)
+ }
+ AppConfig.MSG_STATE_STOP_SUCCESS -> {
+ context?.setState(Tile.STATE_INACTIVE)
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/kotlin/com/v2ray/ang/service/ServiceControl.kt b/app/src/main/kotlin/com/v2ray/ang/service/ServiceControl.kt
new file mode 100644
index 0000000..36a26b8
--- /dev/null
+++ b/app/src/main/kotlin/com/v2ray/ang/service/ServiceControl.kt
@@ -0,0 +1,13 @@
+package com.v2ray.ang.service
+
+import android.app.Service
+
+interface ServiceControl {
+ fun getService(): Service
+
+ fun startService()
+
+ fun stopService()
+
+ fun vpnProtect(socket: Int): Boolean
+}
diff --git a/app/src/main/kotlin/com/v2ray/ang/service/SubscriptionUpdater.kt b/app/src/main/kotlin/com/v2ray/ang/service/SubscriptionUpdater.kt
new file mode 100644
index 0000000..4eeaaa7
--- /dev/null
+++ b/app/src/main/kotlin/com/v2ray/ang/service/SubscriptionUpdater.kt
@@ -0,0 +1,83 @@
+package com.v2ray.ang.service
+
+import android.Manifest
+import android.annotation.SuppressLint
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.content.Context
+import android.content.pm.PackageManager
+import android.os.Build
+import android.util.Log
+import androidx.core.app.ActivityCompat
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import androidx.work.CoroutineWorker
+import androidx.work.WorkerParameters
+import com.v2ray.ang.AppConfig
+import com.v2ray.ang.R
+import com.v2ray.ang.extension.toast
+import com.v2ray.ang.util.AngConfigManager
+import com.v2ray.ang.util.MmkvManager
+import com.v2ray.ang.util.Utils
+import kotlinx.coroutines.delay
+
+object SubscriptionUpdater {
+
+ const val notificationChannel = "update-subscription-channel"
+
+ class UpdateTask(context: Context, params: WorkerParameters) :
+ CoroutineWorker(context, params) {
+
+ private val notificationManager = NotificationManagerCompat.from(applicationContext)
+ private val notification =
+ NotificationCompat.Builder(applicationContext, notificationChannel)
+
+ .setWhen(0)
+ .setTicker("Update")
+ .setContentTitle("Subscription Update")
+ .setSmallIcon(R.drawable.ic_stat_name)
+ .setCategory(NotificationCompat.CATEGORY_SERVICE)
+ .setPriority(NotificationCompat.PRIORITY_MIN)
+
+ @SuppressLint("MissingPermission")
+ override suspend fun doWork(): Result {
+ Log.d(AppConfig.ANG_PACKAGE, "start updating subscriptions")
+
+
+ val subs = MmkvManager.decodeSubscriptions().filter { it.second.autoUpdate }
+
+ for (i in subs) {
+
+ val subscription = i.second
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ notification.setChannelId(notificationChannel)
+ val channel =
+ NotificationChannel(
+ notificationChannel,
+ "Subscription Update Service",
+ NotificationManager.IMPORTANCE_MIN
+ )
+ notificationManager.createNotificationChannel(channel)
+ }
+ notificationManager.notify(3, notification.build())
+ Log.d(AppConfig.ANG_PACKAGE, "update: ${subscription.remarks} subscription")
+ val configs = Utils.getUrlContentWithCustomUserAgent(subscription.url)
+ importBatchConfig(configs, i.first)
+ notification.setContentText("Updating ${subscription.remarks}")
+ }
+ notificationManager.cancel(3)
+ return Result.success()
+ }
+ }
+
+ fun importBatchConfig(server: String?, subid: String = "") {
+
+ val append = subid.isNullOrEmpty()
+
+ var count = AngConfigManager.importBatchConfig(server, subid, append)
+ if (count <= 0) {
+ AngConfigManager.importBatchConfig(Utils.decode(server!!), subid, append)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/v2ray/ang/service/V2RayProxyOnlyService.kt b/app/src/main/kotlin/com/v2ray/ang/service/V2RayProxyOnlyService.kt
new file mode 100644
index 0000000..3403af6
--- /dev/null
+++ b/app/src/main/kotlin/com/v2ray/ang/service/V2RayProxyOnlyService.kt
@@ -0,0 +1,56 @@
+package com.v2ray.ang.service
+
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import android.os.IBinder
+import androidx.annotation.RequiresApi
+import com.v2ray.ang.util.MyContextWrapper
+import com.v2ray.ang.util.Utils
+import java.lang.ref.SoftReference
+
+class V2RayProxyOnlyService : Service(), ServiceControl {
+ override fun onCreate() {
+ super.onCreate()
+ V2RayServiceManager.serviceControl = SoftReference(this)
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ V2RayServiceManager.startV2rayPoint()
+ return START_STICKY
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ V2RayServiceManager.stopV2rayPoint()
+ }
+
+ override fun getService(): Service {
+ return this
+ }
+
+ override fun startService() {
+ // do nothing
+ }
+
+ override fun stopService() {
+ stopSelf()
+ }
+
+ override fun vpnProtect(socket: Int): Boolean {
+ return true
+ }
+
+ override fun onBind(intent: Intent?): IBinder? {
+ return null
+ }
+
+ @RequiresApi(Build.VERSION_CODES.N)
+ override fun attachBaseContext(newBase: Context?) {
+ val context = newBase?.let {
+ MyContextWrapper.wrap(newBase, Utils.getLocale(newBase))
+ }
+ super.attachBaseContext(context)
+ }
+}
diff --git a/app/src/main/kotlin/com/v2ray/ang/service/V2RayServiceManager.kt b/app/src/main/kotlin/com/v2ray/ang/service/V2RayServiceManager.kt
new file mode 100644
index 0000000..333fe83
--- /dev/null
+++ b/app/src/main/kotlin/com/v2ray/ang/service/V2RayServiceManager.kt
@@ -0,0 +1,397 @@
+package com.v2ray.ang.service
+
+import android.app.*
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.graphics.Color
+import android.os.Build
+import android.util.Log
+import androidx.annotation.RequiresApi
+import androidx.core.app.NotificationCompat
+import com.tencent.mmkv.MMKV
+import com.v2ray.ang.AppConfig
+import com.v2ray.ang.AppConfig.ANG_PACKAGE
+import com.v2ray.ang.AppConfig.TAG_DIRECT
+import com.v2ray.ang.R
+import com.v2ray.ang.dto.ServerConfig
+import com.v2ray.ang.extension.toSpeedString
+import com.v2ray.ang.extension.toast
+import com.v2ray.ang.ui.MainActivity
+import com.v2ray.ang.util.MessageUtil
+import com.v2ray.ang.util.MmkvManager
+import com.v2ray.ang.util.Utils
+import com.v2ray.ang.util.V2rayConfigUtil
+import go.Seq
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.launch
+import libv2ray.Libv2ray
+import libv2ray.V2RayPoint
+import libv2ray.V2RayVPNServiceSupportsSet
+import rx.Observable
+import rx.Subscription
+import java.lang.ref.SoftReference
+import kotlin.math.min
+
+object V2RayServiceManager {
+ private const val NOTIFICATION_ID = 1
+ private const val NOTIFICATION_PENDING_INTENT_CONTENT = 0
+ private const val NOTIFICATION_PENDING_INTENT_STOP_V2RAY = 1
+ private const val NOTIFICATION_ICON_THRESHOLD = 3000
+
+ val v2rayPoint: V2RayPoint = Libv2ray.newV2RayPoint(V2RayCallback(), Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1)
+ private val mMsgReceive = ReceiveMessageHandler()
+ private val mainStorage by lazy { MMKV.mmkvWithID(MmkvManager.ID_MAIN, MMKV.MULTI_PROCESS_MODE) }
+ private val settingsStorage by lazy { MMKV.mmkvWithID(MmkvManager.ID_SETTING, MMKV.MULTI_PROCESS_MODE) }
+
+ var serviceControl: SoftReference? = null
+ set(value) {
+ field = value
+ Seq.setContext(value?.get()?.getService()?.applicationContext)
+ Libv2ray.initV2Env(Utils.userAssetPath(value?.get()?.getService()))
+ }
+ var currentConfig: ServerConfig? = null
+
+ private var lastQueryTime = 0L
+ private var mBuilder: NotificationCompat.Builder? = null
+ private var mSubscription: Subscription? = null
+ private var mNotificationManager: NotificationManager? = null
+
+ fun startV2Ray(context: Context) {
+ if (settingsStorage?.decodeBool(AppConfig.PREF_PROXY_SHARING) == true) {
+ } else {
+ }
+ val intent = if (settingsStorage?.decodeString(AppConfig.PREF_MODE) ?: "VPN" == "VPN") {
+ Intent(context.applicationContext, V2RayVpnService::class.java)
+ } else {
+ Intent(context.applicationContext, V2RayProxyOnlyService::class.java)
+ }
+ if (Build.VERSION.SDK_INT > Build.VERSION_CODES.N_MR1) {
+ context.startForegroundService(intent)
+ } else {
+ context.startService(intent)
+ }
+ }
+
+ private class V2RayCallback : V2RayVPNServiceSupportsSet {
+ override fun shutdown(): Long {
+ val serviceControl = serviceControl?.get() ?: return -1
+ // called by go
+ return try {
+ serviceControl.stopService()
+ 0
+ } catch (e: Exception) {
+ Log.d(ANG_PACKAGE, e.toString())
+ -1
+ }
+ }
+
+ override fun prepare(): Long {
+ return 0
+ }
+
+ override fun protect(l: Long): Boolean {
+ val serviceControl = serviceControl?.get() ?: return true
+ return serviceControl.vpnProtect(l.toInt())
+ }
+
+ override fun onEmitStatus(l: Long, s: String?): Long {
+ //Logger.d(s)
+ return 0
+ }
+
+ override fun setup(s: String): Long {
+ val serviceControl = serviceControl?.get() ?: return -1
+ //Logger.d(s)
+ return try {
+ serviceControl.startService()
+ lastQueryTime = System.currentTimeMillis()
+ startSpeedNotification()
+ 0
+ } catch (e: Exception) {
+ Log.d(ANG_PACKAGE, e.toString())
+ -1
+ }
+ }
+ }
+
+ fun startV2rayPoint() {
+ val service = serviceControl?.get()?.getService() ?: return
+ val guid = mainStorage?.decodeString(MmkvManager.KEY_SELECTED_SERVER) ?: return
+ val config = MmkvManager.decodeServerConfig(guid) ?: return
+ if (!v2rayPoint.isRunning) {
+ val result = V2rayConfigUtil.getV2rayConfig(service, guid)
+ if (!result.status)
+ return
+
+ try {
+ val mFilter = IntentFilter(AppConfig.BROADCAST_ACTION_SERVICE)
+ mFilter.addAction(Intent.ACTION_SCREEN_ON)
+ mFilter.addAction(Intent.ACTION_SCREEN_OFF)
+ mFilter.addAction(Intent.ACTION_USER_PRESENT)
+ service.registerReceiver(mMsgReceive, mFilter)
+ } catch (e: Exception) {
+ Log.d(ANG_PACKAGE, e.toString())
+ }
+
+ v2rayPoint.configureFileContent = result.content
+ v2rayPoint.domainName = config.getV2rayPointDomainAndPort()
+ currentConfig = config
+
+ try {
+ v2rayPoint.runLoop(settingsStorage?.decodeBool(AppConfig.PREF_PREFER_IPV6) ?: false)
+ } catch (e: Exception) {
+ Log.d(ANG_PACKAGE, e.toString())
+ }
+
+ if (v2rayPoint.isRunning) {
+ MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_START_SUCCESS, "")
+ showNotification()
+ } else {
+ MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_START_FAILURE, "")
+ cancelNotification()
+ }
+ }
+ }
+
+ fun stopV2rayPoint() {
+ val service = serviceControl?.get()?.getService() ?: return
+
+ if (v2rayPoint.isRunning) {
+ GlobalScope.launch(Dispatchers.Default) {
+ try {
+ v2rayPoint.stopLoop()
+ } catch (e: Exception) {
+ Log.d(ANG_PACKAGE, e.toString())
+ }
+ }
+ }
+
+ MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_STOP_SUCCESS, "")
+ cancelNotification()
+
+ try {
+ service.unregisterReceiver(mMsgReceive)
+ } catch (e: Exception) {
+ Log.d(ANG_PACKAGE, e.toString())
+ }
+ }
+
+ private class ReceiveMessageHandler : BroadcastReceiver() {
+ override fun onReceive(ctx: Context?, intent: Intent?) {
+ val serviceControl = serviceControl?.get() ?: return
+ when (intent?.getIntExtra("key", 0)) {
+ AppConfig.MSG_REGISTER_CLIENT -> {
+ //Logger.e("ReceiveMessageHandler", intent?.getIntExtra("key", 0).toString())
+ if (v2rayPoint.isRunning) {
+ MessageUtil.sendMsg2UI(serviceControl.getService(), AppConfig.MSG_STATE_RUNNING, "")
+ } else {
+ MessageUtil.sendMsg2UI(serviceControl.getService(), AppConfig.MSG_STATE_NOT_RUNNING, "")
+ }
+ }
+ AppConfig.MSG_UNREGISTER_CLIENT -> {
+ // nothing to do
+ }
+ AppConfig.MSG_STATE_START -> {
+ // nothing to do
+ }
+ AppConfig.MSG_STATE_STOP -> {
+ serviceControl.stopService()
+ }
+ AppConfig.MSG_STATE_RESTART -> {
+ startV2rayPoint()
+ }
+ AppConfig.MSG_MEASURE_DELAY -> {
+ measureV2rayDelay()
+ }
+ }
+
+ when (intent?.action) {
+ Intent.ACTION_SCREEN_OFF -> {
+ Log.d(ANG_PACKAGE, "SCREEN_OFF, stop querying stats")
+ stopSpeedNotification()
+ }
+ Intent.ACTION_SCREEN_ON -> {
+ Log.d(ANG_PACKAGE, "SCREEN_ON, start querying stats")
+ startSpeedNotification()
+ }
+ }
+ }
+ }
+
+ private fun measureV2rayDelay() {
+ GlobalScope.launch(Dispatchers.IO) {
+ val service = serviceControl?.get()?.getService() ?: return@launch
+ var time = -1L
+ var errstr = ""
+ if (v2rayPoint.isRunning) {
+ try {
+ time = v2rayPoint.measureDelay()
+ } catch (e: Exception) {
+ Log.d(ANG_PACKAGE, "measureV2rayDelay: $e")
+ errstr = e.message?.substringAfter("\":") ?: "empty message"
+ }
+ }
+ val result = if (time == -1L) {
+ service.getString(R.string.connection_test_error, errstr)
+ } else {
+ service.getString(R.string.connection_test_available, time)
+ }
+
+ MessageUtil.sendMsg2UI(service, AppConfig.MSG_MEASURE_DELAY_SUCCESS, result)
+ }
+ }
+
+ private fun showNotification() {
+ val service = serviceControl?.get()?.getService() ?: return
+ val startMainIntent = Intent(service, MainActivity::class.java)
+ val contentPendingIntent = PendingIntent.getActivity(service,
+ NOTIFICATION_PENDING_INTENT_CONTENT, startMainIntent,
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
+ } else {
+ PendingIntent.FLAG_UPDATE_CURRENT
+ })
+
+ val stopV2RayIntent = Intent(AppConfig.BROADCAST_ACTION_SERVICE)
+ stopV2RayIntent.`package` = ANG_PACKAGE
+ stopV2RayIntent.putExtra("key", AppConfig.MSG_STATE_STOP)
+
+ val stopV2RayPendingIntent = PendingIntent.getBroadcast(service,
+ NOTIFICATION_PENDING_INTENT_STOP_V2RAY, stopV2RayIntent,
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
+ } else {
+ PendingIntent.FLAG_UPDATE_CURRENT
+ })
+
+ val channelId =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ createNotificationChannel()
+ } else {
+ // If earlier version channel ID is not used
+ // https://developer.android.com/reference/android/support/v4/app/NotificationCompat.Builder.html#NotificationCompat.Builder(android.content.Context)
+ ""
+ }
+
+ mBuilder = NotificationCompat.Builder(service, channelId)
+ .setSmallIcon(R.drawable.ic_stat_name)
+ .setContentTitle(currentConfig?.remarks)
+ .setPriority(NotificationCompat.PRIORITY_MIN)
+ .setOngoing(true)
+ .setShowWhen(false)
+ .setOnlyAlertOnce(true)
+ .setContentIntent(contentPendingIntent)
+ .addAction(R.drawable.ic_close_grey_800_24dp,
+ service.getString(R.string.notification_action_stop_v2ray),
+ stopV2RayPendingIntent)
+ //.build()
+
+ //mBuilder?.setDefaults(NotificationCompat.FLAG_ONLY_ALERT_ONCE) //取消震动,铃声其他都不好使
+
+ service.startForeground(NOTIFICATION_ID, mBuilder?.build())
+ }
+
+ @RequiresApi(Build.VERSION_CODES.O)
+ private fun createNotificationChannel(): String {
+ val channelId = "RAY_NG_M_CH_ID"
+ val channelName = "V2rayNG"
+ val chan = NotificationChannel(channelId,
+ channelName, NotificationManager.IMPORTANCE_HIGH)
+ chan.lightColor = Color.DKGRAY
+ chan.importance = NotificationManager.IMPORTANCE_NONE
+ chan.lockscreenVisibility = Notification.VISIBILITY_PRIVATE
+ getNotificationManager()?.createNotificationChannel(chan)
+ return channelId
+ }
+
+ fun cancelNotification() {
+ val service = serviceControl?.get()?.getService() ?: return
+ service.stopForeground(true)
+ mBuilder = null
+ mSubscription?.unsubscribe()
+ mSubscription = null
+ }
+
+ private fun updateNotification(contentText: String?, proxyTraffic: Long, directTraffic: Long) {
+ if (mBuilder != null) {
+ if (proxyTraffic < NOTIFICATION_ICON_THRESHOLD && directTraffic < NOTIFICATION_ICON_THRESHOLD) {
+ mBuilder?.setSmallIcon(R.drawable.ic_stat_name)
+ } else if (proxyTraffic > directTraffic) {
+ mBuilder?.setSmallIcon(R.drawable.ic_stat_proxy)
+ } else {
+ mBuilder?.setSmallIcon(R.drawable.ic_stat_direct)
+ }
+ mBuilder?.setStyle(NotificationCompat.BigTextStyle().bigText(contentText))
+ mBuilder?.setContentText(contentText) // Emui4.1 need content text even if style is set as BigTextStyle
+ getNotificationManager()?.notify(NOTIFICATION_ID, mBuilder?.build())
+ }
+ }
+
+ private fun getNotificationManager(): NotificationManager? {
+ if (mNotificationManager == null) {
+ val service = serviceControl?.get()?.getService() ?: return null
+ mNotificationManager = service.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ }
+ return mNotificationManager
+ }
+
+ private fun startSpeedNotification() {
+ if (mSubscription == null &&
+ v2rayPoint.isRunning &&
+ settingsStorage?.decodeBool(AppConfig.PREF_SPEED_ENABLED) == true) {
+ var lastZeroSpeed = false
+ val outboundTags = currentConfig?.getAllOutboundTags()
+ outboundTags?.remove(TAG_DIRECT)
+
+ mSubscription = Observable.interval(3, java.util.concurrent.TimeUnit.SECONDS)
+ .subscribe {
+ val queryTime = System.currentTimeMillis()
+ val sinceLastQueryInSeconds = (queryTime - lastQueryTime) / 1000.0
+ var proxyTotal = 0L
+ val text = StringBuilder()
+ outboundTags?.forEach {
+ val up = v2rayPoint.queryStats(it, "uplink")
+ val down = v2rayPoint.queryStats(it, "downlink")
+ if (up + down > 0) {
+ appendSpeedString(text, it, up / sinceLastQueryInSeconds, down / sinceLastQueryInSeconds)
+ proxyTotal += up + down
+ }
+ }
+ val directUplink = v2rayPoint.queryStats(TAG_DIRECT, "uplink")
+ val directDownlink = v2rayPoint.queryStats(TAG_DIRECT, "downlink")
+ val zeroSpeed = (proxyTotal == 0L && directUplink == 0L && directDownlink == 0L)
+ if (!zeroSpeed || !lastZeroSpeed) {
+ if (proxyTotal == 0L) {
+ appendSpeedString(text, outboundTags?.firstOrNull(), 0.0, 0.0)
+ }
+ appendSpeedString(text, TAG_DIRECT, directUplink / sinceLastQueryInSeconds,
+ directDownlink / sinceLastQueryInSeconds)
+ updateNotification(text.toString(), proxyTotal, directDownlink + directUplink)
+ }
+ lastZeroSpeed = zeroSpeed
+ lastQueryTime = queryTime
+ }
+ }
+ }
+
+ private fun appendSpeedString(text: StringBuilder, name: String?, up: Double, down: Double) {
+ var n = name ?: "no tag"
+ n = n.substring(0, min(n.length, 6))
+ text.append(n)
+ for (i in n.length..6 step 2) {
+ text.append("\t")
+ }
+ text.append("• ${up.toLong().toSpeedString()}↑ ${down.toLong().toSpeedString()}↓\n")
+ }
+
+ private fun stopSpeedNotification() {
+ if (mSubscription != null) {
+ mSubscription?.unsubscribe() //stop queryStats
+ mSubscription = null
+ updateNotification(currentConfig?.remarks, 0, 0)
+ }
+ }
+}
diff --git a/app/src/main/kotlin/com/v2ray/ang/service/V2RayTestService.kt b/app/src/main/kotlin/com/v2ray/ang/service/V2RayTestService.kt
new file mode 100644
index 0000000..362b719
--- /dev/null
+++ b/app/src/main/kotlin/com/v2ray/ang/service/V2RayTestService.kt
@@ -0,0 +1,45 @@
+package com.v2ray.ang.service
+
+import android.app.Service
+import android.content.Intent
+import android.os.IBinder
+import com.v2ray.ang.AppConfig.MSG_MEASURE_CONFIG
+import com.v2ray.ang.AppConfig.MSG_MEASURE_CONFIG_CANCEL
+import com.v2ray.ang.AppConfig.MSG_MEASURE_CONFIG_SUCCESS
+import com.v2ray.ang.util.MessageUtil
+import com.v2ray.ang.util.SpeedtestUtil
+import com.v2ray.ang.util.Utils
+import go.Seq
+import kotlinx.coroutines.*
+import libv2ray.Libv2ray
+import java.util.concurrent.Executors
+
+class V2RayTestService : Service() {
+ private val realTestScope by lazy { CoroutineScope(Executors.newFixedThreadPool(10).asCoroutineDispatcher()) }
+
+ override fun onCreate() {
+ super.onCreate()
+ Seq.setContext(this)
+ Libv2ray.initV2Env(Utils.userAssetPath(this))
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ when (intent?.getIntExtra("key", 0)) {
+ MSG_MEASURE_CONFIG -> {
+ val contentPair = intent.getSerializableExtra("content") as Pair
+ realTestScope.launch {
+ val result = SpeedtestUtil.realPing(contentPair.second)
+ MessageUtil.sendMsg2UI(this@V2RayTestService, MSG_MEASURE_CONFIG_SUCCESS, Pair(contentPair.first, result))
+ }
+ }
+ MSG_MEASURE_CONFIG_CANCEL -> {
+ realTestScope.coroutineContext[Job]?.cancelChildren()
+ }
+ }
+ return super.onStartCommand(intent, flags, startId)
+ }
+
+ override fun onBind(intent: Intent?): IBinder? {
+ return null
+ }
+}
diff --git a/app/src/main/kotlin/com/v2ray/ang/service/V2RayVpnService.kt b/app/src/main/kotlin/com/v2ray/ang/service/V2RayVpnService.kt
new file mode 100644
index 0000000..af17b46
--- /dev/null
+++ b/app/src/main/kotlin/com/v2ray/ang/service/V2RayVpnService.kt
@@ -0,0 +1,333 @@
+package com.v2ray.ang.service
+
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.net.*
+import android.os.Build
+import android.os.ParcelFileDescriptor
+import android.os.StrictMode
+import android.util.Log
+import androidx.annotation.RequiresApi
+import com.tencent.mmkv.MMKV
+import com.v2ray.ang.AppConfig
+import com.v2ray.ang.R
+import com.v2ray.ang.dto.ERoutingMode
+import com.v2ray.ang.util.MmkvManager
+import com.v2ray.ang.util.MyContextWrapper
+import com.v2ray.ang.util.Utils
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.launch
+import java.io.File
+import java.lang.ref.SoftReference
+
+class V2RayVpnService : VpnService(), ServiceControl {
+ companion object {
+ private const val VPN_MTU = 1500
+ private const val PRIVATE_VLAN4_CLIENT = "26.26.26.1"
+ private const val PRIVATE_VLAN4_ROUTER = "26.26.26.2"
+ private const val PRIVATE_VLAN6_CLIENT = "da26:2626::1"
+ private const val PRIVATE_VLAN6_ROUTER = "da26:2626::2"
+ private const val TUN2SOCKS = "libtun2socks.so"
+ }
+
+ private val settingsStorage by lazy { MMKV.mmkvWithID(MmkvManager.ID_SETTING, MMKV.MULTI_PROCESS_MODE) }
+
+ private lateinit var mInterface: ParcelFileDescriptor
+ private var isRunning = false
+
+ //val fd: Int get() = mInterface.fd
+ private lateinit var process: Process
+
+ /**destroy
+ * Unfortunately registerDefaultNetworkCallback is going to return our VPN interface: https://android.googlesource.com/platform/frameworks/base/+/dda156ab0c5d66ad82bdcf76cda07cbc0a9c8a2e
+ *
+ * This makes doing a requestNetwork with REQUEST necessary so that we don't get ALL possible networks that
+ * satisfies default network capabilities but only THE default network. Unfortunately we need to have
+ * android.permission.CHANGE_NETWORK_STATE to be able to call requestNetwork.
+ *
+ * Source: https://android.googlesource.com/platform/frameworks/base/+/2df4c7d/services/core/java/com/android/server/ConnectivityService.java#887
+ */
+ @delegate:RequiresApi(Build.VERSION_CODES.P)
+ private val defaultNetworkRequest by lazy {
+ NetworkRequest.Builder()
+ .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
+ .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED)
+ .build()
+ }
+
+ private val connectivity by lazy { getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager }
+
+ @delegate:RequiresApi(Build.VERSION_CODES.P)
+ private val defaultNetworkCallback by lazy {
+ object : ConnectivityManager.NetworkCallback() {
+ override fun onAvailable(network: Network) {
+ setUnderlyingNetworks(arrayOf(network))
+ }
+
+ override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) {
+ // it's a good idea to refresh capabilities
+ setUnderlyingNetworks(arrayOf(network))
+ }
+
+ override fun onLost(network: Network) {
+ setUnderlyingNetworks(null)
+ }
+ }
+ }
+
+ override fun onCreate() {
+ super.onCreate()
+
+ val policy = StrictMode.ThreadPolicy.Builder().permitAll().build()
+ StrictMode.setThreadPolicy(policy)
+ V2RayServiceManager.serviceControl = SoftReference(this)
+ }
+
+ override fun onRevoke() {
+ stopV2Ray()
+ }
+
+// override fun onLowMemory() {
+// stopV2Ray()
+// super.onLowMemory()
+// }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ V2RayServiceManager.cancelNotification()
+ }
+
+ private fun setup() {
+ val prepare = prepare(this)
+ if (prepare != null) {
+ return
+ }
+
+ // If the old interface has exactly the same parameters, use it!
+ // Configure a builder while parsing the parameters.
+ val builder = Builder()
+ //val enableLocalDns = defaultDPreference.getPrefBoolean(AppConfig.PREF_LOCAL_DNS_ENABLED, false)
+
+ val routingMode = settingsStorage?.decodeString(AppConfig.PREF_ROUTING_MODE) ?: ERoutingMode.GLOBAL_PROXY.value
+
+ builder.setMtu(VPN_MTU)
+ builder.addAddress(PRIVATE_VLAN4_CLIENT, 30)
+ //builder.addDnsServer(PRIVATE_VLAN4_ROUTER)
+ if (routingMode == ERoutingMode.BYPASS_LAN.value || routingMode == ERoutingMode.BYPASS_LAN_MAINLAND.value) {
+ resources.getStringArray(R.array.bypass_private_ip_address).forEach {
+ val addr = it.split('/')
+ builder.addRoute(addr[0], addr[1].toInt())
+ }
+ } else {
+ builder.addRoute("0.0.0.0", 0)
+ }
+
+ if (settingsStorage?.decodeBool(AppConfig.PREF_PREFER_IPV6) == true) {
+ builder.addAddress(PRIVATE_VLAN6_CLIENT, 126)
+ if (routingMode == ERoutingMode.BYPASS_LAN.value || routingMode == ERoutingMode.BYPASS_LAN_MAINLAND.value) {
+ builder.addRoute("2000::", 3) //currently only 1/8 of total ipV6 is in use
+ } else {
+ builder.addRoute("::", 0)
+ }
+ }
+
+ if (settingsStorage?.decodeBool(AppConfig.PREF_LOCAL_DNS_ENABLED) == true) {
+ builder.addDnsServer(PRIVATE_VLAN4_ROUTER)
+ } else {
+ Utils.getVpnDnsServers()
+ .forEach {
+ if (Utils.isPureIpAddress(it)) {
+ builder.addDnsServer(it)
+ }
+ }
+ }
+
+ builder.setSession(V2RayServiceManager.currentConfig?.remarks.orEmpty())
+
+ if (settingsStorage?.decodeBool(AppConfig.PREF_PER_APP_PROXY) == true) {
+ val apps = settingsStorage?.decodeStringSet(AppConfig.PREF_PER_APP_PROXY_SET)
+ val bypassApps = settingsStorage?.decodeBool(AppConfig.PREF_BYPASS_APPS) ?: false
+ apps?.forEach {
+ try {
+ if (bypassApps)
+ builder.addDisallowedApplication(it)
+ else
+ builder.addAllowedApplication(it)
+ } catch (e: PackageManager.NameNotFoundException) {
+ //Logger.d(e)
+ }
+ }
+ }
+
+ // Close the old interface since the parameters have been changed.
+ try {
+ mInterface.close()
+ } catch (ignored: Exception) {
+ // ignored
+ }
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ try {
+ connectivity.requestNetwork(defaultNetworkRequest, defaultNetworkCallback)
+ } catch (e: Exception) {
+ e.printStackTrace()
+ }
+ }
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ builder.setMetered(false)
+ }
+
+ // Create a new interface using the builder and save the parameters.
+ try {
+ mInterface = builder.establish()!!
+ isRunning = true
+ runTun2socks()
+ } catch (e: Exception) {
+ // non-nullable lateinit var
+ e.printStackTrace()
+ stopV2Ray()
+ }
+ }
+
+ private fun runTun2socks() {
+ val socksPort = Utils.parseInt(settingsStorage?.decodeString(AppConfig.PREF_SOCKS_PORT), AppConfig.PORT_SOCKS.toInt())
+ val cmd = arrayListOf(File(applicationContext.applicationInfo.nativeLibraryDir, TUN2SOCKS).absolutePath,
+ "--netif-ipaddr", PRIVATE_VLAN4_ROUTER,
+ "--netif-netmask", "255.255.255.252",
+ "--socks-server-addr", "127.0.0.1:${socksPort}",
+ "--tunmtu", VPN_MTU.toString(),
+ "--sock-path", "sock_path",//File(applicationContext.filesDir, "sock_path").absolutePath,
+ "--enable-udprelay",
+ "--loglevel", "notice")
+
+ if (settingsStorage?.decodeBool(AppConfig.PREF_PREFER_IPV6) == true) {
+ cmd.add("--netif-ip6addr")
+ cmd.add(PRIVATE_VLAN6_ROUTER)
+ }
+ if (settingsStorage?.decodeBool(AppConfig.PREF_LOCAL_DNS_ENABLED) == true) {
+ val localDnsPort = Utils.parseInt(settingsStorage?.decodeString(AppConfig.PREF_LOCAL_DNS_PORT), AppConfig.PORT_LOCAL_DNS.toInt())
+ cmd.add("--dnsgw")
+ cmd.add("127.0.0.1:${localDnsPort}")
+ }
+ Log.d(packageName, cmd.toString())
+
+ try {
+ val proBuilder = ProcessBuilder(cmd)
+ proBuilder.redirectErrorStream(true)
+ process = proBuilder
+ .directory(applicationContext.filesDir)
+ .start()
+ Thread(Runnable {
+ Log.d(packageName,"$TUN2SOCKS check")
+ process.waitFor()
+ Log.d(packageName,"$TUN2SOCKS exited")
+ if (isRunning) {
+ Log.d(packageName,"$TUN2SOCKS restart")
+ runTun2socks()
+ }
+ }).start()
+ Log.d(packageName, process.toString())
+
+ sendFd()
+ } catch (e: Exception) {
+ Log.d(packageName, e.toString())
+ }
+ }
+
+ private fun sendFd() {
+ val fd = mInterface.fileDescriptor
+ val path = File(applicationContext.filesDir, "sock_path").absolutePath
+ Log.d(packageName, path)
+
+ GlobalScope.launch(Dispatchers.IO) {
+ var tries = 0
+ while (true) try {
+ Thread.sleep(50L shl tries)
+ Log.d(packageName, "sendFd tries: $tries")
+ LocalSocket().use { localSocket ->
+ localSocket.connect(LocalSocketAddress(path, LocalSocketAddress.Namespace.FILESYSTEM))
+ localSocket.setFileDescriptorsForSend(arrayOf(fd))
+ localSocket.outputStream.write(42)
+ }
+ break
+ } catch (e: Exception) {
+ Log.d(packageName, e.toString())
+ if (tries > 5) break
+ tries += 1
+ }
+ }
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ V2RayServiceManager.startV2rayPoint()
+ return START_STICKY
+ //return super.onStartCommand(intent, flags, startId)
+ }
+
+ private fun stopV2Ray(isForced: Boolean = true) {
+// val configName = defaultDPreference.getPrefString(PREF_CURR_CONFIG_GUID, "")
+// val emptyInfo = VpnNetworkInfo()
+// val info = loadVpnNetworkInfo(configName, emptyInfo)!! + (lastNetworkInfo ?: emptyInfo)
+// saveVpnNetworkInfo(configName, info)
+ isRunning = false;
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ try {
+ connectivity.unregisterNetworkCallback(defaultNetworkCallback)
+ } catch (ignored: Exception) {
+ // ignored
+ }
+ }
+
+ try {
+ Log.d(packageName, "tun2socks destroy")
+ process.destroy()
+ } catch (e: Exception) {
+ Log.d(packageName, e.toString())
+ }
+
+ V2RayServiceManager.stopV2rayPoint()
+
+ if (isForced) {
+ //stopSelf has to be called ahead of mInterface.close(). otherwise v2ray core cannot be stooped
+ //It's strage but true.
+ //This can be verified by putting stopself() behind and call stopLoop and startLoop
+ //in a row for several times. You will find that later created v2ray core report port in use
+ //which means the first v2ray core somehow failed to stop and release the port.
+ stopSelf()
+
+ try {
+ mInterface.close()
+ } catch (ignored: Exception) {
+ // ignored
+ }
+ }
+ }
+
+ override fun getService(): Service {
+ return this
+ }
+
+ override fun startService() {
+ setup()
+ }
+
+ override fun stopService() {
+ stopV2Ray(true)
+ }
+
+ override fun vpnProtect(socket: Int): Boolean {
+ return protect(socket)
+ }
+
+ @RequiresApi(Build.VERSION_CODES.N)
+ override fun attachBaseContext(newBase: Context?) {
+ val context = newBase?.let {
+ MyContextWrapper.wrap(newBase, Utils.getLocale(newBase))
+ }
+ super.attachBaseContext(context)
+ }
+}
diff --git a/app/src/main/kotlin/com/v2ray/ang/ui/BaseActivity.kt b/app/src/main/kotlin/com/v2ray/ang/ui/BaseActivity.kt
new file mode 100644
index 0000000..65a211f
--- /dev/null
+++ b/app/src/main/kotlin/com/v2ray/ang/ui/BaseActivity.kt
@@ -0,0 +1,27 @@
+package com.v2ray.ang.ui
+
+import android.content.Context
+import android.os.Build
+import android.view.MenuItem
+import androidx.annotation.RequiresApi
+import androidx.appcompat.app.AppCompatActivity
+import com.v2ray.ang.util.MyContextWrapper
+import com.v2ray.ang.util.Utils
+
+abstract class BaseActivity : AppCompatActivity() {
+ override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
+ android.R.id.home -> {
+ onBackPressed()
+ true
+ }
+ else -> super.onOptionsItemSelected(item)
+ }
+
+ @RequiresApi(Build.VERSION_CODES.N)
+ override fun attachBaseContext(newBase: Context?) {
+ val context = newBase?.let {
+ MyContextWrapper.wrap(newBase, Utils.getLocale(newBase))
+ }
+ super.attachBaseContext(context)
+ }
+}
diff --git a/app/src/main/kotlin/com/v2ray/ang/ui/FragmentAdapter.kt b/app/src/main/kotlin/com/v2ray/ang/ui/FragmentAdapter.kt
new file mode 100644
index 0000000..d9d1299
--- /dev/null
+++ b/app/src/main/kotlin/com/v2ray/ang/ui/FragmentAdapter.kt
@@ -0,0 +1,17 @@
+package com.v2ray.ang.ui
+
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.FragmentActivity
+import androidx.viewpager2.adapter.FragmentStateAdapter
+
+class FragmentAdapter(fragmentActivity: FragmentActivity, private val mFragments: List) :
+ FragmentStateAdapter(fragmentActivity) {
+
+ override fun createFragment(position: Int): Fragment {
+ return mFragments[position]
+ }
+
+ override fun getItemCount(): Int {
+ return mFragments.size
+ }
+}
diff --git a/app/src/main/kotlin/com/v2ray/ang/ui/LogcatActivity.kt b/app/src/main/kotlin/com/v2ray/ang/ui/LogcatActivity.kt
new file mode 100644
index 0000000..08d7649
--- /dev/null
+++ b/app/src/main/kotlin/com/v2ray/ang/ui/LogcatActivity.kt
@@ -0,0 +1,92 @@
+package com.v2ray.ang.ui
+
+import android.os.Handler
+import android.os.Looper
+import android.os.Bundle
+import android.text.method.ScrollingMovementMethod
+import android.view.Menu
+import android.view.MenuItem
+import android.view.View
+import androidx.lifecycle.lifecycleScope
+import com.v2ray.ang.AppConfig.ANG_PACKAGE
+import com.v2ray.ang.R
+import com.v2ray.ang.databinding.ActivityLogcatBinding
+import com.v2ray.ang.extension.toast
+import com.v2ray.ang.util.Utils
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.launch
+
+import java.io.IOException
+import java.util.LinkedHashSet
+
+class LogcatActivity : BaseActivity() {
+ private lateinit var binding: ActivityLogcatBinding
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ binding = ActivityLogcatBinding.inflate(layoutInflater)
+ val view = binding.root
+ setContentView(view)
+
+ title = getString(R.string.title_logcat)
+
+ supportActionBar?.setDisplayHomeAsUpEnabled(true)
+ logcat(false)
+ }
+
+ private fun logcat(shouldFlushLog: Boolean) {
+
+ try {
+ binding.pbWaiting.visibility = View.VISIBLE
+
+ lifecycleScope.launch(Dispatchers.Default) {
+ if (shouldFlushLog) {
+ val lst = LinkedHashSet()
+ lst.add("logcat")
+ lst.add("-c")
+ val process = Runtime.getRuntime().exec(lst.toTypedArray())
+ process.waitFor()
+ }
+ val lst = LinkedHashSet()
+ lst.add("logcat")
+ lst.add("-d")
+ lst.add("-v")
+ lst.add("time")
+ lst.add("-s")
+ lst.add("GoLog,tun2socks,${ANG_PACKAGE},AndroidRuntime,System.err")
+ val process = Runtime.getRuntime().exec(lst.toTypedArray())
+// val bufferedReader = BufferedReader(
+// InputStreamReader(process.inputStream))
+// val allText = bufferedReader.use(BufferedReader::readText)
+ val allText = process.inputStream.bufferedReader().use { it.readText() }
+ launch(Dispatchers.Main) {
+ binding.tvLogcat.text = allText
+ binding.tvLogcat.movementMethod = ScrollingMovementMethod()
+ binding.pbWaiting.visibility = View.GONE
+ Handler(Looper.getMainLooper()).post { binding.svLogcat.fullScroll(View.FOCUS_DOWN) }
+ }
+ }
+ } catch (e: IOException) {
+ e.printStackTrace()
+ }
+ }
+
+ override fun onCreateOptionsMenu(menu: Menu): Boolean {
+ menuInflater.inflate(R.menu.menu_logcat, menu)
+ return super.onCreateOptionsMenu(menu)
+ }
+
+ override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
+ R.id.copy_all -> {
+ Utils.setClipboard(this, binding.tvLogcat.text.toString())
+ toast(R.string.toast_success)
+ true
+ }
+ R.id.clear_all -> {
+ logcat(true)
+ true
+ }
+ else -> super.onOptionsItemSelected(item)
+ }
+}
diff --git a/app/src/main/kotlin/com/v2ray/ang/ui/MainActivity.kt b/app/src/main/kotlin/com/v2ray/ang/ui/MainActivity.kt
new file mode 100644
index 0000000..07640a4
--- /dev/null
+++ b/app/src/main/kotlin/com/v2ray/ang/ui/MainActivity.kt
@@ -0,0 +1,678 @@
+package com.v2ray.ang.ui
+
+import android.Manifest
+import android.content.*
+import android.net.Uri
+import android.net.VpnService
+import androidx.recyclerview.widget.LinearLayoutManager
+import android.view.Menu
+import android.view.MenuItem
+import com.tbruyelle.rxpermissions.RxPermissions
+import com.v2ray.ang.R
+import android.os.Bundle
+import android.text.TextUtils
+import android.view.KeyEvent
+import com.v2ray.ang.AppConfig
+import android.content.res.ColorStateList
+import android.os.Build
+import com.google.android.material.navigation.NavigationView
+import androidx.core.content.ContextCompat
+import androidx.core.view.GravityCompat
+import androidx.appcompat.app.ActionBarDrawerToggle
+import androidx.recyclerview.widget.ItemTouchHelper
+import android.util.Log
+import android.widget.Toast
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.activity.viewModels
+import androidx.appcompat.app.AlertDialog
+import androidx.lifecycle.lifecycleScope
+import com.tencent.mmkv.MMKV
+import com.v2ray.ang.AppConfig.ANG_PACKAGE
+import com.v2ray.ang.BuildConfig
+import com.v2ray.ang.databinding.ActivityMainBinding
+import com.v2ray.ang.dto.EConfigType
+import com.v2ray.ang.extension.toast
+import rx.Observable
+import rx.android.schedulers.AndroidSchedulers
+import java.util.concurrent.TimeUnit
+import com.v2ray.ang.helper.SimpleItemTouchHelperCallback
+import com.v2ray.ang.service.V2RayServiceManager
+import com.v2ray.ang.util.*
+import com.v2ray.ang.viewmodel.MainViewModel
+import kotlinx.coroutines.*
+import me.drakeet.support.toast.ToastCompat
+import java.io.File
+import java.io.FileOutputStream
+
+class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedListener {
+ private lateinit var binding: ActivityMainBinding
+
+ private val adapter by lazy { MainRecyclerAdapter(this) }
+ private val mainStorage by lazy { MMKV.mmkvWithID(MmkvManager.ID_MAIN, MMKV.MULTI_PROCESS_MODE) }
+ private val settingsStorage by lazy { MMKV.mmkvWithID(MmkvManager.ID_SETTING, MMKV.MULTI_PROCESS_MODE) }
+ private val requestVpnPermission = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
+ if (it.resultCode == RESULT_OK) {
+ startV2Ray()
+ }
+ }
+ private var mItemTouchHelper: ItemTouchHelper? = null
+ val mainViewModel: MainViewModel by viewModels()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ binding = ActivityMainBinding.inflate(layoutInflater)
+ val view = binding.root
+ setContentView(view)
+ title = getString(R.string.title_server)
+ setSupportActionBar(binding.toolbar)
+
+ binding.fab.setOnClickListener {
+ if (mainViewModel.isRunning.value == true) {
+ Utils.stopVService(this)
+ } else if (settingsStorage?.decodeString(AppConfig.PREF_MODE) ?: "VPN" == "VPN") {
+ val intent = VpnService.prepare(this)
+ if (intent == null) {
+ startV2Ray()
+ } else {
+ requestVpnPermission.launch(intent)
+ }
+ } else {
+ startV2Ray()
+ }
+ }
+ binding.layoutTest.setOnClickListener {
+ if (mainViewModel.isRunning.value == true) {
+ setTestState(getString(R.string.connection_test_testing))
+ mainViewModel.testCurrentServerRealPing()
+ } else {
+// tv_test_state.text = getString(R.string.connection_test_fail)
+ }
+ }
+
+ binding.recyclerView.setHasFixedSize(true)
+ binding.recyclerView.layoutManager = LinearLayoutManager(this)
+ binding.recyclerView.adapter = adapter
+
+ val callback = SimpleItemTouchHelperCallback(adapter)
+ mItemTouchHelper = ItemTouchHelper(callback)
+ mItemTouchHelper?.attachToRecyclerView(binding.recyclerView)
+
+
+ val toggle = ActionBarDrawerToggle(
+ this, binding.drawerLayout, binding.toolbar, R.string.navigation_drawer_open, R.string.navigation_drawer_close)
+ binding.drawerLayout.addDrawerListener(toggle)
+ toggle.syncState()
+ binding.navView.setNavigationItemSelectedListener(this)
+ binding.version.text = "v${BuildConfig.VERSION_NAME} (${SpeedtestUtil.getLibVersion()})"
+
+ setupViewModel()
+ copyAssets()
+ migrateLegacy()
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ RxPermissions(this)
+ .request(Manifest.permission.POST_NOTIFICATIONS)
+ .subscribe {
+ }
+ }
+ }
+
+ private fun setupViewModel() {
+ mainViewModel.updateListAction.observe(this) { index ->
+ if (index >= 0) {
+ adapter.notifyItemChanged(index)
+ } else {
+ adapter.notifyDataSetChanged()
+ }
+ }
+ mainViewModel.updateTestResultAction.observe(this) { setTestState(it) }
+ mainViewModel.isRunning.observe(this) { isRunning ->
+ adapter.isRunning = isRunning
+ if (isRunning) {
+ binding.fab.backgroundTintList = ColorStateList.valueOf(ContextCompat.getColor(this, R.color.colorSelected))
+ setTestState(getString(R.string.connection_connected))
+ binding.layoutTest.isFocusable = true
+ } else {
+ binding.fab.backgroundTintList = ColorStateList.valueOf(ContextCompat.getColor(this, R.color.colorUnselected))
+ setTestState(getString(R.string.connection_not_connected))
+ binding.layoutTest.isFocusable = false
+ }
+ hideCircle()
+ }
+ mainViewModel.startListenBroadcast()
+ }
+
+ private fun copyAssets() {
+ val extFolder = Utils.userAssetPath(this)
+ lifecycleScope.launch(Dispatchers.IO) {
+ try {
+ val geo = arrayOf("geosite.dat", "geoip.dat")
+ assets.list("")
+ ?.filter { geo.contains(it) }
+ ?.filter { !File(extFolder, it).exists() }
+ ?.forEach {
+ val target = File(extFolder, it)
+ assets.open(it).use { input ->
+ FileOutputStream(target).use { output ->
+ input.copyTo(output)
+ }
+ }
+ Log.i(ANG_PACKAGE, "Copied from apk assets folder to ${target.absolutePath}")
+ }
+ } catch (e: Exception) {
+ Log.e(ANG_PACKAGE, "asset copy failed", e)
+ }
+ }
+ }
+
+ private fun migrateLegacy() {
+ lifecycleScope.launch(Dispatchers.IO) {
+ val result = AngConfigManager.migrateLegacyConfig(this@MainActivity)
+ if (result != null) {
+ launch(Dispatchers.Main) {
+ if (result) {
+ toast(getString(R.string.migration_success))
+ mainViewModel.reloadServerList()
+ } else {
+ toast(getString(R.string.migration_fail))
+ }
+ }
+ }
+ }
+ }
+
+ fun startV2Ray() {
+ if (mainStorage?.decodeString(MmkvManager.KEY_SELECTED_SERVER).isNullOrEmpty()) {
+ return
+ }
+ showCircle()
+// toast(R.string.toast_services_start)
+ V2RayServiceManager.startV2Ray(this)
+ hideCircle()
+ }
+
+ fun restartV2Ray() {
+ if (mainViewModel.isRunning.value == true) {
+ Utils.stopVService(this)
+ }
+ Observable.timer(500, TimeUnit.MILLISECONDS)
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe {
+ startV2Ray()
+ }
+ }
+
+ public override fun onResume() {
+ super.onResume()
+ mainViewModel.reloadServerList()
+ }
+
+ public override fun onPause() {
+ super.onPause()
+ }
+
+ override fun onCreateOptionsMenu(menu: Menu): Boolean {
+ menuInflater.inflate(R.menu.menu_main, menu)
+ return true
+ }
+
+ override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
+ R.id.import_qrcode -> {
+ importQRcode(true)
+ true
+ }
+ R.id.import_clipboard -> {
+ importClipboard()
+ true
+ }
+ R.id.import_manually_vmess -> {
+ importManually(EConfigType.VMESS.value)
+ true
+ }
+ R.id.import_manually_vless -> {
+ importManually(EConfigType.VLESS.value)
+ true
+ }
+ R.id.import_manually_ss -> {
+ importManually(EConfigType.SHADOWSOCKS.value)
+ true
+ }
+ R.id.import_manually_socks -> {
+ importManually(EConfigType.SOCKS.value)
+ true
+ }
+ R.id.import_manually_trojan -> {
+ importManually(EConfigType.TROJAN.value)
+ true
+ }
+ R.id.import_config_custom_clipboard -> {
+ importConfigCustomClipboard()
+ true
+ }
+ R.id.import_config_custom_local -> {
+ importConfigCustomLocal()
+ true
+ }
+ R.id.import_config_custom_url -> {
+ importConfigCustomUrlClipboard()
+ true
+ }
+ R.id.import_config_custom_url_scan -> {
+ importQRcode(false)
+ true
+ }
+
+// R.id.sub_setting -> {
+// startActivity()
+// true
+// }
+
+ R.id.sub_update -> {
+ importConfigViaSub()
+ true
+ }
+
+ R.id.export_all -> {
+ if (AngConfigManager.shareNonCustomConfigsToClipboard(this, mainViewModel.serverList) == 0) {
+ toast(R.string.toast_success)
+ } else {
+ toast(R.string.toast_failure)
+ }
+ true
+ }
+
+ R.id.ping_all -> {
+ mainViewModel.testAllTcping()
+ true
+ }
+
+ R.id.real_ping_all -> {
+ mainViewModel.testAllRealPing()
+ true
+ }
+
+ R.id.service_restart -> {
+ restartV2Ray()
+ true
+ }
+
+ R.id.del_all_config -> {
+ AlertDialog.Builder(this).setMessage(R.string.del_config_comfirm)
+ .setPositiveButton(android.R.string.ok) { _, _ ->
+ MmkvManager.removeAllServer()
+ mainViewModel.reloadServerList()
+ }
+ .show()
+ true
+ }
+ R.id.del_duplicate_config-> {
+ AlertDialog.Builder(this).setMessage(R.string.del_config_comfirm)
+ .setPositiveButton(android.R.string.ok) { _, _ ->
+ mainViewModel.removeDuplicateServer()
+ }
+ .show()
+ true
+ }
+ R.id.del_invalid_config -> {
+ AlertDialog.Builder(this).setMessage(R.string.del_config_comfirm)
+ .setPositiveButton(android.R.string.ok) { _, _ ->
+ MmkvManager.removeInvalidServer()
+ mainViewModel.reloadServerList()
+ }
+ .show()
+ true
+ }
+ R.id.sort_by_test_results -> {
+ MmkvManager.sortByTestResults()
+ mainViewModel.reloadServerList()
+ true
+ }
+ R.id.filter_config -> {
+ mainViewModel.filterConfig(this)
+ true
+ }
+
+ else -> super.onOptionsItemSelected(item)
+ }
+
+ private fun importManually(createConfigType : Int) {
+ startActivity(
+ Intent()
+ .putExtra("createConfigType", createConfigType)
+ .putExtra("subscriptionId", mainViewModel.subscriptionId)
+ .setClass(this, ServerActivity::class.java)
+ )
+ }
+
+ /**
+ * import config from qrcode
+ */
+ fun importQRcode(forConfig: Boolean): Boolean {
+// try {
+// startActivityForResult(Intent("com.google.zxing.client.android.SCAN")
+// .addCategory(Intent.CATEGORY_DEFAULT)
+// .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP), requestCode)
+// } catch (e: Exception) {
+ RxPermissions(this)
+ .request(Manifest.permission.CAMERA)
+ .subscribe {
+ if (it)
+ if (forConfig)
+ scanQRCodeForConfig.launch(Intent(this, ScannerActivity::class.java))
+ else
+ scanQRCodeForUrlToCustomConfig.launch(Intent(this, ScannerActivity::class.java))
+ else
+ toast(R.string.toast_permission_denied)
+ }
+// }
+ return true
+ }
+
+ private val scanQRCodeForConfig = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
+ if (it.resultCode == RESULT_OK) {
+ importBatchConfig(it.data?.getStringExtra("SCAN_RESULT"))
+ }
+ }
+
+ private val scanQRCodeForUrlToCustomConfig = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
+ if (it.resultCode == RESULT_OK) {
+ importConfigCustomUrl(it.data?.getStringExtra("SCAN_RESULT"))
+ }
+ }
+
+ /**
+ * import config from clipboard
+ */
+ fun importClipboard()
+ : Boolean {
+ try {
+ val clipboard = Utils.getClipboard(this)
+ importBatchConfig(clipboard)
+ } catch (e: Exception) {
+ e.printStackTrace()
+ return false
+ }
+ return true
+ }
+
+ fun importBatchConfig(server: String?, subid: String = "") {
+ val subid2 = if(subid.isNullOrEmpty()){
+ mainViewModel.subscriptionId
+ }else{
+ subid
+ }
+ val append = subid.isNullOrEmpty()
+
+ var count = AngConfigManager.importBatchConfig(server, subid2, append)
+ if (count <= 0) {
+ count = AngConfigManager.importBatchConfig(Utils.decode(server!!), subid2, append)
+ }
+ if (count > 0) {
+ mainViewModel.reloadServerList()
+ } else {
+ }
+ }
+
+ fun importConfigCustomClipboard()
+ : Boolean {
+ try {
+ val configText = Utils.getClipboard(this)
+ if (TextUtils.isEmpty(configText)) {
+ toast(R.string.toast_none_data_clipboard)
+ return false
+ }
+ importCustomizeConfig(configText)
+ return true
+ } catch (e: Exception) {
+ e.printStackTrace()
+ return false
+ }
+ }
+
+ /**
+ * import config from local config file
+ */
+ fun importConfigCustomLocal(): Boolean {
+ try {
+ showFileChooser()
+ } catch (e: Exception) {
+ e.printStackTrace()
+ return false
+ }
+ return true
+ }
+
+ fun importConfigCustomUrlClipboard()
+ : Boolean {
+ try {
+ val url = Utils.getClipboard(this)
+ if (TextUtils.isEmpty(url)) {
+ toast(R.string.toast_none_data_clipboard)
+ return false
+ }
+ return importConfigCustomUrl(url)
+ } catch (e: Exception) {
+ e.printStackTrace()
+ return false
+ }
+ }
+
+ /**
+ * import config from url
+ */
+ fun importConfigCustomUrl(url: String?): Boolean {
+ try {
+ if (!Utils.isValidUrl(url)) {
+ toast(R.string.toast_invalid_url)
+ return false
+ }
+ lifecycleScope.launch(Dispatchers.IO) {
+ val configText = try {
+ Utils.getUrlContentWithCustomUserAgent(url)
+ } catch (e: Exception) {
+ e.printStackTrace()
+ ""
+ }
+ launch(Dispatchers.Main) {
+ importCustomizeConfig(configText)
+ }
+ }
+ } catch (e: Exception) {
+ e.printStackTrace()
+ return false
+ }
+ return true
+ }
+
+ /**
+ * import config from sub
+ */
+ fun importConfigViaSub()
+ : Boolean {
+ try {
+ toast(R.string.title_sub_update)
+ MmkvManager.decodeSubscriptions().forEach {
+ if (TextUtils.isEmpty(it.first)
+ || TextUtils.isEmpty(it.second.remarks)
+ || TextUtils.isEmpty(it.second.url)
+ ) {
+ return@forEach
+ }
+ if (!it.second.enabled) {
+ return@forEach
+ }
+ val url = Utils.idnToASCII(it.second.url)
+ if (!Utils.isValidUrl(url)) {
+ return@forEach
+ }
+ Log.d(ANG_PACKAGE, url)
+ lifecycleScope.launch(Dispatchers.IO) {
+ val configText = try {
+ Utils.getUrlContentWithCustomUserAgent(url)
+ } catch (e: Exception) {
+ e.printStackTrace()
+ launch(Dispatchers.Main) {
+ toast("\"" + it.second.remarks + "\" " + getString(R.string.toast_failure))
+ }
+ return@launch
+ }
+ launch(Dispatchers.Main) {
+ importBatchConfig(configText, it.first)
+ }
+ }
+ }
+ } catch (e: Exception) {
+ e.printStackTrace()
+ return false
+ }
+ return true
+ }
+
+ /**
+ * show file chooser
+ */
+ private fun showFileChooser() {
+ val intent = Intent(Intent.ACTION_GET_CONTENT)
+ intent.type = "*/*"
+ intent.addCategory(Intent.CATEGORY_OPENABLE)
+
+ try {
+ chooseFileForCustomConfig.launch(Intent.createChooser(intent, getString(R.string.title_file_chooser)))
+ } catch (ex: ActivityNotFoundException) {
+ toast(R.string.toast_require_file_manager)
+ }
+ }
+
+ private val chooseFileForCustomConfig = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
+ val uri = it.data?.data
+ if (it.resultCode == RESULT_OK && uri != null) {
+ readContentFromUri(uri)
+ }
+ }
+
+ /**
+ * read content from uri
+ */
+ private fun readContentFromUri(uri: Uri) {
+ RxPermissions(this)
+ .request(Manifest.permission.READ_EXTERNAL_STORAGE)
+ .subscribe {
+ if (it) {
+ try {
+ contentResolver.openInputStream(uri).use { input ->
+ importCustomizeConfig(input?.bufferedReader()?.readText())
+ }
+ } catch (e: Exception) {
+ e.printStackTrace()
+ }
+ } else
+ toast(R.string.toast_permission_denied)
+ }
+ }
+
+ /**
+ * import customize config
+ */
+ fun importCustomizeConfig(server: String?) {
+ try {
+ if (server == null || TextUtils.isEmpty(server)) {
+ toast(R.string.toast_none_data)
+ return
+ }
+ mainViewModel.appendCustomConfigServer(server)
+ mainViewModel.reloadServerList()
+ toast(R.string.toast_success)
+ //adapter.notifyItemInserted(mainViewModel.serverList.lastIndex)
+ } catch (e: Exception) {
+ ToastCompat.makeText(this, "${getString(R.string.toast_malformed_josn)} ${e.cause?.message}", Toast.LENGTH_LONG).show()
+ e.printStackTrace()
+ return
+ }
+ }
+
+ fun setTestState(content: String?) {
+ binding.tvTestState.text = content
+ }
+
+// val mConnection = object : ServiceConnection {
+// override fun onServiceDisconnected(name: ComponentName?) {
+// }
+//
+// override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
+// sendMsg(AppConfig.MSG_REGISTER_CLIENT, "")
+// }
+// }
+
+ override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
+ if (keyCode == KeyEvent.KEYCODE_BACK) {
+ moveTaskToBack(false)
+ return true
+ }
+ return super.onKeyDown(keyCode, event)
+ }
+
+ fun showCircle() {
+ binding.fabProgressCircle.show()
+ }
+
+ fun hideCircle() {
+ try {
+ Observable.timer(300, TimeUnit.MILLISECONDS)
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe {
+ try {
+ if (binding.fabProgressCircle.isShown) {
+ binding.fabProgressCircle.hide()
+ }
+ } catch (e: Exception) {
+ Log.w(ANG_PACKAGE, e)
+ }
+ }
+ } catch (e: Exception) {
+ Log.d(ANG_PACKAGE, e.toString())
+ }
+ }
+
+ @Deprecated("Deprecated in Java")
+ override fun onBackPressed() {
+ if (binding.drawerLayout.isDrawerOpen(GravityCompat.START)) {
+ binding.drawerLayout.closeDrawer(GravityCompat.START)
+ } else {
+ //super.onBackPressed()
+ onBackPressedDispatcher.onBackPressed()
+ }
+ }
+
+ override fun onNavigationItemSelected(item: MenuItem): Boolean {
+ // Handle navigation view item clicks here.
+ when (item.itemId) {
+ //R.id.server_profile -> activityClass = MainActivity::class.java
+ R.id.sub_setting -> {
+ toast(getString(R.string.subscription_warning))
+ startActivity(Intent(this, SubSettingActivity::class.java))
+ }
+ R.id.settings -> {
+ startActivity(Intent(this, SettingsActivity::class.java)
+ .putExtra("isRunning", mainViewModel.isRunning.value == true))
+ }
+ R.id.user_asset_setting -> {
+ startActivity(Intent(this, UserAssetActivity::class.java))
+ }
+ R.id.feedback -> {
+ Utils.openUri(this, AppConfig.v2rayNGIssues)
+ toast(getString(R.string.opening_browser))
+ }
+ R.id.logcat -> {
+ startActivity(Intent(this, LogcatActivity::class.java))
+ }
+ R.id.vpnhotspot -> {
+ toast(getString(R.string.vpnhotspot_need_installed))
+ Intent();
+ intent.setComponent( ComponentName("be.mygod.vpnhotspot","be.mygod.vpnhotspot.MainActivity"));
+ startActivity(intent)
+ }
+ }
+ binding.drawerLayout.closeDrawer(GravityCompat.START)
+ return true
+ }
+}
diff --git a/app/src/main/kotlin/com/v2ray/ang/ui/MainRecyclerAdapter.kt b/app/src/main/kotlin/com/v2ray/ang/ui/MainRecyclerAdapter.kt
new file mode 100644
index 0000000..25a90db
--- /dev/null
+++ b/app/src/main/kotlin/com/v2ray/ang/ui/MainRecyclerAdapter.kt
@@ -0,0 +1,260 @@
+package com.v2ray.ang.ui
+
+import android.content.Intent
+import android.graphics.Color
+import android.text.TextUtils
+import androidx.core.content.ContextCompat
+import androidx.recyclerview.widget.RecyclerView
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.appcompat.app.AlertDialog
+import com.google.gson.Gson
+import com.tencent.mmkv.MMKV
+import com.v2ray.ang.AppConfig
+import com.v2ray.ang.R
+import com.v2ray.ang.databinding.ItemQrcodeBinding
+import com.v2ray.ang.databinding.ItemRecyclerFooterBinding
+import com.v2ray.ang.databinding.ItemRecyclerMainBinding
+import com.v2ray.ang.dto.EConfigType
+import com.v2ray.ang.dto.SubscriptionItem
+import com.v2ray.ang.extension.toast
+import com.v2ray.ang.helper.ItemTouchHelperAdapter
+import com.v2ray.ang.helper.ItemTouchHelperViewHolder
+import com.v2ray.ang.service.V2RayServiceManager
+import com.v2ray.ang.util.AngConfigManager
+import com.v2ray.ang.util.MmkvManager
+import com.v2ray.ang.util.Utils
+import rx.Observable
+import rx.android.schedulers.AndroidSchedulers
+import java.util.concurrent.TimeUnit
+
+class MainRecyclerAdapter(val activity: MainActivity) : RecyclerView.Adapter()
+ , ItemTouchHelperAdapter {
+ companion object {
+ private const val VIEW_TYPE_ITEM = 1
+ private const val VIEW_TYPE_FOOTER = 2
+ }
+
+ private var mActivity: MainActivity = activity
+ private val mainStorage by lazy { MMKV.mmkvWithID(MmkvManager.ID_MAIN, MMKV.MULTI_PROCESS_MODE) }
+ private val subStorage by lazy { MMKV.mmkvWithID(MmkvManager.ID_SUB, MMKV.MULTI_PROCESS_MODE) }
+ private val settingsStorage by lazy { MMKV.mmkvWithID(MmkvManager.ID_SETTING, MMKV.MULTI_PROCESS_MODE) }
+ private val share_method: Array by lazy {
+ mActivity.resources.getStringArray(R.array.share_method)
+ }
+ var isRunning = false
+
+ override fun getItemCount() = mActivity.mainViewModel.serversCache.size + 1
+
+ override fun onBindViewHolder(holder: BaseViewHolder, position: Int) {
+ if (holder is MainViewHolder) {
+ val guid = mActivity.mainViewModel.serversCache[position].guid
+ val config = mActivity.mainViewModel.serversCache[position].config
+// //filter
+// if (mActivity.mainViewModel.subscriptionId.isNotEmpty()
+// && mActivity.mainViewModel.subscriptionId != config.subscriptionId
+// ) {
+// holder.itemMainBinding.cardView.visibility = View.GONE
+// } else {
+// holder.itemMainBinding.cardView.visibility = View.VISIBLE
+// }
+
+ val outbound = config.getProxyOutbound()
+ val aff = MmkvManager.decodeServerAffiliationInfo(guid)
+
+ holder.itemMainBinding.tvName.text = config.remarks
+ holder.itemView.setBackgroundColor(Color.TRANSPARENT)
+ holder.itemMainBinding.tvTestResult.text = aff?.getTestDelayString() ?: ""
+ if ((aff?.testDelayMillis ?: 0L) < 0L) {
+ holder.itemMainBinding.tvTestResult.setTextColor(ContextCompat.getColor(mActivity, R.color.colorPingRed))
+ } else {
+ holder.itemMainBinding.tvTestResult.setTextColor(ContextCompat.getColor(mActivity, R.color.colorPing))
+ }
+ if (guid == mainStorage?.decodeString(MmkvManager.KEY_SELECTED_SERVER)) {
+ holder.itemMainBinding.layoutIndicator.setBackgroundResource(R.color.colorSelected)
+ } else {
+ holder.itemMainBinding.layoutIndicator.setBackgroundResource(R.color.colorUnselected)
+ }
+ holder.itemMainBinding.tvSubscription.text = ""
+ val json = subStorage?.decodeString(config.subscriptionId)
+ if (!json.isNullOrBlank()) {
+ val sub = Gson().fromJson(json, SubscriptionItem::class.java)
+ holder.itemMainBinding.tvSubscription.text = sub.remarks
+ }
+
+ var shareOptions = share_method.asList()
+ when (config.configType) {
+ EConfigType.CUSTOM -> {
+ holder.itemMainBinding.tvType.text = mActivity.getString(R.string.server_customize_config)
+ shareOptions = shareOptions.takeLast(1)
+ }
+ EConfigType.VLESS -> {
+ holder.itemMainBinding.tvType.text = config.configType.name
+ }
+ else -> {
+ holder.itemMainBinding.tvType.text = config.configType.name.lowercase()
+ }
+ }
+ holder.itemMainBinding.tvStatistics.text = "${outbound?.getServerAddress()} : ${outbound?.getServerPort()}"
+
+ holder.itemMainBinding.layoutShare.setOnClickListener {
+ AlertDialog.Builder(mActivity).setItems(shareOptions.toTypedArray()) { _, i ->
+ try {
+ when (i) {
+ 0 -> {
+ if (config.configType == EConfigType.CUSTOM) {
+ shareFullContent(guid)
+ } else {
+ val ivBinding = ItemQrcodeBinding.inflate(LayoutInflater.from(mActivity))
+ ivBinding.ivQcode.setImageBitmap(AngConfigManager.share2QRCode(guid))
+ AlertDialog.Builder(mActivity).setView(ivBinding.root).show()
+ }
+ }
+ 1 -> {
+ if (AngConfigManager.share2Clipboard(mActivity, guid) == 0) {
+ } else {
+ mActivity.toast(R.string.toast_failure)
+ }
+ }
+ 2 -> shareFullContent(guid)
+ else -> mActivity.toast("else")
+ }
+ } catch (e: Exception) {
+ e.printStackTrace()
+ }
+ }.show()
+ }
+
+ holder.itemMainBinding.layoutEdit.setOnClickListener {
+ val intent = Intent().putExtra("guid", guid)
+ .putExtra("isRunning", isRunning)
+ if (config.configType == EConfigType.CUSTOM) {
+ mActivity.startActivity(intent.setClass(mActivity, ServerCustomConfigActivity::class.java))
+ } else {
+ mActivity.startActivity(intent.setClass(mActivity, ServerActivity::class.java))
+ }
+ }
+ holder.itemMainBinding.layoutRemove.setOnClickListener {
+ if (guid != mainStorage?.decodeString(MmkvManager.KEY_SELECTED_SERVER)) {
+ if (settingsStorage?.decodeBool(AppConfig.PREF_CONFIRM_REMOVE) == true) {
+ AlertDialog.Builder(mActivity).setMessage(R.string.del_config_comfirm)
+ .setPositiveButton(android.R.string.ok) { _, _ ->
+ removeServer(guid, position)
+ mActivity.toast(R.string.toast_success)
+ }
+ .show()
+ } else {
+ removeServer(guid, position)
+ }
+ }
+ }
+
+ holder.itemMainBinding.infoContainer.setOnClickListener {
+ val selected = mainStorage?.decodeString(MmkvManager.KEY_SELECTED_SERVER)
+ if (guid != selected) {
+ mainStorage?.encode(MmkvManager.KEY_SELECTED_SERVER, guid)
+ if (!TextUtils.isEmpty(selected)) {
+ notifyItemChanged(mActivity.mainViewModel.getPosition(selected!!))
+ }
+ notifyItemChanged(mActivity.mainViewModel.getPosition(guid))
+ if (isRunning) {
+ mActivity.hideCircle()
+ Utils.stopVService(mActivity)
+ Observable.timer(500, TimeUnit.MILLISECONDS)
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe {
+ V2RayServiceManager.startV2Ray(mActivity)
+ mActivity.hideCircle()
+ }
+ }
+ }
+ }
+ }
+ if (holder is FooterViewHolder) {
+ //if (activity?.defaultDPreference?.getPrefBoolean(AppConfig.PREF_INAPP_BUY_IS_PREMIUM, false)) {
+ if (true) {
+ holder.itemFooterBinding.layoutEdit.visibility = View.INVISIBLE
+ } else {
+ holder.itemFooterBinding.layoutEdit.setOnClickListener {
+
+ }
+ }
+ }
+ }
+
+ private fun shareFullContent(guid: String) {
+ if (AngConfigManager.shareFullContent2Clipboard(mActivity, guid) == 0) {
+ } else {
+ mActivity.toast(R.string.toast_failure)
+ }
+ }
+
+ private fun removeServer(guid: String,position:Int) {
+ mActivity.mainViewModel.removeServer(guid)
+ notifyItemRemoved(position)
+ notifyItemRangeChanged(position, mActivity.mainViewModel.serversCache.size)
+ }
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BaseViewHolder {
+ return when (viewType) {
+ VIEW_TYPE_ITEM ->
+ MainViewHolder(ItemRecyclerMainBinding.inflate(LayoutInflater.from(parent.context), parent, false))
+ else ->
+ FooterViewHolder(ItemRecyclerFooterBinding.inflate(LayoutInflater.from(parent.context), parent, false))
+ }
+ }
+
+ override fun getItemViewType(position: Int): Int {
+ return if (position == mActivity.mainViewModel.serversCache.size) {
+ VIEW_TYPE_FOOTER
+ } else {
+ VIEW_TYPE_ITEM
+ }
+ }
+
+ open class BaseViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
+ fun onItemSelected() {
+ itemView.setBackgroundColor(Color.LTGRAY)
+ }
+
+ fun onItemClear() {
+ itemView.setBackgroundColor(0)
+ }
+ }
+
+ class MainViewHolder(val itemMainBinding: ItemRecyclerMainBinding) :
+ BaseViewHolder(itemMainBinding.root), ItemTouchHelperViewHolder
+
+ class FooterViewHolder(val itemFooterBinding: ItemRecyclerFooterBinding) :
+ BaseViewHolder(itemFooterBinding.root), ItemTouchHelperViewHolder
+
+ override fun onItemDismiss(position: Int) {
+ val guid = mActivity.mainViewModel.serversCache.getOrNull(position)?.guid ?: return
+ if (guid != mainStorage?.decodeString(MmkvManager.KEY_SELECTED_SERVER)) {
+// mActivity.alert(R.string.del_config_comfirm) {
+// positiveButton(android.R.string.ok) {
+ mActivity.mainViewModel.removeServer(guid)
+ notifyItemRemoved(position)
+ mActivity.toast(R.string.toast_success)
+// }
+// show()
+// }
+ }
+ }
+
+ override fun onItemMove(fromPosition: Int, toPosition: Int): Boolean {
+ mActivity.mainViewModel.swapServer(fromPosition, toPosition)
+ notifyItemMoved(fromPosition, toPosition)
+ // position is changed, since position is used by click callbacks, need to update range
+ if (toPosition > fromPosition)
+ notifyItemRangeChanged(fromPosition, toPosition - fromPosition + 1)
+ else
+ notifyItemRangeChanged(toPosition, fromPosition - toPosition + 1)
+ return true
+ }
+
+ override fun onItemMoveCompleted() {
+ // do nothing
+ }
+}
diff --git a/app/src/main/kotlin/com/v2ray/ang/ui/PerAppProxyActivity.kt b/app/src/main/kotlin/com/v2ray/ang/ui/PerAppProxyActivity.kt
new file mode 100644
index 0000000..eabed93
--- /dev/null
+++ b/app/src/main/kotlin/com/v2ray/ang/ui/PerAppProxyActivity.kt
@@ -0,0 +1,355 @@
+package com.v2ray.ang.ui
+
+import android.os.Bundle
+import android.text.TextUtils
+import android.util.Log
+import android.view.Menu
+import android.view.MenuItem
+import android.view.View
+import androidx.appcompat.widget.SearchView
+import androidx.lifecycle.lifecycleScope
+import androidx.preference.PreferenceManager
+import androidx.recyclerview.widget.DividerItemDecoration
+import androidx.recyclerview.widget.LinearLayoutManager
+import com.v2ray.ang.AppConfig
+import com.v2ray.ang.AppConfig.ANG_PACKAGE
+import com.v2ray.ang.R
+import com.v2ray.ang.databinding.ActivityBypassListBinding
+import com.v2ray.ang.dto.AppInfo
+import com.v2ray.ang.extension.toast
+import com.v2ray.ang.extension.v2RayApplication
+import com.v2ray.ang.util.AppManagerUtil
+import com.v2ray.ang.util.Utils
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.launch
+import rx.android.schedulers.AndroidSchedulers
+import rx.schedulers.Schedulers
+import java.text.Collator
+import java.util.*
+
+class PerAppProxyActivity : BaseActivity() {
+ private lateinit var binding: ActivityBypassListBinding
+
+ private var adapter: PerAppProxyAdapter? = null
+ private var appsAll: List? = null
+ private val defaultSharedPreferences by lazy { PreferenceManager.getDefaultSharedPreferences(this) }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ binding = ActivityBypassListBinding.inflate(layoutInflater)
+ val view = binding.root
+ setContentView(view)
+
+ supportActionBar?.setDisplayHomeAsUpEnabled(true)
+
+ val dividerItemDecoration = DividerItemDecoration(this, LinearLayoutManager.VERTICAL)
+ binding.recyclerView.addItemDecoration(dividerItemDecoration)
+
+ val blacklist = defaultSharedPreferences.getStringSet(AppConfig.PREF_PER_APP_PROXY_SET, null)
+
+ AppManagerUtil.rxLoadNetworkAppList(this)
+ .subscribeOn(Schedulers.io())
+ .map {
+ if (blacklist != null) {
+ it.forEach { one ->
+ if ((blacklist.contains(one.packageName))) {
+ one.isSelected = 1
+ } else {
+ one.isSelected = 0
+ }
+ }
+ val comparator = Comparator { p1, p2 ->
+ when {
+ p1.isSelected > p2.isSelected -> -1
+ p1.isSelected == p2.isSelected -> 0
+ else -> 1
+ }
+ }
+ it.sortedWith(comparator)
+ } else {
+ val comparator = object : Comparator {
+ val collator = Collator.getInstance()
+ override fun compare(o1: AppInfo, o2: AppInfo) = collator.compare(o1.appName, o2.appName)
+ }
+ it.sortedWith(comparator)
+ }
+ }
+// .map {
+// val comparator = object : Comparator {
+// val collator = Collator.getInstance()
+// override fun compare(o1: AppInfo, o2: AppInfo) = collator.compare(o1.appName, o2.appName)
+// }
+// it.sortedWith(comparator)
+// }
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe {
+ appsAll = it
+ adapter = PerAppProxyAdapter(this, it, blacklist)
+ binding.recyclerView.adapter = adapter
+ binding.pbWaiting.visibility = View.GONE
+ }
+ /***
+ recycler_view.addOnScrollListener(object : RecyclerView.OnScrollListener() {
+ var dst = 0
+ val threshold = resources.getDimensionPixelSize(R.dimen.bypass_list_header_height) * 2
+ override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
+ dst += dy
+ if (dst > threshold) {
+ header_view.hide()
+ dst = 0
+ } else if (dst < -20) {
+ header_view.show()
+ dst = 0
+ }
+ }
+
+ var hiding = false
+ fun View.hide() {
+ val target = -height.toFloat()
+ if (hiding || translationY == target) return
+ animate()
+ .translationY(target)
+ .setInterpolator(AccelerateInterpolator(2F))
+ .setListener(object : AnimatorListenerAdapter() {
+ override fun onAnimationEnd(animation: Animator?) {
+ hiding = false
+ }
+ })
+ hiding = true
+ }
+
+ var showing = false
+ fun View.show() {
+ val target = 0f
+ if (showing || translationY == target) return
+ animate()
+ .translationY(target)
+ .setInterpolator(DecelerateInterpolator(2F))
+ .setListener(object : AnimatorListenerAdapter() {
+ override fun onAnimationEnd(animation: Animator?) {
+ showing = false
+ }
+ })
+ showing = true
+ }
+ })
+ ***/
+
+ binding.switchPerAppProxy.setOnCheckedChangeListener { _, isChecked ->
+ defaultSharedPreferences.edit().putBoolean(AppConfig.PREF_PER_APP_PROXY, isChecked).apply()
+ }
+ binding.switchPerAppProxy.isChecked = defaultSharedPreferences.getBoolean(AppConfig.PREF_PER_APP_PROXY, false)
+
+ binding.switchBypassApps.setOnCheckedChangeListener { _, isChecked ->
+ defaultSharedPreferences.edit().putBoolean(AppConfig.PREF_BYPASS_APPS, isChecked).apply()
+ }
+ binding.switchBypassApps.isChecked = defaultSharedPreferences.getBoolean(AppConfig.PREF_BYPASS_APPS, false)
+
+ /***
+ et_search.setOnEditorActionListener { v, actionId, event ->
+ if (actionId == EditorInfo.IME_ACTION_SEARCH) {
+ //hide
+ var imm: InputMethodManager = v.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
+ imm.toggleSoftInput(0, InputMethodManager.HIDE_NOT_ALWAYS)
+
+ val key = v.text.toString().toUpperCase()
+ val apps = ArrayList()
+ if (TextUtils.isEmpty(key)) {
+ appsAll?.forEach {
+ apps.add(it)
+ }
+ } else {
+ appsAll?.forEach {
+ if (it.appName.toUpperCase().indexOf(key) >= 0) {
+ apps.add(it)
+ }
+ }
+ }
+ adapter = PerAppProxyAdapter(this, apps, adapter?.blacklist)
+ recycler_view.adapter = adapter
+ adapter?.notifyDataSetChanged()
+ true
+ } else {
+ false
+ }
+ }
+ ***/
+ }
+
+ override fun onPause() {
+ super.onPause()
+ adapter?.let {
+ defaultSharedPreferences.edit().putStringSet(AppConfig.PREF_PER_APP_PROXY_SET, it.blacklist).apply()
+ }
+ }
+
+ override fun onCreateOptionsMenu(menu: Menu): Boolean {
+ menuInflater.inflate(R.menu.menu_bypass_list, menu)
+
+ val searchItem = menu.findItem(R.id.search_view)
+ if (searchItem != null) {
+ val searchView = searchItem.actionView as SearchView
+ searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
+ override fun onQueryTextSubmit(query: String?): Boolean {
+ return false
+ }
+
+ override fun onQueryTextChange(newText: String?): Boolean {
+ filterProxyApp(newText!!)
+ return false
+ }
+ })
+ }
+
+
+ return super.onCreateOptionsMenu(menu)
+ }
+
+ override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
+ R.id.select_all -> adapter?.let {
+ val pkgNames = it.apps.map { it.packageName }
+ if (it.blacklist.containsAll(pkgNames)) {
+ it.apps.forEach {
+ val packageName = it.packageName
+ adapter?.blacklist!!.remove(packageName)
+ }
+ } else {
+ it.apps.forEach {
+ val packageName = it.packageName
+ adapter?.blacklist!!.add(packageName)
+ }
+ }
+ it.notifyDataSetChanged()
+ true
+ } ?: false
+ R.id.select_proxy_app -> {
+ selectProxyApp()
+ true
+ }
+ R.id.import_proxy_app -> {
+ importProxyApp()
+ true
+ }
+ R.id.export_proxy_app -> {
+ exportProxyApp()
+ true
+ }
+ else -> super.onOptionsItemSelected(item)
+ }
+
+ private fun selectProxyApp() {
+ toast(R.string.msg_downloading_content)
+ val url = AppConfig.androidpackagenamelistUrl
+ lifecycleScope.launch(Dispatchers.IO) {
+ val content = Utils.getUrlContext(url, 5000)
+ launch(Dispatchers.Main) {
+ Log.d(ANG_PACKAGE, content)
+ selectProxyApp(content, true)
+ toast(R.string.toast_success)
+ }
+ }
+ }
+
+ private fun importProxyApp() {
+ val content = Utils.getClipboard(applicationContext)
+ if (TextUtils.isEmpty(content)) {
+ return
+ }
+ selectProxyApp(content, false)
+ toast(R.string.toast_success)
+ }
+
+ private fun exportProxyApp() {
+ var lst = binding.switchBypassApps.isChecked.toString()
+
+ adapter?.blacklist?.forEach block@{
+ lst = lst + System.getProperty("line.separator") + it
+ }
+ Utils.setClipboard(applicationContext, lst)
+ toast(R.string.toast_success)
+ }
+
+ private fun selectProxyApp(content: String, force: Boolean): Boolean {
+ try {
+ val proxyApps = if (TextUtils.isEmpty(content)) {
+ Utils.readTextFromAssets(v2RayApplication, "proxy_packagename.txt")
+ } else {
+ content
+ }
+ if (TextUtils.isEmpty(proxyApps)) {
+ return false
+ }
+
+ adapter?.blacklist!!.clear()
+
+ if (binding.switchBypassApps.isChecked) {
+ adapter?.let {
+ it.apps.forEach block@{
+ val packageName = it.packageName
+ Log.d(ANG_PACKAGE, packageName)
+ if (!inProxyApps(proxyApps, packageName, force)) {
+ adapter?.blacklist!!.add(packageName)
+ println(packageName)
+ return@block
+ }
+ }
+ it.notifyDataSetChanged()
+ }
+ } else {
+ adapter?.let {
+ it.apps.forEach block@{
+ val packageName = it.packageName
+ Log.d(ANG_PACKAGE, packageName)
+ if (inProxyApps(proxyApps, packageName, force)) {
+ adapter?.blacklist!!.add(packageName)
+ println(packageName)
+ return@block
+ }
+ }
+ it.notifyDataSetChanged()
+ }
+ }
+ } catch (e: Exception) {
+ e.printStackTrace()
+ return false
+ }
+ return true
+ }
+
+ private fun inProxyApps(proxyApps: String, packageName: String, force: Boolean): Boolean {
+ if (force) {
+ if (packageName == "com.google.android.webview") {
+ return false
+ }
+ if (packageName.startsWith("com.google")) {
+ return true
+ }
+ }
+
+ return proxyApps.indexOf(packageName) >= 0
+ }
+
+ private fun filterProxyApp(content: String): Boolean {
+ val apps = ArrayList()
+
+ val key = content.uppercase()
+ if (key.isNotEmpty()) {
+ appsAll?.forEach {
+ if (it.appName.uppercase().indexOf(key) >= 0
+ || it.packageName.uppercase().indexOf(key) >= 0) {
+ apps.add(it)
+ }
+ }
+ } else {
+ appsAll?.forEach {
+ apps.add(it)
+ }
+ }
+
+ adapter = PerAppProxyAdapter(this, apps, adapter?.blacklist)
+ binding.recyclerView.adapter = adapter
+ adapter?.notifyDataSetChanged()
+ return true
+ }
+}
diff --git a/app/src/main/kotlin/com/v2ray/ang/ui/PerAppProxyAdapter.kt b/app/src/main/kotlin/com/v2ray/ang/ui/PerAppProxyAdapter.kt
new file mode 100644
index 0000000..040e9b9
--- /dev/null
+++ b/app/src/main/kotlin/com/v2ray/ang/ui/PerAppProxyAdapter.kt
@@ -0,0 +1,87 @@
+package com.v2ray.ang.ui
+
+import android.view.LayoutInflater
+import androidx.recyclerview.widget.RecyclerView
+import android.view.View
+import android.view.ViewGroup
+import com.v2ray.ang.R
+import com.v2ray.ang.databinding.ItemRecyclerBypassListBinding
+import com.v2ray.ang.dto.AppInfo
+import java.util.*
+
+class PerAppProxyAdapter(val activity: BaseActivity, val apps: List, blacklist: MutableSet?) :
+ RecyclerView.Adapter() {
+
+ companion object {
+ private const val VIEW_TYPE_HEADER = 0
+ private const val VIEW_TYPE_ITEM = 1
+ }
+
+ val blacklist = if (blacklist == null) HashSet() else HashSet(blacklist)
+
+ override fun onBindViewHolder(holder: BaseViewHolder, position: Int) {
+ if (holder is AppViewHolder) {
+ val appInfo = apps[position - 1]
+ holder.bind(appInfo)
+ }
+ }
+
+ override fun getItemCount() = apps.size + 1
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BaseViewHolder {
+ val ctx = parent.context
+
+ return when (viewType) {
+ VIEW_TYPE_HEADER -> {
+ val view = View(ctx)
+ view.layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
+ ctx.resources.getDimensionPixelSize(R.dimen.bypass_list_header_height) * 0)
+ BaseViewHolder(view)
+ }
+// VIEW_TYPE_ITEM -> AppViewHolder(ctx.layoutInflater
+// .inflate(R.layout.item_recycler_bypass_list, parent, false))
+
+ else -> AppViewHolder(ItemRecyclerBypassListBinding.inflate(LayoutInflater.from(ctx), parent, false))
+
+ }
+ }
+
+ override fun getItemViewType(position: Int) = if (position == 0) VIEW_TYPE_HEADER else VIEW_TYPE_ITEM
+
+ open class BaseViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView)
+
+ inner class AppViewHolder(private val itemBypassBinding: ItemRecyclerBypassListBinding) : BaseViewHolder(itemBypassBinding.root),
+ View.OnClickListener {
+ private val inBlacklist: Boolean get() = blacklist.contains(appInfo.packageName)
+ private lateinit var appInfo: AppInfo
+
+ fun bind(appInfo: AppInfo) {
+ this.appInfo = appInfo
+
+ itemBypassBinding.icon.setImageDrawable(appInfo.appIcon)
+// name.text = appInfo.appName
+
+ itemBypassBinding.checkBox.isChecked = inBlacklist
+ itemBypassBinding.packageName.text = appInfo.packageName
+ if (appInfo.isSystemApp) {
+ itemBypassBinding.name.text = String.format("** %1s", appInfo.appName)
+ //name.textColor = Color.RED
+ } else {
+ itemBypassBinding.name.text = appInfo.appName
+ //name.textColor = Color.DKGRAY
+ }
+
+ itemView.setOnClickListener(this)
+ }
+
+ override fun onClick(v: View?) {
+ if (inBlacklist) {
+ blacklist.remove(appInfo.packageName)
+ itemBypassBinding.checkBox.isChecked = false
+ } else {
+ blacklist.add(appInfo.packageName)
+ itemBypassBinding.checkBox.isChecked = true
+ }
+ }
+ }
+}
diff --git a/app/src/main/kotlin/com/v2ray/ang/ui/RoutingSettingsActivity.kt b/app/src/main/kotlin/com/v2ray/ang/ui/RoutingSettingsActivity.kt
new file mode 100644
index 0000000..030e80f
--- /dev/null
+++ b/app/src/main/kotlin/com/v2ray/ang/ui/RoutingSettingsActivity.kt
@@ -0,0 +1,38 @@
+package com.v2ray.ang.ui
+
+import android.os.Bundle
+import com.v2ray.ang.R
+import androidx.fragment.app.Fragment
+import com.google.android.material.tabs.TabLayoutMediator
+import com.v2ray.ang.AppConfig
+import com.v2ray.ang.databinding.ActivityRoutingSettingsBinding
+
+class RoutingSettingsActivity : BaseActivity() {
+ private lateinit var binding: ActivityRoutingSettingsBinding
+
+ private val titles: Array by lazy {
+ resources.getStringArray(R.array.routing_tag)
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ binding = ActivityRoutingSettingsBinding.inflate(layoutInflater)
+ val view = binding.root
+ setContentView(view)
+
+ title = getString(R.string.title_pref_routing_custom)
+ supportActionBar?.setDisplayHomeAsUpEnabled(true)
+
+ val fragments = ArrayList()
+ fragments.add(RoutingSettingsFragment().newInstance(AppConfig.PREF_V2RAY_ROUTING_AGENT))
+ fragments.add(RoutingSettingsFragment().newInstance(AppConfig.PREF_V2RAY_ROUTING_DIRECT))
+ fragments.add(RoutingSettingsFragment().newInstance(AppConfig.PREF_V2RAY_ROUTING_BLOCKED))
+
+ val adapter = FragmentAdapter(this, fragments)
+ binding.viewpager.adapter = adapter
+ //tablayout.setTabTextColors(Color.BLACK, Color.RED)
+ TabLayoutMediator(binding.tablayout, binding.viewpager) { tab, position ->
+ tab.text = titles[position]
+ }.attach()
+ }
+}
diff --git a/app/src/main/kotlin/com/v2ray/ang/ui/RoutingSettingsFragment.kt b/app/src/main/kotlin/com/v2ray/ang/ui/RoutingSettingsFragment.kt
new file mode 100644
index 0000000..f5a8e39
--- /dev/null
+++ b/app/src/main/kotlin/com/v2ray/ang/ui/RoutingSettingsFragment.kt
@@ -0,0 +1,158 @@
+package com.v2ray.ang.ui
+
+import android.Manifest
+import android.app.Activity.RESULT_OK
+import android.content.Intent
+import android.os.Bundle
+import android.text.TextUtils
+import android.view.*
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.fragment.app.Fragment
+import androidx.lifecycle.lifecycleScope
+import androidx.preference.PreferenceManager
+import com.tbruyelle.rxpermissions.RxPermissions
+import com.v2ray.ang.AppConfig
+import com.v2ray.ang.R
+import com.v2ray.ang.databinding.FragmentRoutingSettingsBinding
+import com.v2ray.ang.extension.toast
+import com.v2ray.ang.extension.v2RayApplication
+import com.v2ray.ang.util.Utils
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.launch
+
+class RoutingSettingsFragment : Fragment() {
+ private lateinit var binding: FragmentRoutingSettingsBinding
+ companion object {
+ private const val routing_arg = "routing_arg"
+ }
+
+ val defaultSharedPreferences by lazy { PreferenceManager.getDefaultSharedPreferences(requireContext()) }
+
+ override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
+ savedInstanceState: Bundle?): View? {
+ // Inflate the layout for this fragment
+ binding = FragmentRoutingSettingsBinding.inflate(layoutInflater)
+ return binding.root// inflater.inflate(R.layout.fragment_routing_settings, container, false)
+ }
+
+ fun newInstance(arg: String): Fragment {
+ val fragment = RoutingSettingsFragment()
+ val bundle = Bundle()
+ bundle.putString(routing_arg, arg)
+ fragment.arguments = bundle
+ return fragment
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ val content = defaultSharedPreferences.getString(requireArguments().getString(routing_arg), "")
+ binding.etRoutingContent.text = Utils.getEditable(content!!)
+
+ setHasOptionsMenu(true)
+ }
+
+ override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
+ inflater.inflate(R.menu.menu_routing, menu)
+ return super.onCreateOptionsMenu(menu, inflater)
+ }
+
+ override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
+ R.id.save_routing -> {
+ saveRouting()
+ true
+ }
+ R.id.del_routing -> {
+ binding.etRoutingContent.text = null
+ true
+ }
+ R.id.scan_replace -> {
+ scanQRcode(true)
+ true
+ }
+ R.id.scan_append -> {
+ scanQRcode(false)
+ true
+ }
+ R.id.default_rules -> {
+ setDefaultRules()
+ true
+ }
+ else -> super.onOptionsItemSelected(item)
+ }
+
+ private fun saveRouting() {
+ val content = binding.etRoutingContent.text.toString()
+ defaultSharedPreferences.edit().putString(requireArguments().getString(routing_arg), content).apply()
+ activity?.toast(R.string.toast_success)
+ }
+
+ fun scanQRcode(forReplace: Boolean): Boolean {
+// try {
+// startActivityForResult(Intent("com.google.zxing.client.android.SCAN")
+// .addCategory(Intent.CATEGORY_DEFAULT)
+// .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP), requestCode)
+// } catch (e: Exception) {
+ RxPermissions(requireActivity())
+ .request(Manifest.permission.CAMERA)
+ .subscribe {
+ if (it)
+ if (forReplace)
+ scanQRCodeForReplace.launch(Intent(activity, ScannerActivity::class.java))
+ else
+ scanQRCodeForAppend.launch(Intent(activity, ScannerActivity::class.java))
+ else
+ activity?.toast(R.string.toast_permission_denied)
+ }
+// }
+ return true
+ }
+
+ private val scanQRCodeForReplace = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
+ if (it.resultCode == RESULT_OK) {
+ val content = it.data?.getStringExtra("SCAN_RESULT")
+ binding.etRoutingContent.text = Utils.getEditable(content!!)
+ }
+ }
+
+ private val scanQRCodeForAppend = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
+ if (it.resultCode == RESULT_OK) {
+ val content = it.data?.getStringExtra("SCAN_RESULT")
+ binding.etRoutingContent.text = Utils.getEditable("${binding.etRoutingContent.text},$content")
+ }
+ }
+
+ fun setDefaultRules(): Boolean {
+ var url = AppConfig.v2rayCustomRoutingListUrl
+ var tag = ""
+ when (requireArguments().getString(routing_arg)) {
+ AppConfig.PREF_V2RAY_ROUTING_AGENT -> {
+ tag = AppConfig.TAG_AGENT
+ }
+ AppConfig.PREF_V2RAY_ROUTING_DIRECT -> {
+ tag = AppConfig.TAG_DIRECT
+ }
+ AppConfig.PREF_V2RAY_ROUTING_BLOCKED -> {
+ tag = AppConfig.TAG_BLOCKED
+ }
+ }
+ url += tag
+
+ activity?.toast(R.string.msg_downloading_content)
+ lifecycleScope.launch(Dispatchers.IO) {
+ val content = Utils.getUrlContext(url, 5000)
+ launch(Dispatchers.Main) {
+ val routingList = if (TextUtils.isEmpty(content)) {
+ Utils.readTextFromAssets(activity?.v2RayApplication!!, "custom_routing_$tag")
+ } else {
+ content
+ }
+ binding.etRoutingContent.text = Utils.getEditable(routingList)
+ saveRouting()
+ //toast(R.string.toast_success)
+ }
+ }
+ return true
+ }
+}
diff --git a/app/src/main/kotlin/com/v2ray/ang/ui/ScScannerActivity.kt b/app/src/main/kotlin/com/v2ray/ang/ui/ScScannerActivity.kt
new file mode 100644
index 0000000..da1cb7c
--- /dev/null
+++ b/app/src/main/kotlin/com/v2ray/ang/ui/ScScannerActivity.kt
@@ -0,0 +1,45 @@
+package com.v2ray.ang.ui
+
+import android.Manifest
+import android.content.*
+import com.tbruyelle.rxpermissions.RxPermissions
+import com.v2ray.ang.R
+import com.v2ray.ang.util.AngConfigManager
+import android.os.Bundle
+import androidx.activity.result.contract.ActivityResultContracts
+import com.v2ray.ang.extension.toast
+
+class ScScannerActivity : BaseActivity() {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(R.layout.activity_none)
+ importQRcode()
+ }
+
+ fun importQRcode(): Boolean {
+ RxPermissions(this)
+ .request(Manifest.permission.CAMERA)
+ .subscribe {
+ if (it)
+ scanQRCode.launch(Intent(this, ScannerActivity::class.java))
+ else
+ toast(R.string.toast_permission_denied)
+ }
+
+ return true
+ }
+
+ private val scanQRCode = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
+ if (it.resultCode == RESULT_OK) {
+ val count = AngConfigManager.importBatchConfig(it.data?.getStringExtra("SCAN_RESULT"), "", false)
+ if (count > 0) {
+ toast(R.string.toast_success)
+ } else {
+ toast(R.string.toast_failure)
+ }
+ startActivity(Intent(this, MainActivity::class.java))
+ }
+ finish()
+ }
+}
diff --git a/app/src/main/kotlin/com/v2ray/ang/ui/ScSwitchActivity.kt b/app/src/main/kotlin/com/v2ray/ang/ui/ScSwitchActivity.kt
new file mode 100644
index 0000000..3d3101e
--- /dev/null
+++ b/app/src/main/kotlin/com/v2ray/ang/ui/ScSwitchActivity.kt
@@ -0,0 +1,22 @@
+package com.v2ray.ang.ui
+
+import com.v2ray.ang.R
+import com.v2ray.ang.util.Utils
+import android.os.Bundle
+import com.v2ray.ang.service.V2RayServiceManager
+
+class ScSwitchActivity : BaseActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ moveTaskToBack(true)
+
+ setContentView(R.layout.activity_none)
+
+ if (V2RayServiceManager.v2rayPoint.isRunning) {
+ Utils.stopVService(this)
+ } else {
+ Utils.startVServiceFromToggle(this)
+ }
+ finish()
+ }
+}
diff --git a/app/src/main/kotlin/com/v2ray/ang/ui/ScannerActivity.kt b/app/src/main/kotlin/com/v2ray/ang/ui/ScannerActivity.kt
new file mode 100644
index 0000000..baff6a8
--- /dev/null
+++ b/app/src/main/kotlin/com/v2ray/ang/ui/ScannerActivity.kt
@@ -0,0 +1,132 @@
+package com.v2ray.ang.ui
+
+import android.Manifest
+import android.app.Activity
+import android.os.Bundle
+import android.content.Intent
+import android.graphics.BitmapFactory
+import android.os.Build
+import android.view.Menu
+import android.view.MenuItem
+import androidx.activity.result.contract.ActivityResultContracts
+import com.tbruyelle.rxpermissions.RxPermissions
+import com.tencent.mmkv.MMKV
+import com.v2ray.ang.AppConfig
+import com.v2ray.ang.R
+
+
+import com.v2ray.ang.extension.toast
+import com.v2ray.ang.util.MmkvManager
+import com.v2ray.ang.util.QRCodeDecoder
+import io.github.g00fy2.quickie.QRResult
+import io.github.g00fy2.quickie.ScanCustomCode
+import io.github.g00fy2.quickie.config.ScannerConfig
+
+class ScannerActivity : BaseActivity(){
+
+ private val scanQrCode = registerForActivityResult(ScanCustomCode(), ::handleResult)
+ private val settingsStorage by lazy { MMKV.mmkvWithID(MmkvManager.ID_SETTING, MMKV.MULTI_PROCESS_MODE) }
+
+ public override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ if (settingsStorage?.decodeBool(AppConfig.PREF_START_SCAN_IMMEDIATE) == true) {
+ launchScan()
+ }
+
+ supportActionBar?.setDisplayHomeAsUpEnabled(true)
+ }
+
+ public override fun onResume() {
+ super.onResume()
+ }
+
+ public override fun onPause() {
+ super.onPause()
+ }
+
+ private fun launchScan(){
+ scanQrCode.launch(
+ ScannerConfig.build {
+ setHapticSuccessFeedback(true) // enable (default) or disable haptic feedback when a barcode was detected
+ setShowTorchToggle(true) // show or hide (default) torch/flashlight toggle button
+ setShowCloseButton(true) // show or hide (default) close button
+ }
+ )
+ }
+
+ private fun handleResult(result: QRResult) {
+ if (result is QRResult.QRSuccess ) {
+ finished(result.content.rawValue)
+ } else {
+ finish()
+ }
+ }
+
+ private fun finished(text: String) {
+ val intent = Intent()
+ intent.putExtra("SCAN_RESULT", text)
+ setResult(Activity.RESULT_OK, intent)
+ finish()
+ }
+
+ override fun onCreateOptionsMenu(menu: Menu): Boolean {
+ menuInflater.inflate(R.menu.menu_scanner, menu)
+ return true
+ }
+
+ override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
+ R.id.scan_code -> {
+ launchScan()
+ true
+ }
+ R.id.select_photo -> {
+ val permission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ Manifest.permission.READ_MEDIA_IMAGES
+ } else {
+ Manifest.permission.READ_EXTERNAL_STORAGE
+ }
+ RxPermissions(this)
+ .request(permission)
+ .subscribe {
+ if (it) {
+ try {
+ showFileChooser()
+ } catch (e: Exception) {
+ e.printStackTrace()
+ }
+ } else
+ toast(R.string.toast_permission_denied)
+ }
+ true
+ }
+ else -> super.onOptionsItemSelected(item)
+ }
+
+ private fun showFileChooser() {
+ val intent = Intent(Intent.ACTION_GET_CONTENT)
+ intent.type = "image/*"
+ intent.addCategory(Intent.CATEGORY_OPENABLE)
+ //intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
+
+ try {
+ chooseFile.launch(Intent.createChooser(intent, getString(R.string.title_file_chooser)))
+ } catch (ex: android.content.ActivityNotFoundException) {
+ toast(R.string.toast_require_file_manager)
+ }
+ }
+
+ private val chooseFile = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
+ val uri = it.data?.data
+ if (it.resultCode == RESULT_OK && uri != null) {
+ try {
+ val bitmap = BitmapFactory.decodeStream(contentResolver.openInputStream(uri))
+ val text = QRCodeDecoder.syncDecodeQRCode(bitmap)
+ finished(text!!)
+ } catch (e: Exception) {
+ e.printStackTrace()
+ toast(e.message.toString())
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/v2ray/ang/ui/ServerActivity.kt b/app/src/main/kotlin/com/v2ray/ang/ui/ServerActivity.kt
new file mode 100644
index 0000000..6ab4f3e
--- /dev/null
+++ b/app/src/main/kotlin/com/v2ray/ang/ui/ServerActivity.kt
@@ -0,0 +1,491 @@
+package com.v2ray.ang.ui
+
+import android.os.Bundle
+import android.text.TextUtils
+import android.view.Menu
+import android.view.MenuItem
+import android.view.View
+import android.widget.*
+import androidx.appcompat.app.AlertDialog
+import com.tencent.mmkv.MMKV
+import com.v2ray.ang.AppConfig
+import com.v2ray.ang.AppConfig.PREF_ALLOW_INSECURE
+import com.v2ray.ang.R
+import com.v2ray.ang.dto.EConfigType
+import com.v2ray.ang.dto.ServerConfig
+import com.v2ray.ang.dto.V2rayConfig
+import com.v2ray.ang.dto.V2rayConfig.Companion.DEFAULT_PORT
+import com.v2ray.ang.dto.V2rayConfig.Companion.TLS
+import com.v2ray.ang.extension.toast
+import com.v2ray.ang.util.MmkvManager
+import com.v2ray.ang.util.MmkvManager.ID_MAIN
+import com.v2ray.ang.util.MmkvManager.KEY_SELECTED_SERVER
+import com.v2ray.ang.util.Utils
+
+class ServerActivity : BaseActivity() {
+
+ private val mainStorage by lazy { MMKV.mmkvWithID(ID_MAIN, MMKV.MULTI_PROCESS_MODE) }
+ private val settingsStorage by lazy { MMKV.mmkvWithID(MmkvManager.ID_SETTING, MMKV.MULTI_PROCESS_MODE) }
+ private val editGuid by lazy { intent.getStringExtra("guid").orEmpty() }
+ private val isRunning by lazy {
+ intent.getBooleanExtra("isRunning", false)
+ && editGuid.isNotEmpty()
+ && editGuid == mainStorage?.decodeString(KEY_SELECTED_SERVER)
+ }
+ private val createConfigType by lazy {
+ EConfigType.fromInt(intent.getIntExtra("createConfigType", EConfigType.VMESS.value)) ?: EConfigType.VMESS
+ }
+ private val subscriptionId by lazy {
+ intent.getStringExtra("subscriptionId")
+ }
+
+ private val securitys: Array by lazy {
+ resources.getStringArray(R.array.securitys)
+ }
+ private val shadowsocksSecuritys: Array by lazy {
+ resources.getStringArray(R.array.ss_securitys)
+ }
+ private val flows: Array by lazy {
+ resources.getStringArray(R.array.flows)
+ }
+ private val networks: Array by lazy {
+ resources.getStringArray(R.array.networks)
+ }
+ private val tcpTypes: Array by lazy {
+ resources.getStringArray(R.array.header_type_tcp)
+ }
+ private val kcpAndQuicTypes: Array by lazy {
+ resources.getStringArray(R.array.header_type_kcp_and_quic)
+ }
+ private val grpcModes: Array by lazy {
+ resources.getStringArray(R.array.mode_type_grpc)
+ }
+ private val streamSecuritys: Array by lazy {
+ resources.getStringArray(R.array.streamsecurityxs)
+ }
+ private val allowinsecures: Array by lazy {
+ resources.getStringArray(R.array.allowinsecures)
+ }
+ private val uTlsItems: Array by lazy {
+ resources.getStringArray(R.array.streamsecurity_utls)
+ }
+ private val alpns: Array by lazy {
+ resources.getStringArray(R.array.streamsecurity_alpn)
+ }
+ // Kotlin synthetics was used, but since it is removed in 1.8. We switch to old manual approach.
+ // We don't use AndroidViewBinding because, it is better to share similar logics for different
+ // protocols. Use findViewById manually ensures the xml are de-coupled with the activity logic.
+ private val et_remarks: EditText by lazy { findViewById(R.id.et_remarks) }
+ private val et_address: EditText by lazy { findViewById(R.id.et_address) }
+ private val et_port: EditText by lazy { findViewById(R.id.et_port) }
+ private val et_id: EditText by lazy { findViewById(R.id.et_id) }
+ private val et_alterId: EditText? by lazy { findViewById(R.id.et_alterId) }
+ private val et_security: EditText? by lazy { findViewById(R.id.et_security) }
+ private val sp_flow: Spinner? by lazy { findViewById(R.id.sp_flow) }
+ private val sp_security: Spinner? by lazy { findViewById(R.id.sp_security) }
+ private val sp_stream_security: Spinner? by lazy { findViewById(R.id.sp_stream_security) }
+ private val sp_allow_insecure: Spinner? by lazy { findViewById(R.id.sp_allow_insecure) }
+ private val container_allow_insecure: LinearLayout? by lazy { findViewById(R.id.l5) }
+ private val et_sni: EditText? by lazy { findViewById(R.id.et_sni) }
+ private val container_sni: LinearLayout? by lazy { findViewById(R.id.l2) }
+ private val sp_stream_fingerprint: Spinner? by lazy { findViewById(R.id.sp_stream_fingerprint) } //uTLS
+ private val container_fingerprint: LinearLayout? by lazy { findViewById(R.id.l3) }
+ private val sp_network: Spinner? by lazy { findViewById(R.id.sp_network) }
+ private val sp_header_type: Spinner? by lazy { findViewById(R.id.sp_header_type) }
+ private val sp_header_type_title: TextView? by lazy { findViewById(R.id.sp_header_type_title) }
+ private val et_request_host: EditText? by lazy { findViewById(R.id.et_request_host) }
+ private val et_path: EditText? by lazy { findViewById(R.id.et_path) }
+ private val sp_stream_alpn: Spinner? by lazy { findViewById(R.id.sp_stream_alpn) } //uTLS
+ private val container_alpn: LinearLayout? by lazy { findViewById(R.id.l4) }
+ private val et_public_key: EditText? by lazy { findViewById(R.id.et_public_key) }
+ private val container_public_key: LinearLayout? by lazy { findViewById(R.id.l6) }
+ private val et_short_id: EditText? by lazy { findViewById(R.id.et_short_id) }
+ private val container_short_id: LinearLayout? by lazy { findViewById(R.id.l7) }
+ private val et_spider_x: EditText? by lazy { findViewById(R.id.et_spider_x) }
+ private val container_spider_x: LinearLayout? by lazy { findViewById(R.id.l8) }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ title = getString(R.string.title_server)
+
+ val config = MmkvManager.decodeServerConfig(editGuid)
+ when(config?.configType ?: createConfigType) {
+ EConfigType.VMESS -> setContentView(R.layout.activity_server_vmess)
+ EConfigType.CUSTOM -> return
+ EConfigType.SHADOWSOCKS -> setContentView(R.layout.activity_server_shadowsocks)
+ EConfigType.SOCKS -> setContentView(R.layout.activity_server_socks)
+ EConfigType.VLESS -> setContentView(R.layout.activity_server_vless)
+ EConfigType.TROJAN -> setContentView(R.layout.activity_server_trojan)
+ else -> setContentView(R.layout.activity_server_vmess)
+ }
+ sp_network?.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
+ override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
+ val types = transportTypes(networks[position])
+ sp_header_type?.isEnabled = types.size > 1
+ val adapter = ArrayAdapter(this@ServerActivity, android.R.layout.simple_spinner_item, types)
+ adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
+ sp_header_type?.adapter = adapter
+ sp_header_type_title?.text = if (networks[position] == "grpc")
+ getString(R.string.server_lab_mode_type) else
+ getString(R.string.server_lab_head_type)
+ config?.getProxyOutbound()?.getTransportSettingDetails()?.let { transportDetails ->
+ sp_header_type?.setSelection(Utils.arrayFind(types, transportDetails[0]))
+ et_request_host?.text = Utils.getEditable(transportDetails[1])
+ et_path?.text = Utils.getEditable(transportDetails[2])
+ }
+ }
+ override fun onNothingSelected(parent: AdapterView<*>?) {
+ // do nothing
+ }
+ }
+ sp_stream_security?.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
+ override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
+ if (streamSecuritys[position].isBlank()) {
+ container_sni?.visibility = View.GONE
+ container_fingerprint?.visibility = View.GONE
+ container_alpn?.visibility = View.GONE
+ container_allow_insecure?.visibility = View.GONE
+ container_public_key?.visibility = View.GONE
+ container_short_id?.visibility = View.GONE
+ container_spider_x?.visibility = View.GONE
+ } else {
+ container_sni?.visibility = View.VISIBLE
+ container_fingerprint?.visibility = View.VISIBLE
+ container_alpn?.visibility = View.VISIBLE
+ if (streamSecuritys[position] == TLS) {
+ container_allow_insecure?.visibility = View.VISIBLE
+ container_public_key?.visibility = View.GONE
+ container_short_id?.visibility = View.GONE
+ container_spider_x?.visibility = View.GONE
+ } else {
+ container_allow_insecure?.visibility = View.GONE
+ container_alpn?.visibility = View.GONE
+ container_public_key?.visibility = View.VISIBLE
+ container_short_id?.visibility = View.VISIBLE
+ container_spider_x?.visibility = View.VISIBLE
+ }
+ }
+ }
+ override fun onNothingSelected(p0: AdapterView<*>?) {
+ // do nothing
+ }
+ }
+ if (config != null) {
+ bindingServer(config)
+ } else {
+ clearServer()
+ }
+ supportActionBar?.setDisplayHomeAsUpEnabled(true)
+ }
+
+ /**
+ * bingding seleced server config
+ */
+ private fun bindingServer(config: ServerConfig): Boolean {
+ val outbound = config.getProxyOutbound() ?: return false
+ val streamSetting = config.outboundBean?.streamSettings ?: return false
+
+ et_remarks.text = Utils.getEditable(config.remarks)
+ et_address.text = Utils.getEditable(outbound.getServerAddress().orEmpty())
+ et_port.text = Utils.getEditable(outbound.getServerPort()?.toString() ?: DEFAULT_PORT.toString())
+ et_id.text = Utils.getEditable(outbound.getPassword().orEmpty())
+ et_alterId?.text = Utils.getEditable(outbound.settings?.vnext?.get(0)?.users?.get(0)?.alterId.toString())
+ if (config.configType == EConfigType.SOCKS) {
+ et_security?.text = Utils.getEditable(outbound.settings?.servers?.get(0)?.users?.get(0)?.user.orEmpty())
+ } else if (config.configType == EConfigType.VLESS) {
+ et_security?.text = Utils.getEditable(outbound.getSecurityEncryption().orEmpty())
+ val flow = Utils.arrayFind(flows, outbound.settings?.vnext?.get(0)?.users?.get(0)?.flow.orEmpty())
+ if (flow >= 0) {
+ sp_flow?.setSelection(flow)
+ }
+ } else if (config.configType == EConfigType.TROJAN) {
+ val flow = Utils.arrayFind(flows, outbound.settings?.servers?.get(0)?.flow.orEmpty())
+ if (flow >= 0) {
+ sp_flow?.setSelection(flow)
+ }
+ }
+ val securityEncryptions = if (config.configType == EConfigType.SHADOWSOCKS) shadowsocksSecuritys else securitys
+ val security = Utils.arrayFind(securityEncryptions, outbound.getSecurityEncryption().orEmpty())
+ if (security >= 0) {
+ sp_security?.setSelection(security)
+ }
+
+ val streamSecurity = Utils.arrayFind(streamSecuritys, streamSetting.security)
+ if (streamSecurity >= 0) {
+ sp_stream_security?.setSelection(streamSecurity)
+ (streamSetting.tlsSettings?: streamSetting.realitySettings)?.let { tlsSetting ->
+ container_sni?.visibility = View.VISIBLE
+ container_fingerprint?.visibility = View.VISIBLE
+ container_alpn?.visibility = View.VISIBLE
+ et_sni?.text = Utils.getEditable(tlsSetting.serverName)
+ tlsSetting.fingerprint?.let {
+ val utlsIndex = Utils.arrayFind(uTlsItems, tlsSetting.fingerprint)
+ sp_stream_fingerprint?.setSelection(utlsIndex)
+ }
+ tlsSetting.alpn?.let {
+ val alpnIndex = Utils.arrayFind(alpns, Utils.removeWhiteSpace(tlsSetting.alpn.joinToString())!!)
+ sp_stream_alpn?.setSelection(alpnIndex)
+ }
+ if (streamSetting.tlsSettings != null) {
+ container_allow_insecure?.visibility = View.VISIBLE
+ val allowinsecure = Utils.arrayFind(allowinsecures, tlsSetting.allowInsecure.toString())
+ if (allowinsecure >= 0) {
+ sp_allow_insecure?.setSelection(allowinsecure)
+ }
+ container_public_key?.visibility = View.GONE
+ container_short_id?.visibility = View.GONE
+ container_spider_x?.visibility = View.GONE
+ } else { // reality settings
+ container_public_key?.visibility = View.VISIBLE
+ et_public_key?.text = Utils.getEditable(tlsSetting.publicKey.orEmpty())
+ container_short_id?.visibility = View.VISIBLE
+ et_short_id?.text = Utils.getEditable(tlsSetting.shortId.orEmpty())
+ container_spider_x?.visibility = View.VISIBLE
+ et_spider_x?.text = Utils.getEditable(tlsSetting.spiderX.orEmpty())
+ container_allow_insecure?.visibility = View.GONE
+ }
+ }
+ if (streamSetting.tlsSettings == null && streamSetting.realitySettings == null) {
+ container_sni?.visibility = View.GONE
+ container_fingerprint?.visibility = View.GONE
+ container_alpn?.visibility = View.GONE
+ container_allow_insecure?.visibility = View.GONE
+ container_public_key?.visibility = View.GONE
+ container_short_id?.visibility = View.GONE
+ container_spider_x?.visibility = View.GONE
+ }
+ }
+ val network = Utils.arrayFind(networks, streamSetting.network)
+ if (network >= 0) {
+ sp_network?.setSelection(network)
+ }
+ return true
+ }
+
+ /**
+ * clear or init server config
+ */
+ private fun clearServer(): Boolean {
+ et_remarks.text = null
+ et_address.text = null
+ et_port.text = Utils.getEditable(DEFAULT_PORT.toString())
+ et_id.text = null
+ et_alterId?.text = Utils.getEditable("0")
+ sp_security?.setSelection(0)
+ sp_network?.setSelection(0)
+
+ sp_header_type?.setSelection(0)
+ et_request_host?.text = null
+ et_path?.text = null
+ sp_stream_security?.setSelection(0)
+ sp_allow_insecure?.setSelection(0)
+ et_sni?.text = null
+
+ //et_security.text = null
+ sp_flow?.setSelection(0)
+ return true
+ }
+
+ /**
+ * save server config
+ */
+ private fun saveServer(): Boolean {
+ if (TextUtils.isEmpty(et_remarks.text.toString())) {
+ toast(R.string.server_lab_remarks)
+ return false
+ }
+ if (TextUtils.isEmpty(et_address.text.toString())) {
+ toast(R.string.server_lab_address)
+ return false
+ }
+ val port = Utils.parseInt(et_port.text.toString())
+ if (port <= 0) {
+ toast(R.string.server_lab_port)
+ return false
+ }
+ val config = MmkvManager.decodeServerConfig(editGuid) ?: ServerConfig.create(createConfigType)
+ if (config.configType != EConfigType.SOCKS && TextUtils.isEmpty(et_id.text.toString())) {
+ toast(R.string.server_lab_id)
+ return false
+ }
+ sp_stream_security?.let {
+ if (config.configType == EConfigType.TROJAN && TextUtils.isEmpty(streamSecuritys[it.selectedItemPosition])) {
+ toast(R.string.server_lab_stream_security)
+ return false
+ }
+ }
+ et_alterId?.let {
+ val alterId = Utils.parseInt(it.text.toString())
+ if (alterId < 0) {
+ toast(R.string.server_lab_alterid)
+ return false
+ }
+ }
+
+ config.remarks = et_remarks.text.toString().trim()
+ config.outboundBean?.settings?.vnext?.get(0)?.let { vnext ->
+ saveVnext(vnext, port, config)
+ }
+ config.outboundBean?.settings?.servers?.get(0)?.let { server ->
+ saveServers(server, port, config)
+ }
+ config.outboundBean?.streamSettings?.let {
+ saveStreamSettings(it)
+ }
+ if(config.subscriptionId.isEmpty() && !subscriptionId.isNullOrEmpty()) {
+ config.subscriptionId = subscriptionId!!
+ }
+
+ MmkvManager.encodeServerConfig(editGuid, config)
+ toast(R.string.toast_success)
+ finish()
+ return true
+ }
+
+ private fun saveVnext(vnext: V2rayConfig.OutboundBean.OutSettingsBean.VnextBean, port: Int, config: ServerConfig) {
+ vnext.address = et_address.text.toString().trim()
+ vnext.port = port
+ vnext.users[0].id = et_id.text.toString().trim()
+ if (config.configType == EConfigType.VMESS) {
+ vnext.users[0].alterId = Utils.parseInt(et_alterId?.text.toString())
+ vnext.users[0].security = securitys[sp_security?.selectedItemPosition ?: 0]
+ } else if (config.configType == EConfigType.VLESS) {
+ vnext.users[0].encryption = et_security?.text.toString().trim()
+ vnext.users[0].flow = flows[sp_flow?.selectedItemPosition ?: 0]
+ }
+ }
+
+ private fun saveServers(server: V2rayConfig.OutboundBean.OutSettingsBean.ServersBean, port: Int, config: ServerConfig) {
+ server.address = et_address.text.toString().trim()
+ server.port = port
+ if (config.configType == EConfigType.SHADOWSOCKS) {
+ server.password = et_id.text.toString().trim()
+ server.method = shadowsocksSecuritys[sp_security?.selectedItemPosition ?: 0]
+ } else if (config.configType == EConfigType.SOCKS) {
+ if (TextUtils.isEmpty(et_security?.text) && TextUtils.isEmpty(et_id.text)) {
+ server.users = null
+ } else {
+ val socksUsersBean = V2rayConfig.OutboundBean.OutSettingsBean.ServersBean.SocksUsersBean()
+ socksUsersBean.user = et_security?.text.toString().trim()
+ socksUsersBean.pass = et_id.text.toString().trim()
+ server.users = listOf(socksUsersBean)
+ }
+ } else if (config.configType == EConfigType.TROJAN) {
+ server.password = et_id.text.toString().trim()
+ }
+ }
+
+ private fun saveStreamSettings(streamSetting: V2rayConfig.OutboundBean.StreamSettingsBean) {
+ val network = sp_network?.selectedItemPosition ?: return
+ val type = sp_header_type?.selectedItemPosition ?: return
+ val requestHost = et_request_host?.text?.toString()?.trim() ?: return
+ val path = et_path?.text?.toString()?.trim() ?: return
+ val sniField = et_sni?.text?.toString()?.trim() ?: return
+ val allowInsecureField = sp_allow_insecure?.selectedItemPosition ?: return
+ val streamSecurity = sp_stream_security?.selectedItemPosition ?: return
+ val utlsIndex = sp_stream_fingerprint?.selectedItemPosition ?: return
+ val alpnIndex = sp_stream_alpn?.selectedItemPosition ?: return
+ val publicKey = et_public_key?.text?.toString()?.trim() ?: return
+ val shortId = et_short_id?.text?.toString()?.trim() ?: return
+ val spiderX = et_spider_x?.text?.toString()?.trim() ?: return
+
+ var sni = streamSetting.populateTransportSettings(
+ transport = networks[network],
+ headerType = transportTypes(networks[network])[type],
+ host = requestHost,
+ path = path,
+ seed = path,
+ quicSecurity = requestHost,
+ key = path,
+ mode = transportTypes(networks[network])[type],
+ serviceName = path
+ )
+ if (sniField.isNotBlank()) {
+ sni = sniField
+ }
+ val allowInsecure = if (allowinsecures[allowInsecureField].isBlank()) {
+ settingsStorage?.decodeBool(PREF_ALLOW_INSECURE) ?: false
+ } else {
+ allowinsecures[allowInsecureField].toBoolean()
+ }
+
+ streamSetting.populateTlsSettings(
+ streamSecurity = streamSecuritys[streamSecurity],
+ allowInsecure = allowInsecure,
+ sni = sni,
+ fingerprint = uTlsItems[utlsIndex],
+ alpns = alpns[alpnIndex],
+ publicKey = publicKey,
+ shortId = shortId,
+ spiderX = spiderX
+ )
+ }
+
+ private fun transportTypes(network: String?): Array {
+ return when (network) {
+ "tcp" -> {
+ tcpTypes
+ }
+ "kcp", "quic" -> {
+ kcpAndQuicTypes
+ }
+ "grpc" -> {
+ grpcModes
+ }
+ else -> {
+ arrayOf("---")
+ }
+ }
+ }
+
+ /**
+ * save server config
+ */
+ private fun deleteServer(): Boolean {
+ if (editGuid.isNotEmpty()) {
+ if (editGuid != mainStorage?.decodeString(KEY_SELECTED_SERVER)) {
+ if (settingsStorage?.decodeBool(AppConfig.PREF_CONFIRM_REMOVE) == true) {
+ AlertDialog.Builder(this).setMessage(R.string.del_config_comfirm)
+ .setPositiveButton(android.R.string.ok) { _, _ ->
+ MmkvManager.removeServer(editGuid)
+ finish()
+ }
+ .show()
+ } else {
+ MmkvManager.removeServer(editGuid)
+ finish()
+ }
+ }
+ }
+ return true
+ }
+
+ override fun onCreateOptionsMenu(menu: Menu): Boolean {
+ menuInflater.inflate(R.menu.action_server, menu)
+ val delButton = menu.findItem(R.id.del_config)
+ val saveButton = menu.findItem(R.id.save_config)
+
+ if (editGuid.isNotEmpty()) {
+ if (isRunning) {
+ delButton?.isVisible = false
+ saveButton?.isVisible = false
+ }
+ } else {
+ delButton?.isVisible = false
+ }
+
+ return super.onCreateOptionsMenu(menu)
+ }
+
+ override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
+ R.id.del_config -> {
+ deleteServer()
+ true
+ }
+ R.id.save_config -> {
+ saveServer()
+ true
+ }
+ else -> super.onOptionsItemSelected(item)
+ }
+}
diff --git a/app/src/main/kotlin/com/v2ray/ang/ui/ServerCustomConfigActivity.kt b/app/src/main/kotlin/com/v2ray/ang/ui/ServerCustomConfigActivity.kt
new file mode 100644
index 0000000..3d61b6d
--- /dev/null
+++ b/app/src/main/kotlin/com/v2ray/ang/ui/ServerCustomConfigActivity.kt
@@ -0,0 +1,146 @@
+package com.v2ray.ang.ui
+
+import android.os.Bundle
+import android.text.TextUtils
+import android.view.Menu
+import android.view.MenuItem
+import android.widget.Toast
+import com.blacksquircle.ui.editorkit.utils.EditorTheme
+import androidx.appcompat.app.AlertDialog
+import com.blacksquircle.ui.language.json.JsonLanguage
+import com.google.gson.*
+import com.tencent.mmkv.MMKV
+import com.v2ray.ang.R
+import com.v2ray.ang.databinding.ActivityServerCustomConfigBinding
+import com.v2ray.ang.dto.EConfigType
+import com.v2ray.ang.dto.ServerConfig
+import com.v2ray.ang.dto.V2rayConfig
+import com.v2ray.ang.extension.toast
+import com.v2ray.ang.util.MmkvManager
+import com.v2ray.ang.util.Utils
+import me.drakeet.support.toast.ToastCompat
+
+class ServerCustomConfigActivity : BaseActivity() {
+ private lateinit var binding: ActivityServerCustomConfigBinding
+
+ private val mainStorage by lazy { MMKV.mmkvWithID(MmkvManager.ID_MAIN, MMKV.MULTI_PROCESS_MODE) }
+ private val serverRawStorage by lazy { MMKV.mmkvWithID(MmkvManager.ID_SERVER_RAW, MMKV.MULTI_PROCESS_MODE) }
+ private val editGuid by lazy { intent.getStringExtra("guid").orEmpty() }
+ private val isRunning by lazy {
+ intent.getBooleanExtra("isRunning", false)
+ && editGuid.isNotEmpty()
+ && editGuid == mainStorage?.decodeString(MmkvManager.KEY_SELECTED_SERVER)
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ binding = ActivityServerCustomConfigBinding.inflate(layoutInflater)
+ val view = binding.root
+ setContentView(view)
+ title = getString(R.string.title_server)
+
+ binding.editor.colorScheme = EditorTheme.INTELLIJ_LIGHT
+ binding.editor.language = JsonLanguage()
+ val config = MmkvManager.decodeServerConfig(editGuid)
+ if (config != null) {
+ bindingServer(config)
+ } else {
+ clearServer()
+ }
+ supportActionBar?.setDisplayHomeAsUpEnabled(true)
+ }
+
+ /**
+ * bingding seleced server config
+ */
+ private fun bindingServer(config: ServerConfig): Boolean {
+ binding.etRemarks.text = Utils.getEditable(config.remarks)
+ val raw = serverRawStorage?.decodeString(editGuid)
+ if (raw.isNullOrBlank()) {
+ binding.editor.setTextContent(Utils.getEditable(config.fullConfig?.toPrettyPrinting().orEmpty()))
+ } else {
+ binding.editor.setTextContent(Utils.getEditable(raw))
+ }
+ return true
+ }
+
+ /**
+ * clear or init server config
+ */
+ private fun clearServer(): Boolean {
+ binding.etRemarks.text = null
+ return true
+ }
+
+ /**
+ * save server config
+ */
+ private fun saveServer(): Boolean {
+ if (TextUtils.isEmpty(binding.etRemarks.text.toString())) {
+ toast(R.string.server_lab_remarks)
+ return false
+ }
+
+ val v2rayConfig = try {
+ Gson().fromJson(binding.editor.text.toString(), V2rayConfig::class.java)
+ } catch (e: Exception) {
+ e.printStackTrace()
+ ToastCompat.makeText(this, "${getString(R.string.toast_malformed_josn)} ${e.cause?.message}", Toast.LENGTH_LONG).show()
+ return false
+ }
+
+ val config = MmkvManager.decodeServerConfig(editGuid) ?: ServerConfig.create(EConfigType.CUSTOM)
+ config.remarks = binding.etRemarks.text.toString().trim()
+ config.fullConfig = v2rayConfig
+
+ MmkvManager.encodeServerConfig(editGuid, config)
+ serverRawStorage?.encode(editGuid, binding.editor.text.toString())
+ toast(R.string.toast_success)
+ finish()
+ return true
+ }
+
+ /**
+ * save server config
+ */
+ private fun deleteServer(): Boolean {
+ if (editGuid.isNotEmpty()) {
+ AlertDialog.Builder(this).setMessage(R.string.del_config_comfirm)
+ .setPositiveButton(android.R.string.ok) { _, _ ->
+ MmkvManager.removeServer(editGuid)
+ finish()
+ }
+ .show()
+ }
+ return true
+ }
+
+ override fun onCreateOptionsMenu(menu: Menu): Boolean {
+ menuInflater.inflate(R.menu.action_server, menu)
+ val delButton = menu.findItem(R.id.del_config)
+ val saveButton = menu.findItem(R.id.save_config)
+
+ if (editGuid.isNotEmpty()) {
+ if (isRunning) {
+ delButton?.isVisible = false
+ saveButton?.isVisible = false
+ }
+ } else {
+ delButton?.isVisible = false
+ }
+
+ return super.onCreateOptionsMenu(menu)
+ }
+
+ override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
+ R.id.del_config -> {
+ deleteServer()
+ true
+ }
+ R.id.save_config -> {
+ saveServer()
+ true
+ }
+ else -> super.onOptionsItemSelected(item)
+ }
+}
diff --git a/app/src/main/kotlin/com/v2ray/ang/ui/SettingsActivity.kt b/app/src/main/kotlin/com/v2ray/ang/ui/SettingsActivity.kt
new file mode 100644
index 0000000..b76522f
--- /dev/null
+++ b/app/src/main/kotlin/com/v2ray/ang/ui/SettingsActivity.kt
@@ -0,0 +1,250 @@
+package com.v2ray.ang.ui
+
+import android.content.Intent
+import android.os.Bundle
+import android.text.TextUtils
+import android.view.View
+import androidx.activity.viewModels
+import androidx.preference.*
+import androidx.work.Constraints
+import androidx.work.ExistingPeriodicWorkPolicy
+import androidx.work.NetworkType
+import androidx.work.PeriodicWorkRequest
+import androidx.work.multiprocess.RemoteWorkManager
+import com.v2ray.ang.AngApplication
+import com.v2ray.ang.AppConfig
+import com.v2ray.ang.R
+import com.v2ray.ang.dto.AngConfig
+import com.v2ray.ang.service.SubscriptionUpdater
+import com.v2ray.ang.util.Utils
+import com.v2ray.ang.viewmodel.SettingsViewModel
+import java.sql.Time
+import java.util.concurrent.TimeUnit
+import kotlin.time.toDuration
+
+class SettingsActivity : BaseActivity() {
+ private val settingsViewModel: SettingsViewModel by viewModels()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(R.layout.activity_settings)
+
+ title = getString(R.string.title_settings)
+
+ supportActionBar?.setDisplayHomeAsUpEnabled(true)
+
+ settingsViewModel.startListenPreferenceChange()
+ }
+
+ class SettingsFragment : PreferenceFragmentCompat() {
+ private val perAppProxy by lazy { findPreference(AppConfig.PREF_PER_APP_PROXY) }
+ private val localDns by lazy { findPreference(AppConfig.PREF_LOCAL_DNS_ENABLED) }
+ private val fakeDns by lazy { findPreference(AppConfig.PREF_FAKE_DNS_ENABLED) }
+ private val localDnsPort by lazy { findPreference(AppConfig.PREF_LOCAL_DNS_PORT) }
+ private val vpnDns by lazy { findPreference(AppConfig.PREF_VPN_DNS) }
+ private val remoteDns by lazy { findPreference(AppConfig.PREF_REMOTE_DNS) }
+ private val domesticDns by lazy { findPreference(AppConfig.PREF_DOMESTIC_DNS) }
+ private val socksPort by lazy { findPreference(AppConfig.PREF_SOCKS_PORT) }
+ private val httpPort by lazy { findPreference(AppConfig.PREF_HTTP_PORT) }
+ private val routingCustom by lazy { findPreference(AppConfig.PREF_ROUTING_CUSTOM) }
+ private val autoUpdateCheck by lazy { findPreference(AppConfig.SUBSCRIPTION_AUTO_UPDATE) }
+ private val autoUpdateInterval by lazy { findPreference(AppConfig.SUBSCRIPTION_AUTO_UPDATE_INTERVAL) }
+ private val languageswitch by lazy { findPreference(AppConfig.PREF_LANGUAGE) }
+ private val mode by lazy { findPreference(AppConfig.PREF_MODE) }
+ private val muxConcurrency by lazy { findPreference(AppConfig.PREF_MUX_CONCURRENCY) }
+
+ override fun onCreatePreferences(bundle: Bundle?, s: String?) {
+ addPreferencesFromResource(R.xml.pref_settings)
+
+ routingCustom?.setOnPreferenceClickListener {
+ startActivity(Intent(activity, RoutingSettingsActivity::class.java))
+ false
+ }
+
+ autoUpdateCheck?.setOnPreferenceChangeListener { _, newValue ->
+ val value = newValue as Boolean
+ autoUpdateCheck?.isChecked = value
+ autoUpdateInterval?.isEnabled = value
+ autoUpdateInterval?.text?.toLong()?.let {
+ if (newValue) configureUpdateTask(it) else cancelUpdateTask()
+ }
+ true
+ }
+
+ autoUpdateInterval?.setOnPreferenceChangeListener { _, any ->
+ val nval = any as String
+ autoUpdateInterval?.summary =
+ if (TextUtils.isEmpty(nval) or (nval.toLong() < 15)) AppConfig.DEFAULT_UPDATE_INTERVAL else nval
+ configureUpdateTask(nval.toLong())
+ false
+ }
+
+ perAppProxy?.setOnPreferenceClickListener {
+ startActivity(Intent(activity, PerAppProxyActivity::class.java))
+ perAppProxy?.isChecked = true
+ false
+ }
+
+ remoteDns?.setOnPreferenceChangeListener { _, any ->
+ // remoteDns.summary = any as String
+ val nval = any as String
+ remoteDns?.summary = if (nval == "") AppConfig.DNS_AGENT else nval
+ true
+ }
+ domesticDns?.setOnPreferenceChangeListener { _, any ->
+ // domesticDns.summary = any as String
+ val nval = any as String
+ domesticDns?.summary = if (nval == "") AppConfig.DNS_DIRECT else nval
+ true
+ }
+
+ muxConcurrency?.setOnPreferenceChangeListener { _, any ->
+ val nval = any as String
+ muxConcurrency?.summary =
+ if (TextUtils.isEmpty(nval)) AppConfig.MUX_CONCURRENCY else nval
+ true
+ }
+ localDns?.setOnPreferenceChangeListener { _, any ->
+ updateLocalDns(any as Boolean)
+ true
+ }
+ localDnsPort?.setOnPreferenceChangeListener { _, any ->
+ val nval = any as String
+ localDnsPort?.summary =
+ if (TextUtils.isEmpty(nval)) AppConfig.PORT_LOCAL_DNS else nval
+ true
+ }
+ vpnDns?.setOnPreferenceChangeListener { _, any ->
+ vpnDns?.summary = any as String
+ true
+ }
+ socksPort?.setOnPreferenceChangeListener { _, any ->
+ if (TextUtils.isEmpty(muxConcurrency?.summary)) {
+ muxConcurrency?.summary = AppConfig.MUX_CONCURRENCY
+ }
+ val nval = any as String
+ socksPort?.summary = if (TextUtils.isEmpty(nval)) AppConfig.PORT_SOCKS else nval
+ true
+ }
+ httpPort?.setOnPreferenceChangeListener { _, any ->
+ val nval = any as String
+ httpPort?.summary = if (TextUtils.isEmpty(nval)) AppConfig.PORT_HTTP else nval
+ true
+ }
+ mode?.setOnPreferenceChangeListener { _, newValue ->
+ updateMode(newValue.toString())
+ true
+ }
+ //loglevel.summary = "LogLevel"
+ }
+
+ override fun onStart() {
+ super.onStart()
+ val defaultSharedPreferences =
+ PreferenceManager.getDefaultSharedPreferences(requireActivity())
+ updateMode(defaultSharedPreferences.getString(AppConfig.PREF_MODE, "VPN"))
+ var remoteDnsString = defaultSharedPreferences.getString(AppConfig.PREF_REMOTE_DNS, "")
+ domesticDns?.summary =
+ defaultSharedPreferences.getString(AppConfig.PREF_DOMESTIC_DNS, "")
+
+ localDnsPort?.summary = defaultSharedPreferences.getString(
+ AppConfig.PREF_LOCAL_DNS_PORT,
+ AppConfig.PORT_LOCAL_DNS
+ )
+ socksPort?.summary =
+ defaultSharedPreferences.getString(AppConfig.PREF_SOCKS_PORT, AppConfig.PORT_SOCKS)
+ httpPort?.summary =
+ defaultSharedPreferences.getString(AppConfig.PREF_HTTP_PORT, AppConfig.PORT_HTTP)
+ localDnsPort?.summary = defaultSharedPreferences.getString(
+ AppConfig.PREF_LOCAL_DNS_PORT,
+ AppConfig.PORT_LOCAL_DNS
+ )
+ socksPort?.summary =
+ defaultSharedPreferences.getString(AppConfig.PREF_SOCKS_PORT, AppConfig.PORT_SOCKS)
+ httpPort?.summary =
+ defaultSharedPreferences.getString(AppConfig.PREF_HTTP_PORT, AppConfig.PORT_HTTP)
+ autoUpdateInterval?.summary = defaultSharedPreferences.getString(
+ AppConfig.SUBSCRIPTION_AUTO_UPDATE_INTERVAL,
+ AppConfig.DEFAULT_UPDATE_INTERVAL
+ )
+
+
+ if (TextUtils.isEmpty(remoteDnsString)) {
+ remoteDnsString = AppConfig.DNS_AGENT
+ }
+ if (TextUtils.isEmpty(domesticDns?.summary)) {
+ domesticDns?.summary = AppConfig.DNS_DIRECT
+ }
+ remoteDns?.summary = remoteDnsString
+ vpnDns?.summary =
+ defaultSharedPreferences.getString(AppConfig.PREF_VPN_DNS, remoteDnsString)
+
+ if (TextUtils.isEmpty(localDnsPort?.summary)) {
+ localDnsPort?.summary = AppConfig.PORT_LOCAL_DNS
+ }
+ if (TextUtils.isEmpty(socksPort?.summary)) {
+ socksPort?.summary = AppConfig.PORT_SOCKS
+ }
+ if (TextUtils.isEmpty(httpPort?.summary)) {
+ httpPort?.summary = AppConfig.PORT_HTTP
+ }
+ }
+
+ private fun updateMode(mode: String?) {
+ val defaultSharedPreferences =
+ PreferenceManager.getDefaultSharedPreferences(requireActivity())
+ val vpn = mode == "VPN"
+ perAppProxy?.isEnabled = vpn
+ perAppProxy?.isChecked =
+ PreferenceManager.getDefaultSharedPreferences(requireActivity())
+ .getBoolean(AppConfig.PREF_PER_APP_PROXY, false)
+ localDns?.isEnabled = vpn
+ fakeDns?.isEnabled = vpn
+ localDnsPort?.isEnabled = vpn
+ vpnDns?.isEnabled = vpn
+ if (vpn) {
+ updateLocalDns(
+ defaultSharedPreferences.getBoolean(
+ AppConfig.PREF_LOCAL_DNS_ENABLED,
+ false
+ )
+ )
+ }
+ }
+
+ private fun updateLocalDns(enabled: Boolean) {
+ fakeDns?.isEnabled = enabled
+ localDnsPort?.isEnabled = enabled
+ vpnDns?.isEnabled = !enabled
+ }
+
+ private fun configureUpdateTask(interval: Long) {
+ val rw = RemoteWorkManager.getInstance(AngApplication.application)
+ rw.cancelUniqueWork(AppConfig.UPDATE_TASK_NAME)
+ rw.enqueueUniquePeriodicWork(
+ AppConfig.UPDATE_TASK_NAME,
+ ExistingPeriodicWorkPolicy.UPDATE,
+ PeriodicWorkRequest.Builder(
+ SubscriptionUpdater.UpdateTask::class.java,
+ interval,
+ TimeUnit.MINUTES
+ )
+ .apply {
+ setInitialDelay(interval, TimeUnit.MINUTES)
+ }
+ .setConstraints(
+ Constraints(
+ NetworkType.CONNECTED,
+ )
+ ).build()
+ )
+ }
+
+ private fun cancelUpdateTask() {
+ val rw = RemoteWorkManager.getInstance(AngApplication.application)
+ rw.cancelUniqueWork(AppConfig.UPDATE_TASK_NAME)
+ }
+ }
+}
+ private fun languageswitch() {
+ }
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/v2ray/ang/ui/SubEditActivity.kt b/app/src/main/kotlin/com/v2ray/ang/ui/SubEditActivity.kt
new file mode 100644
index 0000000..f5a3eda
--- /dev/null
+++ b/app/src/main/kotlin/com/v2ray/ang/ui/SubEditActivity.kt
@@ -0,0 +1,144 @@
+package com.v2ray.ang.ui
+
+import android.os.Bundle
+import android.text.TextUtils
+import android.view.Menu
+import android.view.MenuItem
+import androidx.appcompat.app.AlertDialog
+import androidx.work.Constraints
+import androidx.work.ExistingPeriodicWorkPolicy
+import androidx.work.NetworkType
+import androidx.work.PeriodicWorkRequest
+import androidx.work.multiprocess.RemoteWorkManager
+import com.google.gson.Gson
+import com.tencent.mmkv.MMKV
+import com.v2ray.ang.AngApplication
+import com.v2ray.ang.R
+import com.v2ray.ang.databinding.ActivitySubEditBinding
+import com.v2ray.ang.dto.SubscriptionItem
+import com.v2ray.ang.extension.toast
+import com.v2ray.ang.service.SubscriptionUpdater
+import com.v2ray.ang.util.MmkvManager
+import com.v2ray.ang.util.Utils
+import java.util.concurrent.TimeUnit
+
+class SubEditActivity : BaseActivity() {
+ private lateinit var binding: ActivitySubEditBinding
+
+ var del_config: MenuItem? = null
+ var save_config: MenuItem? = null
+
+ private val subStorage by lazy { MMKV.mmkvWithID(MmkvManager.ID_SUB, MMKV.MULTI_PROCESS_MODE) }
+ private val editSubId by lazy { intent.getStringExtra("subId").orEmpty() }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ binding = ActivitySubEditBinding.inflate(layoutInflater)
+ val view = binding.root
+ setContentView(view)
+ title = getString(R.string.title_sub_setting)
+
+ val json = subStorage?.decodeString(editSubId)
+ if (!json.isNullOrBlank()) {
+ bindingServer(Gson().fromJson(json, SubscriptionItem::class.java))
+ } else {
+ clearServer()
+ }
+ supportActionBar?.setDisplayHomeAsUpEnabled(true)
+ }
+
+ /**
+ * bingding seleced server config
+ */
+ private fun bindingServer(subItem: SubscriptionItem): Boolean {
+ binding.etRemarks.text = Utils.getEditable(subItem.remarks)
+ binding.etUrl.text = Utils.getEditable(subItem.url)
+ binding.chkEnable.isChecked = subItem.enabled
+ binding.autoUpdateCheck.isChecked = subItem.autoUpdate
+ return true
+ }
+
+ /**
+ * clear or init server config
+ */
+ private fun clearServer(): Boolean {
+ binding.etRemarks.text = null
+ binding.etUrl.text = null
+ binding.chkEnable.isChecked = true
+ return true
+ }
+
+ /**
+ * save server config
+ */
+ private fun saveServer(): Boolean {
+ val subItem: SubscriptionItem
+ val json = subStorage?.decodeString(editSubId)
+ var subId = editSubId
+ if (!json.isNullOrBlank()) {
+ subItem = Gson().fromJson(json, SubscriptionItem::class.java)
+ } else {
+ subId = Utils.getUuid()
+ subItem = SubscriptionItem()
+ }
+
+ subItem.remarks = binding.etRemarks.text.toString()
+ subItem.url = binding.etUrl.text.toString()
+ subItem.enabled = binding.chkEnable.isChecked
+ subItem.autoUpdate = binding.autoUpdateCheck.isChecked
+
+ if (TextUtils.isEmpty(subItem.remarks)) {
+ toast(R.string.sub_setting_remarks)
+ return false
+ }
+// if (TextUtils.isEmpty(subItem.url)) {
+// toast(R.string.sub_setting_url)
+// return false
+// }
+
+ subStorage?.encode(subId, Gson().toJson(subItem))
+ toast(R.string.toast_success)
+ finish()
+ return true
+ }
+
+ /**
+ * save server config
+ */
+ private fun deleteServer(): Boolean {
+ if (editSubId.isNotEmpty()) {
+ AlertDialog.Builder(this).setMessage(R.string.del_config_comfirm)
+ .setPositiveButton(android.R.string.ok) { _, _ ->
+ MmkvManager.removeSubscription(editSubId)
+ finish()
+ }
+ .show()
+ }
+ return true
+ }
+
+ override fun onCreateOptionsMenu(menu: Menu): Boolean {
+ menuInflater.inflate(R.menu.action_server, menu)
+ del_config = menu.findItem(R.id.del_config)
+ save_config = menu.findItem(R.id.save_config)
+
+ if (editSubId.isEmpty()) {
+ del_config?.isVisible = false
+ }
+
+ return super.onCreateOptionsMenu(menu)
+ }
+
+ override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
+ R.id.del_config -> {
+ deleteServer()
+ true
+ }
+ R.id.save_config -> {
+ saveServer()
+ true
+ }
+ else -> super.onOptionsItemSelected(item)
+
+ }
+}
diff --git a/app/src/main/kotlin/com/v2ray/ang/ui/SubSettingActivity.kt b/app/src/main/kotlin/com/v2ray/ang/ui/SubSettingActivity.kt
new file mode 100644
index 0000000..853a387
--- /dev/null
+++ b/app/src/main/kotlin/com/v2ray/ang/ui/SubSettingActivity.kt
@@ -0,0 +1,55 @@
+package com.v2ray.ang.ui
+
+import android.content.Intent
+import androidx.recyclerview.widget.LinearLayoutManager
+import android.view.Menu
+import android.view.MenuItem
+import com.v2ray.ang.R
+import android.os.Bundle
+import com.v2ray.ang.databinding.ActivitySubSettingBinding
+import com.v2ray.ang.dto.SubscriptionItem
+import com.v2ray.ang.util.MmkvManager
+
+class SubSettingActivity : BaseActivity() {
+ private lateinit var binding: ActivitySubSettingBinding
+
+ var subscriptions:List> = listOf()
+ private val adapter by lazy { SubSettingRecyclerAdapter(this) }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ binding = ActivitySubSettingBinding.inflate(layoutInflater)
+ val view = binding.root
+ setContentView(view)
+
+ title = getString(R.string.title_sub_setting)
+
+ binding.recyclerView.setHasFixedSize(true)
+ binding.recyclerView.layoutManager = LinearLayoutManager(this)
+ binding.recyclerView.adapter = adapter
+
+ supportActionBar?.setDisplayHomeAsUpEnabled(true)
+ }
+
+ override fun onResume() {
+ super.onResume()
+ subscriptions = MmkvManager.decodeSubscriptions()
+ adapter.notifyDataSetChanged()
+ }
+
+ override fun onCreateOptionsMenu(menu: Menu): Boolean {
+ menuInflater.inflate(R.menu.action_sub_setting, menu)
+ menu.findItem(R.id.del_config)?.isVisible = false
+ menu.findItem(R.id.save_config)?.isVisible = false
+
+ return super.onCreateOptionsMenu(menu)
+ }
+
+ override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
+ R.id.add_config -> {
+ startActivity(Intent(this, SubEditActivity::class.java))
+ true
+ }
+ else -> super.onOptionsItemSelected(item)
+ }
+}
diff --git a/app/src/main/kotlin/com/v2ray/ang/ui/SubSettingRecyclerAdapter.kt b/app/src/main/kotlin/com/v2ray/ang/ui/SubSettingRecyclerAdapter.kt
new file mode 100644
index 0000000..f3732bf
--- /dev/null
+++ b/app/src/main/kotlin/com/v2ray/ang/ui/SubSettingRecyclerAdapter.kt
@@ -0,0 +1,50 @@
+package com.v2ray.ang.ui
+
+import android.content.Intent
+import android.graphics.Color
+import android.view.LayoutInflater
+import androidx.recyclerview.widget.RecyclerView
+import android.view.ViewGroup
+import com.google.gson.Gson
+import com.tencent.mmkv.MMKV
+import com.v2ray.ang.R
+import com.v2ray.ang.databinding.ItemRecyclerSubSettingBinding
+import com.v2ray.ang.util.MmkvManager
+
+class SubSettingRecyclerAdapter(val activity: SubSettingActivity) : RecyclerView.Adapter() {
+
+ private var mActivity: SubSettingActivity = activity
+ private val subStorage by lazy { MMKV.mmkvWithID(MmkvManager.ID_SUB, MMKV.MULTI_PROCESS_MODE) }
+
+ override fun getItemCount() = mActivity.subscriptions.size
+
+ override fun onBindViewHolder(holder: MainViewHolder, position: Int) {
+ val subId = mActivity.subscriptions[position].first
+ val subItem = mActivity.subscriptions[position].second
+ holder.itemSubSettingBinding.tvName.text = subItem.remarks
+ holder.itemSubSettingBinding.tvUrl.text = subItem.url
+ if (subItem.enabled) {
+ holder.itemSubSettingBinding.chkEnable.setBackgroundResource(R.color.colorSelected)
+ } else {
+ holder.itemSubSettingBinding.chkEnable.setBackgroundResource(R.color.colorUnselected)
+ }
+ holder.itemView.setBackgroundColor(Color.TRANSPARENT)
+
+ holder.itemSubSettingBinding.layoutEdit.setOnClickListener {
+ mActivity.startActivity(Intent(mActivity, SubEditActivity::class.java)
+ .putExtra("subId", subId)
+ )
+ }
+ holder.itemSubSettingBinding.infoContainer.setOnClickListener {
+ subItem.enabled = !subItem.enabled
+ subStorage?.encode(subId, Gson().toJson(subItem))
+ notifyItemChanged(position)
+ }
+ }
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MainViewHolder {
+ return MainViewHolder(ItemRecyclerSubSettingBinding.inflate(LayoutInflater.from(parent.context), parent, false))
+ }
+
+ class MainViewHolder(val itemSubSettingBinding: ItemRecyclerSubSettingBinding) : RecyclerView.ViewHolder(itemSubSettingBinding.root)
+}
diff --git a/app/src/main/kotlin/com/v2ray/ang/ui/TaskerActivity.kt b/app/src/main/kotlin/com/v2ray/ang/ui/TaskerActivity.kt
new file mode 100644
index 0000000..1378075
--- /dev/null
+++ b/app/src/main/kotlin/com/v2ray/ang/ui/TaskerActivity.kt
@@ -0,0 +1,116 @@
+package com.v2ray.ang.ui
+
+import android.app.Activity
+import android.os.Bundle
+import android.view.View
+import android.widget.ArrayAdapter
+import android.widget.ListView
+import java.util.ArrayList
+import com.v2ray.ang.R
+import android.content.Intent
+import android.text.TextUtils
+import android.view.Menu
+import android.view.MenuItem
+import com.tencent.mmkv.MMKV
+import com.v2ray.ang.AppConfig
+import com.v2ray.ang.databinding.ActivityTaskerBinding
+import com.v2ray.ang.util.MmkvManager
+
+class TaskerActivity : BaseActivity() {
+ private lateinit var binding: ActivityTaskerBinding
+
+ private var listview: ListView? = null
+ private var lstData: ArrayList = ArrayList()
+ private var lstGuid: ArrayList = ArrayList()
+
+ private val serverStorage by lazy { MMKV.mmkvWithID(MmkvManager.ID_SERVER_CONFIG, MMKV.MULTI_PROCESS_MODE) }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ binding = ActivityTaskerBinding.inflate(layoutInflater)
+ val view = binding.root
+ setContentView(view)
+
+ //add def value
+ lstData.add("Default")
+ lstGuid.add(AppConfig.TASKER_DEFAULT_GUID)
+
+ serverStorage?.allKeys()?.forEach { key ->
+ MmkvManager.decodeServerConfig(key)?.let { config ->
+ lstData.add(config.remarks)
+ lstGuid.add(key)
+ }
+ }
+ val adapter = ArrayAdapter(this,
+ android.R.layout.simple_list_item_single_choice, lstData)
+ listview = findViewById(R.id.listview) as ListView
+ listview!!.adapter = adapter
+
+ init()
+ }
+
+ private fun init() {
+ try {
+ val bundle = intent?.getBundleExtra(AppConfig.TASKER_EXTRA_BUNDLE)
+ val switch = bundle?.getBoolean(AppConfig.TASKER_EXTRA_BUNDLE_SWITCH, false)
+ val guid = bundle?.getString(AppConfig.TASKER_EXTRA_BUNDLE_GUID, "")
+
+ if (switch == null || TextUtils.isEmpty(guid)) {
+ return
+ } else {
+ binding.switchStartService.isChecked = switch
+ val pos = lstGuid.indexOf(guid.toString())
+ if (pos >= 0) {
+ listview?.setItemChecked(pos, true)
+ }
+ }
+ } catch (e: Exception) {
+ e.printStackTrace()
+
+ }
+ }
+
+ private fun confirmFinish() {
+ val position = listview?.checkedItemPosition
+ if (position == null || position < 0) {
+ return
+ }
+
+ val extraBundle = Bundle()
+ extraBundle.putBoolean(AppConfig.TASKER_EXTRA_BUNDLE_SWITCH, binding.switchStartService.isChecked)
+ extraBundle.putString(AppConfig.TASKER_EXTRA_BUNDLE_GUID, lstGuid[position])
+ val intent = Intent()
+
+ val remarks = lstData[position]
+ val blurb = if (binding.switchStartService.isChecked) {
+ "Start $remarks"
+ } else {
+ "Stop $remarks"
+ }
+
+ intent.putExtra(AppConfig.TASKER_EXTRA_BUNDLE, extraBundle)
+ intent.putExtra(AppConfig.TASKER_EXTRA_STRING_BLURB, blurb)
+ setResult(Activity.RESULT_OK, intent)
+ finish()
+ }
+
+ override fun onCreateOptionsMenu(menu: Menu): Boolean {
+ menuInflater.inflate(R.menu.action_server, menu)
+ val del_config = menu.findItem(R.id.del_config)
+ del_config?.isVisible = false
+ return super.onCreateOptionsMenu(menu)
+ }
+
+ override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
+ R.id.del_config -> {
+ true
+ }
+ R.id.save_config -> {
+ confirmFinish()
+ true
+ }
+ else -> super.onOptionsItemSelected(item)
+ }
+
+}
+
diff --git a/app/src/main/kotlin/com/v2ray/ang/ui/UrlSchemeActivity.kt b/app/src/main/kotlin/com/v2ray/ang/ui/UrlSchemeActivity.kt
new file mode 100644
index 0000000..7f18a71
--- /dev/null
+++ b/app/src/main/kotlin/com/v2ray/ang/ui/UrlSchemeActivity.kt
@@ -0,0 +1,50 @@
+package com.v2ray.ang.ui
+
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+import com.v2ray.ang.R
+import com.v2ray.ang.databinding.ActivityLogcatBinding
+import com.v2ray.ang.extension.toast
+import com.v2ray.ang.util.AngConfigManager
+
+class UrlSchemeActivity : BaseActivity() {
+ private lateinit var binding: ActivityLogcatBinding
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ binding = ActivityLogcatBinding.inflate(layoutInflater)
+ val view = binding.root
+ setContentView(view)
+
+ var shareUrl: String = ""
+ try {
+ intent?.apply {
+ when (action) {
+ Intent.ACTION_SEND -> {
+ if ("text/plain" == type) {
+ intent.getStringExtra(Intent.EXTRA_TEXT)?.let {
+ shareUrl = it
+ }
+ }
+ }
+ Intent.ACTION_VIEW -> {
+ val uri: Uri? = intent.data
+ shareUrl = uri?.getQueryParameter("url")!!
+ }
+ }
+ }
+ toast(shareUrl)
+ val count = AngConfigManager.importBatchConfig(shareUrl, "", false)
+ if (count > 0) {
+ toast(R.string.toast_success)
+ } else {
+ toast(R.string.toast_failure)
+ }
+ startActivity(Intent(this, MainActivity::class.java))
+ finish()
+ } catch (e: Exception) {
+ e.printStackTrace()
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/v2ray/ang/ui/UserAssetActivity.kt b/app/src/main/kotlin/com/v2ray/ang/ui/UserAssetActivity.kt
new file mode 100644
index 0000000..17755bf
--- /dev/null
+++ b/app/src/main/kotlin/com/v2ray/ang/ui/UserAssetActivity.kt
@@ -0,0 +1,216 @@
+package com.v2ray.ang.ui
+
+import android.Manifest
+import android.annotation.SuppressLint
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+import android.provider.OpenableColumns
+import android.util.Log
+import android.view.*
+import android.view.View.GONE
+import android.view.View.VISIBLE
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.lifecycle.lifecycleScope
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import com.tbruyelle.rxpermissions.RxPermissions
+import com.tencent.mmkv.MMKV
+import com.v2ray.ang.AppConfig
+import com.v2ray.ang.R
+import com.v2ray.ang.databinding.ActivitySubSettingBinding
+import com.v2ray.ang.databinding.ItemRecyclerUserAssetBinding
+import com.v2ray.ang.extension.toTrafficString
+import com.v2ray.ang.extension.toast
+import com.v2ray.ang.util.MmkvManager
+import com.v2ray.ang.util.Utils
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import java.io.File
+import java.io.FileOutputStream
+import java.net.HttpURLConnection
+import java.net.InetSocketAddress
+import java.net.Proxy
+import java.net.URL
+import java.text.DateFormat
+import java.util.*
+
+class UserAssetActivity : BaseActivity() {
+ private lateinit var binding: ActivitySubSettingBinding
+ private val settingsStorage by lazy { MMKV.mmkvWithID(MmkvManager.ID_SETTING, MMKV.MULTI_PROCESS_MODE) }
+
+ val extDir by lazy { File(Utils.userAssetPath(this)) }
+ val geofiles = arrayOf("geosite.dat", "geoip.dat")
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ binding = ActivitySubSettingBinding.inflate(layoutInflater)
+ val view = binding.root
+ setContentView(view)
+ title = getString(R.string.title_user_asset_setting)
+ supportActionBar?.setDisplayHomeAsUpEnabled(true)
+
+ binding.recyclerView.setHasFixedSize(true)
+ binding.recyclerView.layoutManager = LinearLayoutManager(this)
+ binding.recyclerView.adapter = UserAssetAdapter()
+ }
+
+ override fun onCreateOptionsMenu(menu: Menu): Boolean {
+ menuInflater.inflate(R.menu.menu_asset, menu)
+ return super.onCreateOptionsMenu(menu)
+ }
+
+ override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
+ R.id.add_file -> {
+ showFileChooser()
+ true
+ }
+
+ R.id.download_file -> {
+ downloadGeoFiles()
+ true
+ }
+
+ else -> super.onOptionsItemSelected(item)
+ }
+
+ private fun showFileChooser() {
+ RxPermissions(this).request(Manifest.permission.READ_EXTERNAL_STORAGE).subscribe {
+ if (it) {
+ val intent = Intent(Intent.ACTION_GET_CONTENT)
+ intent.type = "*/*"
+ intent.addCategory(Intent.CATEGORY_OPENABLE)
+
+ try {
+ chooseFile.launch(
+ Intent.createChooser(
+ intent,
+ getString(R.string.title_file_chooser)
+ )
+ )
+ } catch (ex: android.content.ActivityNotFoundException) {
+ toast(R.string.toast_require_file_manager)
+ }
+ }
+ }
+ }
+
+ private val chooseFile =
+ registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
+ val uri = it.data?.data
+ if (it.resultCode == RESULT_OK && uri != null) {
+ try {
+ copyFile(uri)
+ } catch (e: Exception) {
+ toast(R.string.toast_asset_copy_failed)
+ }
+ }
+ }
+
+ private fun copyFile(uri: Uri): String {
+ val targetFile = File(extDir, getCursorName(uri) ?: uri.toString())
+ contentResolver.openInputStream(uri).use { inputStream ->
+ targetFile.outputStream().use { fileOut ->
+ inputStream?.copyTo(fileOut)
+ toast(R.string.toast_success)
+ binding.recyclerView.adapter?.notifyDataSetChanged()
+ }
+ }
+ return targetFile.path
+ }
+
+ private fun getCursorName(uri: Uri): String? = try {
+ contentResolver.query(uri, null, null, null, null)?.let { cursor ->
+ cursor.run {
+ if (moveToFirst()) getString(getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME))
+ else null
+ }.also { cursor.close() }
+ }
+ } catch (e: Exception) {
+ e.printStackTrace()
+ null
+ }
+
+ private fun downloadGeoFiles() {
+ val httpPort = Utils.parseInt(settingsStorage?.decodeString(AppConfig.PREF_HTTP_PORT), AppConfig.PORT_HTTP.toInt())
+
+ toast(R.string.msg_downloading_content)
+ geofiles.forEach {
+ //toast(getString(R.string.msg_downloading_content) + it)
+ lifecycleScope.launch(Dispatchers.IO) {
+ val result = downloadGeo(it, 60000, httpPort)
+ launch(Dispatchers.Main) {
+ if (result) {
+ toast(getString(R.string.toast_success) + " " + it)
+ binding.recyclerView.adapter?.notifyDataSetChanged()
+ } else {
+ toast(getString(R.string.toast_failure) + " " + it)
+ }
+ }
+ }
+ }
+ }
+
+ private fun downloadGeo(name: String, timeout: Int, httpPort: Int): Boolean {
+ val url = AppConfig.geoUrl + name
+ val targetTemp = File(extDir, name + "_temp")
+ val target = File(extDir, name)
+ var conn: HttpURLConnection? = null
+ //Log.d(AppConfig.ANG_PACKAGE, url)
+
+ try {
+ conn = URL(url).openConnection(
+ Proxy(
+ Proxy.Type.HTTP,
+ InetSocketAddress("127.0.0.1", httpPort)
+ )
+ ) as HttpURLConnection
+ conn.connectTimeout = timeout
+ conn.readTimeout = timeout
+ val inputStream = conn.inputStream
+ val responseCode = conn.responseCode
+ if (responseCode == HttpURLConnection.HTTP_OK) {
+ FileOutputStream(targetTemp).use { output ->
+ inputStream.copyTo(output)
+ }
+
+ targetTemp.renameTo(target)
+ }
+ return true
+ } catch (e: Exception) {
+ Log.e(AppConfig.ANG_PACKAGE, Log.getStackTraceString(e))
+ return false
+ } finally {
+ conn?.disconnect()
+ }
+ }
+
+ inner class UserAssetAdapter : RecyclerView.Adapter() {
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): UserAssetViewHolder {
+ return UserAssetViewHolder(ItemRecyclerUserAssetBinding.inflate(LayoutInflater.from(parent.context), parent, false))
+ }
+
+ @SuppressLint("SetTextI18n")
+ override fun onBindViewHolder(holder: UserAssetViewHolder, position: Int) {
+ val file = extDir.listFiles()?.getOrNull(position) ?: return
+ holder.itemUserAssetBinding.assetName.text = file.name
+ val dateFormat = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.MEDIUM)
+ holder.itemUserAssetBinding.assetProperties.text = "${file.length().toTrafficString()} • ${dateFormat.format(Date(file.lastModified()))}"
+ if (file.name in geofiles) {
+ holder.itemUserAssetBinding.layoutRemove.visibility = GONE
+ } else {
+ holder.itemUserAssetBinding.layoutRemove.visibility = VISIBLE
+ }
+ holder.itemUserAssetBinding.layoutRemove.setOnClickListener {
+ file.delete()
+ binding.recyclerView.adapter?.notifyItemRemoved(position)
+ }
+ }
+
+ override fun getItemCount(): Int {
+ return extDir.listFiles()?.size ?: 0
+ }
+ }
+
+ class UserAssetViewHolder(val itemUserAssetBinding: ItemRecyclerUserAssetBinding) : RecyclerView.ViewHolder(itemUserAssetBinding.root)
+}
diff --git a/app/src/main/kotlin/com/v2ray/ang/util/AngConfigManager.kt b/app/src/main/kotlin/com/v2ray/ang/util/AngConfigManager.kt
new file mode 100644
index 0000000..e4d4d5f
--- /dev/null
+++ b/app/src/main/kotlin/com/v2ray/ang/util/AngConfigManager.kt
@@ -0,0 +1,774 @@
+package com.v2ray.ang.util
+
+import android.content.Context
+import android.content.SharedPreferences
+import android.graphics.Bitmap
+import android.text.TextUtils
+import android.util.Log
+import androidx.preference.PreferenceManager
+import com.google.gson.Gson
+import com.tencent.mmkv.MMKV
+import com.v2ray.ang.AppConfig
+import com.v2ray.ang.AppConfig.ANG_CONFIG
+import com.v2ray.ang.AppConfig.HTTPS_PROTOCOL
+import com.v2ray.ang.AppConfig.HTTP_PROTOCOL
+import com.v2ray.ang.R
+import com.v2ray.ang.dto.*
+import com.v2ray.ang.dto.V2rayConfig.Companion.DEFAULT_SECURITY
+import com.v2ray.ang.dto.V2rayConfig.Companion.TLS
+import com.v2ray.ang.util.MmkvManager.KEY_SELECTED_SERVER
+import java.net.URI
+import java.util.*
+import com.v2ray.ang.extension.idnHost
+
+object AngConfigManager {
+ private val mainStorage by lazy { MMKV.mmkvWithID(MmkvManager.ID_MAIN, MMKV.MULTI_PROCESS_MODE) }
+ private val serverRawStorage by lazy { MMKV.mmkvWithID(MmkvManager.ID_SERVER_RAW, MMKV.MULTI_PROCESS_MODE) }
+ private val settingsStorage by lazy { MMKV.mmkvWithID(MmkvManager.ID_SETTING, MMKV.MULTI_PROCESS_MODE) }
+ private val subStorage by lazy { MMKV.mmkvWithID(MmkvManager.ID_SUB, MMKV.MULTI_PROCESS_MODE) }
+
+ /**
+ * Legacy loading config
+ */
+ fun migrateLegacyConfig(c: Context): Boolean? {
+ try {
+ val defaultSharedPreferences = PreferenceManager.getDefaultSharedPreferences(c)
+ val context = defaultSharedPreferences.getString(ANG_CONFIG, "")
+ if (context.isNullOrBlank()) {
+ return null
+ }
+ val angConfig = Gson().fromJson(context, AngConfig::class.java)
+ for (i in angConfig.vmess.indices) {
+ upgradeServerVersion(angConfig.vmess[i])
+ }
+
+ copyLegacySettings(defaultSharedPreferences)
+ migrateVmessBean(angConfig, defaultSharedPreferences)
+ migrateSubItemBean(angConfig)
+
+ defaultSharedPreferences.edit().remove(ANG_CONFIG).apply()
+ return true
+ } catch (e: Exception) {
+ e.printStackTrace()
+ }
+ return false
+ }
+
+ private fun copyLegacySettings(sharedPreferences: SharedPreferences) {
+ listOf(
+ AppConfig.PREF_MODE,
+ AppConfig.PREF_REMOTE_DNS,
+ AppConfig.PREF_DOMESTIC_DNS,
+ AppConfig.PREF_LOCAL_DNS_PORT,
+ AppConfig.PREF_SOCKS_PORT,
+ AppConfig.PREF_HTTP_PORT,
+ AppConfig.PREF_LOGLEVEL,
+ AppConfig.PREF_ROUTING_DOMAIN_STRATEGY,
+ AppConfig.PREF_ROUTING_MODE,
+ AppConfig.PREF_V2RAY_ROUTING_AGENT,
+ AppConfig.PREF_V2RAY_ROUTING_BLOCKED,
+ AppConfig.PREF_V2RAY_ROUTING_DIRECT,
+ AppConfig.PREF_MUX_CONCURRENCY,
+ ).forEach { key ->
+ settingsStorage?.encode(key, sharedPreferences.getString(key, null))
+ }
+ listOf(
+ AppConfig.PREF_SPEED_ENABLED,
+ AppConfig.PREF_PROXY_SHARING,
+ AppConfig.PREF_LOCAL_DNS_ENABLED,
+ AppConfig.PREF_ALLOW_INSECURE,
+ AppConfig.PREF_PREFER_IPV6,
+ AppConfig.PREF_PER_APP_PROXY,
+ AppConfig.PREF_BYPASS_APPS,
+ AppConfig.PREF_MUX_ENABLED,
+ ).forEach { key ->
+ settingsStorage?.encode(key, sharedPreferences.getBoolean(key, false))
+ }
+ settingsStorage?.encode(AppConfig.PREF_SNIFFING_ENABLED, sharedPreferences.getBoolean(AppConfig.PREF_SNIFFING_ENABLED, true))
+ settingsStorage?.encode(AppConfig.PREF_PER_APP_PROXY_SET, sharedPreferences.getStringSet(AppConfig.PREF_PER_APP_PROXY_SET, setOf()))
+ }
+
+ private fun migrateVmessBean(angConfig: AngConfig, sharedPreferences: SharedPreferences) {
+ angConfig.vmess.forEachIndexed { index, vmessBean ->
+ val type = EConfigType.fromInt(vmessBean.configType) ?: return@forEachIndexed
+ val config = ServerConfig.create(type)
+ config.remarks = vmessBean.remarks
+ config.subscriptionId = vmessBean.subid
+ if (type == EConfigType.CUSTOM) {
+ val jsonConfig = sharedPreferences.getString(ANG_CONFIG + vmessBean.guid, "")
+ val v2rayConfig = try {
+ Gson().fromJson(jsonConfig, V2rayConfig::class.java)
+ } catch (e: Exception) {
+ e.printStackTrace()
+ return@forEachIndexed
+ }
+ config.fullConfig = v2rayConfig
+ serverRawStorage?.encode(vmessBean.guid, jsonConfig)
+ } else {
+ config.outboundBean?.settings?.vnext?.get(0)?.let { vnext ->
+ vnext.address = vmessBean.address
+ vnext.port = vmessBean.port
+ vnext.users[0].id = vmessBean.id
+ if (config.configType == EConfigType.VMESS) {
+ vnext.users[0].alterId = vmessBean.alterId
+ vnext.users[0].security = vmessBean.security
+ } else if (config.configType == EConfigType.VLESS) {
+ vnext.users[0].encryption = vmessBean.security
+ vnext.users[0].flow = vmessBean.flow
+ }
+ }
+ config.outboundBean?.settings?.servers?.get(0)?.let { server ->
+ server.address = vmessBean.address
+ server.port = vmessBean.port
+ if (config.configType == EConfigType.SHADOWSOCKS) {
+ server.password = vmessBean.id
+ server.method = vmessBean.security
+ } else if (config.configType == EConfigType.SOCKS) {
+ if (TextUtils.isEmpty(vmessBean.security) && TextUtils.isEmpty(vmessBean.id)) {
+ server.users = null
+ } else {
+ val socksUsersBean = V2rayConfig.OutboundBean.OutSettingsBean.ServersBean.SocksUsersBean()
+ socksUsersBean.user = vmessBean.security
+ socksUsersBean.pass = vmessBean.id
+ server.users = listOf(socksUsersBean)
+ }
+ } else if (config.configType == EConfigType.TROJAN) {
+ server.password = vmessBean.id
+ }
+ }
+ config.outboundBean?.streamSettings?.let { streamSetting ->
+ val sni = streamSetting.populateTransportSettings(vmessBean.network, vmessBean.headerType,
+ vmessBean.requestHost, vmessBean.path, vmessBean.path, vmessBean.requestHost, vmessBean.path,
+ vmessBean.headerType, vmessBean.path)
+ val allowInsecure = if (vmessBean.allowInsecure.isBlank()) {
+ settingsStorage?.decodeBool(AppConfig.PREF_ALLOW_INSECURE) ?: false
+ } else {
+ vmessBean.allowInsecure.toBoolean()
+ }
+ var fingerprint = streamSetting.tlsSettings?.fingerprint
+ streamSetting.populateTlsSettings(vmessBean.streamSecurity, allowInsecure,
+ vmessBean.sni.ifBlank { sni }, fingerprint, null, null, null, null)
+ }
+ }
+ val key = MmkvManager.encodeServerConfig(vmessBean.guid, config)
+ if (index == angConfig.index) {
+ mainStorage?.encode(KEY_SELECTED_SERVER, key)
+ }
+ }
+ }
+
+ private fun migrateSubItemBean(angConfig: AngConfig) {
+ angConfig.subItem.forEach {
+ val subItem = SubscriptionItem()
+ subItem.remarks = it.remarks
+ subItem.url = it.url
+ subItem.enabled = it.enabled
+ subStorage?.encode(it.id, Gson().toJson(subItem))
+ }
+ }
+
+ /**
+ * import config form qrcode or...
+ */
+ private fun importConfig(str: String?, subid: String, removedSelectedServer: ServerConfig?): Int {
+ try {
+ if (str == null || TextUtils.isEmpty(str)) {
+ return R.string.toast_none_data
+ }
+
+ //maybe sub
+ if (TextUtils.isEmpty(subid) && (str.startsWith(HTTP_PROTOCOL) || str.startsWith(HTTPS_PROTOCOL))) {
+ MmkvManager.importUrlAsSubscription(str)
+ return 0
+ }
+
+ var config: ServerConfig? = null
+ val allowInsecure = settingsStorage?.decodeBool(AppConfig.PREF_ALLOW_INSECURE) ?: false
+ if (str.startsWith(EConfigType.VMESS.protocolScheme)) {
+ config = ServerConfig.create(EConfigType.VMESS)
+ val streamSetting = config.outboundBean?.streamSettings ?: return -1
+
+
+ if (!tryParseNewVmess(str, config, allowInsecure)) {
+ if (str.indexOf("?") > 0) {
+ if (!tryResolveVmess4Kitsunebi(str, config)) {
+ return R.string.toast_incorrect_protocol
+ }
+ } else {
+ var result = str.replace(EConfigType.VMESS.protocolScheme, "")
+ result = Utils.decode(result)
+ if (TextUtils.isEmpty(result)) {
+ return R.string.toast_decoding_failed
+ }
+ val vmessQRCode = Gson().fromJson(result, VmessQRCode::class.java)
+ // Although VmessQRCode fields are non null, looks like Gson may still create null fields
+ if (TextUtils.isEmpty(vmessQRCode.add)
+ || TextUtils.isEmpty(vmessQRCode.port)
+ || TextUtils.isEmpty(vmessQRCode.id)
+ || TextUtils.isEmpty(vmessQRCode.net)
+ ) {
+ return R.string.toast_incorrect_protocol
+ }
+
+ config.remarks = vmessQRCode.ps
+ config.outboundBean?.settings?.vnext?.get(0)?.let { vnext ->
+ vnext.address = vmessQRCode.add
+ vnext.port = Utils.parseInt(vmessQRCode.port)
+ vnext.users[0].id = vmessQRCode.id
+ vnext.users[0].security = if (TextUtils.isEmpty(vmessQRCode.scy)) DEFAULT_SECURITY else vmessQRCode.scy
+ vnext.users[0].alterId = Utils.parseInt(vmessQRCode.aid)
+ }
+ val sni = streamSetting.populateTransportSettings(vmessQRCode.net, vmessQRCode.type, vmessQRCode.host,
+ vmessQRCode.path, vmessQRCode.path, vmessQRCode.host, vmessQRCode.path, vmessQRCode.type, vmessQRCode.path)
+
+ val fingerprint = vmessQRCode.fp ?: streamSetting.tlsSettings?.fingerprint
+ streamSetting.populateTlsSettings(vmessQRCode.tls, allowInsecure,
+ if (TextUtils.isEmpty(vmessQRCode.sni)) sni else vmessQRCode.sni,
+ fingerprint, vmessQRCode.alpn, null, null, null)
+ }
+ }
+ } else if (str.startsWith(EConfigType.SHADOWSOCKS.protocolScheme)) {
+ config = ServerConfig.create(EConfigType.SHADOWSOCKS)
+ if (!tryResolveResolveSip002(str, config)) {
+ var result = str.replace(EConfigType.SHADOWSOCKS.protocolScheme, "")
+ val indexSplit = result.indexOf("#")
+ if (indexSplit > 0) {
+ try {
+ config.remarks = Utils.urlDecode(result.substring(indexSplit + 1, result.length))
+ } catch (e: Exception) {
+ e.printStackTrace()
+ }
+
+ result = result.substring(0, indexSplit)
+ }
+
+ //part decode
+ val indexS = result.indexOf("@")
+ result = if (indexS > 0) {
+ Utils.decode(result.substring(0, indexS)) + result.substring(indexS, result.length)
+ } else {
+ Utils.decode(result)
+ }
+
+ val legacyPattern = "^(.+?):(.*)@(.+?):(\\d+?)/?$".toRegex()
+ val match = legacyPattern.matchEntire(result) ?: return R.string.toast_incorrect_protocol
+
+ config.outboundBean?.settings?.servers?.get(0)?.let { server ->
+ server.address = match.groupValues[3].removeSurrounding("[", "]")
+ server.port = match.groupValues[4].toInt()
+ server.password = match.groupValues[2]
+ server.method = match.groupValues[1].lowercase()
+ }
+ }
+ } else if (str.startsWith(EConfigType.SOCKS.protocolScheme)) {
+ var result = str.replace(EConfigType.SOCKS.protocolScheme, "")
+ val indexSplit = result.indexOf("#")
+ config = ServerConfig.create(EConfigType.SOCKS)
+ if (indexSplit > 0) {
+ try {
+ config.remarks = Utils.urlDecode(result.substring(indexSplit + 1, result.length))
+ } catch (e: Exception) {
+ e.printStackTrace()
+ }
+
+ result = result.substring(0, indexSplit)
+ }
+
+ //part decode
+ val indexS = result.indexOf("@")
+ if (indexS > 0) {
+ result = Utils.decode(result.substring(0, indexS)) + result.substring(indexS, result.length)
+ } else {
+ result = Utils.decode(result)
+ }
+
+ val legacyPattern = "^(.*):(.*)@(.+?):(\\d+?)$".toRegex()
+ val match = legacyPattern.matchEntire(result) ?: return R.string.toast_incorrect_protocol
+
+ config.outboundBean?.settings?.servers?.get(0)?.let { server ->
+ server.address = match.groupValues[3].removeSurrounding("[", "]")
+ server.port = match.groupValues[4].toInt()
+ val socksUsersBean = V2rayConfig.OutboundBean.OutSettingsBean.ServersBean.SocksUsersBean()
+ socksUsersBean.user = match.groupValues[1].lowercase()
+ socksUsersBean.pass = match.groupValues[2]
+ server.users = listOf(socksUsersBean)
+ }
+ } else if (str.startsWith(EConfigType.TROJAN.protocolScheme)) {
+ val uri = URI(Utils.fixIllegalUrl(str))
+ config = ServerConfig.create(EConfigType.TROJAN)
+ config.remarks = Utils.urlDecode(uri.fragment ?: "")
+
+ var flow = ""
+ var fingerprint = config.outboundBean?.streamSettings?.tlsSettings?.fingerprint
+ if (uri.rawQuery != null) {
+ val queryParam = uri.rawQuery.split("&")
+ .associate { it.split("=").let { (k, v) -> k to Utils.urlDecode(v) } }
+
+ val sni = config.outboundBean?.streamSettings?.populateTransportSettings(queryParam["type"] ?: "tcp", queryParam["headerType"],
+ queryParam["host"], queryParam["path"], queryParam["seed"], queryParam["quicSecurity"], queryParam["key"],
+ queryParam["mode"], queryParam["serviceName"])
+ fingerprint = queryParam["fp"] ?: ""
+ config.outboundBean?.streamSettings?.populateTlsSettings(queryParam["security"] ?: TLS,
+ allowInsecure, queryParam["sni"] ?: sni!!, fingerprint, queryParam["alpn"],
+ null, null, null)
+ flow = queryParam["flow"] ?: ""
+ } else {
+ config.outboundBean?.streamSettings?.populateTlsSettings(TLS, allowInsecure, "",
+ fingerprint, null, null, null, null)
+ }
+
+ config.outboundBean?.settings?.servers?.get(0)?.let { server ->
+ server.address = uri.idnHost
+ server.port = uri.port
+ server.password = uri.userInfo
+ server.flow = flow
+ }
+ } else if (str.startsWith(EConfigType.VLESS.protocolScheme)) {
+ val uri = URI(Utils.fixIllegalUrl(str))
+ val queryParam = uri.rawQuery.split("&")
+ .associate { it.split("=").let { (k, v) -> k to Utils.urlDecode(v) } }
+ config = ServerConfig.create(EConfigType.VLESS)
+ val streamSetting = config.outboundBean?.streamSettings ?: return -1
+ var fingerprint = streamSetting.tlsSettings?.fingerprint
+
+ config.remarks = Utils.urlDecode(uri.fragment ?: "")
+ config.outboundBean?.settings?.vnext?.get(0)?.let { vnext ->
+ vnext.address = uri.idnHost
+ vnext.port = uri.port
+ vnext.users[0].id = uri.userInfo
+ vnext.users[0].encryption = queryParam["encryption"] ?: "none"
+ vnext.users[0].flow =queryParam["flow"] ?: ""
+ }
+
+ val sni = streamSetting.populateTransportSettings(queryParam["type"] ?: "tcp", queryParam["headerType"],
+ queryParam["host"], queryParam["path"], queryParam["seed"], queryParam["quicSecurity"], queryParam["key"],
+ queryParam["mode"], queryParam["serviceName"])
+ fingerprint = queryParam["fp"] ?: ""
+ val pbk = queryParam["pbk"] ?: ""
+ val sid = queryParam["sid"] ?: ""
+ val spx = Utils.urlDecode(queryParam["spx"] ?: "")
+ streamSetting.populateTlsSettings(queryParam["security"] ?: "", allowInsecure,
+ queryParam["sni"] ?: sni, fingerprint, queryParam["alpn"], pbk, sid, spx)
+ }
+ if (config == null){
+ return R.string.toast_incorrect_protocol
+ }
+ config.subscriptionId = subid
+ val guid = MmkvManager.encodeServerConfig("", config)
+ if (removedSelectedServer != null &&
+ config.getProxyOutbound()?.getServerAddress() == removedSelectedServer.getProxyOutbound()?.getServerAddress() &&
+ config.getProxyOutbound()?.getServerPort() == removedSelectedServer.getProxyOutbound()?.getServerPort()) {
+ mainStorage?.encode(KEY_SELECTED_SERVER, guid)
+ }
+ } catch (e: Exception) {
+ e.printStackTrace()
+ return -1
+ }
+ return 0
+ }
+
+ private fun tryParseNewVmess(uriString: String, config: ServerConfig, allowInsecure: Boolean): Boolean {
+ return runCatching {
+ val uri = URI(uriString)
+ check(uri.scheme == "vmess")
+ val (_, protocol, tlsStr, uuid, alterId) =
+ Regex("(tcp|http|ws|kcp|quic|grpc)(\\+tls)?:([0-9a-z]{8}-[0-9a-z]{4}-[0-9a-z]{4}-[0-9a-z]{4}-[0-9a-z]{12})")
+ .matchEntire(uri.userInfo)?.groupValues
+ ?: error("parse user info fail.")
+ val tls = tlsStr.isNotBlank()
+ val queryParam = uri.rawQuery.split("&")
+ .associate { it.split("=").let { (k, v) -> k to Utils.urlDecode(v) } }
+
+ val streamSetting = config.outboundBean?.streamSettings ?: return false
+ config.remarks = Utils.urlDecode(uri.fragment ?: "")
+ config.outboundBean.settings?.vnext?.get(0)?.let { vnext ->
+ vnext.address = uri.idnHost
+ vnext.port = uri.port
+ vnext.users[0].id = uuid
+ vnext.users[0].security = DEFAULT_SECURITY
+ vnext.users[0].alterId = alterId.toInt()
+ }
+ var fingerprint = streamSetting.tlsSettings?.fingerprint
+ val sni = streamSetting.populateTransportSettings(protocol, queryParam["type"],
+ queryParam["host"]?.split("|")?.get(0) ?: "",
+ queryParam["path"]?.takeIf { it.trim() != "/" } ?: "", queryParam["seed"], queryParam["security"],
+ queryParam["key"], queryParam["mode"], queryParam["serviceName"])
+ streamSetting.populateTlsSettings(if (tls) TLS else "", allowInsecure, sni, fingerprint, null,
+ null, null, null)
+ true
+ }.getOrElse { false }
+ }
+
+ private fun tryResolveVmess4Kitsunebi(server: String, config: ServerConfig): Boolean {
+
+ var result = server.replace(EConfigType.VMESS.protocolScheme, "")
+ val indexSplit = result.indexOf("?")
+ if (indexSplit > 0) {
+ result = result.substring(0, indexSplit)
+ }
+ result = Utils.decode(result)
+
+ val arr1 = result.split('@')
+ if (arr1.count() != 2) {
+ return false
+ }
+ val arr21 = arr1[0].split(':')
+ val arr22 = arr1[1].split(':')
+ if (arr21.count() != 2) {
+ return false
+ }
+
+ config.remarks = "Alien"
+ config.outboundBean?.settings?.vnext?.get(0)?.let { vnext ->
+ vnext.address = arr22[0]
+ vnext.port = Utils.parseInt(arr22[1])
+ vnext.users[0].id = arr21[1]
+ vnext.users[0].security = arr21[0]
+ vnext.users[0].alterId = 0
+ }
+ return true
+ }
+
+ private fun tryResolveResolveSip002(str: String, config: ServerConfig): Boolean {
+ try {
+ val uri = URI(Utils.fixIllegalUrl(str))
+ config.remarks = Utils.urlDecode(uri.fragment ?: "")
+
+ val method: String
+ val password: String
+ if (uri.userInfo.contains(":")) {
+ val arrUserInfo = uri.userInfo.split(":").map { it.trim() }
+ if (arrUserInfo.count() != 2) {
+ return false
+ }
+ method = arrUserInfo[0]
+ password = Utils.urlDecode(arrUserInfo[1])
+ } else {
+ val base64Decode = Utils.decode(uri.userInfo)
+ val arrUserInfo = base64Decode.split(":").map { it.trim() }
+ if (arrUserInfo.count() < 2) {
+ return false
+ }
+ method = arrUserInfo[0]
+ password = base64Decode.substringAfter(":")
+ }
+
+ config.outboundBean?.settings?.servers?.get(0)?.let { server ->
+ server.address = uri.idnHost
+ server.port = uri.port
+ server.password = password
+ server.method = method
+ }
+ return true
+ } catch (e: Exception) {
+ Log.d(AppConfig.ANG_PACKAGE, e.toString())
+ return false
+ }
+ }
+
+ /**
+ * share config
+ */
+ private fun shareConfig(guid: String): String {
+ try {
+ val config = MmkvManager.decodeServerConfig(guid) ?: return ""
+ val outbound = config.getProxyOutbound() ?: return ""
+ val streamSetting = outbound.streamSettings ?: return ""
+ return config.configType.protocolScheme + when (config.configType) {
+ EConfigType.VMESS -> {
+ val vmessQRCode = VmessQRCode()
+ vmessQRCode.v = "2"
+ vmessQRCode.ps = config.remarks
+ vmessQRCode.add = outbound.getServerAddress().orEmpty()
+ vmessQRCode.port = outbound.getServerPort().toString()
+ vmessQRCode.id = outbound.getPassword().orEmpty()
+ vmessQRCode.aid = outbound.settings?.vnext?.get(0)?.users?.get(0)?.alterId.toString()
+ vmessQRCode.scy = outbound.settings?.vnext?.get(0)?.users?.get(0)?.security.toString()
+ vmessQRCode.net = streamSetting.network
+ vmessQRCode.tls = streamSetting.security
+ vmessQRCode.sni = streamSetting.tlsSettings?.serverName.orEmpty()
+ vmessQRCode.alpn = Utils.removeWhiteSpace(streamSetting.tlsSettings?.alpn?.joinToString()).orEmpty()
+ vmessQRCode.fp = streamSetting.tlsSettings?.fingerprint.orEmpty()
+ outbound.getTransportSettingDetails()?.let { transportDetails ->
+ vmessQRCode.type = transportDetails[0]
+ vmessQRCode.host = transportDetails[1]
+ vmessQRCode.path = transportDetails[2]
+ }
+ val json = Gson().toJson(vmessQRCode)
+ Utils.encode(json)
+ }
+ EConfigType.CUSTOM, EConfigType.WIREGUARD -> ""
+ EConfigType.SHADOWSOCKS -> {
+ val remark = "#" + Utils.urlEncode(config.remarks)
+ val pw = Utils.encode("${outbound.getSecurityEncryption()}:${outbound.getPassword()}")
+ val url = String.format("%s@%s:%s",
+ pw,
+ Utils.getIpv6Address(outbound.getServerAddress()!!),
+ outbound.getServerPort())
+ url + remark
+ }
+ EConfigType.SOCKS -> {
+ val remark = "#" + Utils.urlEncode(config.remarks)
+ val pw = Utils.encode("${outbound.settings?.servers?.get(0)?.users?.get(0)?.user}:${outbound.getPassword()}")
+ val url = String.format("%s@%s:%s",
+ pw,
+ Utils.getIpv6Address(outbound.getServerAddress()!!),
+ outbound.getServerPort())
+ url + remark
+ }
+ EConfigType.VLESS,
+ EConfigType.TROJAN -> {
+ val remark = "#" + Utils.urlEncode(config.remarks)
+
+ val dicQuery = HashMap()
+ if (config.configType == EConfigType.VLESS) {
+ outbound.settings?.vnext?.get(0)?.users?.get(0)?.flow?.let {
+ if (!TextUtils.isEmpty(it)) {
+ dicQuery["flow"] = it
+ }
+ }
+ dicQuery["encryption"] =
+ if (outbound.getSecurityEncryption().isNullOrEmpty()) "none"
+ else outbound.getSecurityEncryption().orEmpty()
+ } else if (config.configType == EConfigType.TROJAN) {
+ config.outboundBean?.settings?.servers?.get(0)?.flow?.let {
+ if (!TextUtils.isEmpty(it)) {
+ dicQuery["flow"] = it
+ }
+ }
+ }
+
+ dicQuery["security"] = streamSetting.security.ifEmpty { "none" }
+ (streamSetting.tlsSettings?: streamSetting.realitySettings)?.let { tlsSetting ->
+ if (!TextUtils.isEmpty(tlsSetting.serverName)) {
+ dicQuery["sni"] = tlsSetting.serverName
+ }
+ if (!tlsSetting.alpn.isNullOrEmpty() && tlsSetting.alpn.isNotEmpty()) {
+ dicQuery["alpn"] = Utils.removeWhiteSpace(tlsSetting.alpn.joinToString()).orEmpty()
+ }
+ if (!TextUtils.isEmpty(tlsSetting.fingerprint)) {
+ dicQuery["fp"] = tlsSetting.fingerprint!!
+ }
+ if (!TextUtils.isEmpty(tlsSetting.publicKey)) {
+ dicQuery["pbk"] = tlsSetting.publicKey!!
+ }
+ if (!TextUtils.isEmpty(tlsSetting.shortId)) {
+ dicQuery["sid"] = tlsSetting.shortId!!
+ }
+ if (!TextUtils.isEmpty(tlsSetting.spiderX)) {
+ dicQuery["spx"] = Utils.urlEncode(tlsSetting.spiderX!!)
+ }
+ }
+ dicQuery["type"] = streamSetting.network.ifEmpty { V2rayConfig.DEFAULT_NETWORK }
+
+ outbound.getTransportSettingDetails()?.let { transportDetails ->
+ when (streamSetting.network) {
+ "tcp" -> {
+ dicQuery["headerType"] = transportDetails[0].ifEmpty { "none" }
+ if (!TextUtils.isEmpty(transportDetails[1])) {
+ dicQuery["host"] = Utils.urlEncode(transportDetails[1])
+ }
+ }
+ "kcp" -> {
+ dicQuery["headerType"] = transportDetails[0].ifEmpty { "none" }
+ if (!TextUtils.isEmpty(transportDetails[2])) {
+ dicQuery["seed"] = Utils.urlEncode(transportDetails[2])
+ }
+ }
+ "ws" -> {
+ if (!TextUtils.isEmpty(transportDetails[1])) {
+ dicQuery["host"] = Utils.urlEncode(transportDetails[1])
+ }
+ if (!TextUtils.isEmpty(transportDetails[2])) {
+ dicQuery["path"] = Utils.urlEncode(transportDetails[2])
+ }
+ }
+ "http", "h2" -> {
+ dicQuery["type"] = "http"
+ if (!TextUtils.isEmpty(transportDetails[1])) {
+ dicQuery["host"] = Utils.urlEncode(transportDetails[1])
+ }
+ if (!TextUtils.isEmpty(transportDetails[2])) {
+ dicQuery["path"] = Utils.urlEncode(transportDetails[2])
+ }
+ }
+ "quic" -> {
+ dicQuery["headerType"] = transportDetails[0].ifEmpty { "none" }
+ dicQuery["quicSecurity"] = Utils.urlEncode(transportDetails[1])
+ dicQuery["key"] = Utils.urlEncode(transportDetails[2])
+ }
+ "grpc" -> {
+ dicQuery["mode"] = transportDetails[0]
+ dicQuery["serviceName"] = transportDetails[2]
+ }
+ }
+ }
+ val query = "?" + dicQuery.toList().joinToString(
+ separator = "&",
+ transform = { it.first + "=" + it.second })
+
+ val url = String.format("%s@%s:%s",
+ outbound.getPassword(),
+ Utils.getIpv6Address(outbound.getServerAddress()!!),
+ outbound.getServerPort())
+ url + query + remark
+ }
+ }
+ } catch (e: Exception) {
+ e.printStackTrace()
+ return ""
+ }
+ }
+
+ /**
+ * share2Clipboard
+ */
+ fun share2Clipboard(context: Context, guid: String): Int {
+ try {
+ val conf = shareConfig(guid)
+ if (TextUtils.isEmpty(conf)) {
+ return -1
+ }
+
+ Utils.setClipboard(context, conf)
+
+ } catch (e: Exception) {
+ e.printStackTrace()
+ return -1
+ }
+ return 0
+ }
+
+ /**
+ * share2Clipboard
+ */
+ fun shareNonCustomConfigsToClipboard(context: Context, serverList: List): Int {
+ try {
+ val sb = StringBuilder()
+ for (guid in serverList) {
+ val url = shareConfig(guid)
+ if (TextUtils.isEmpty(url)) {
+ continue
+ }
+ sb.append(url)
+ sb.appendLine()
+ }
+ if (sb.count() > 0) {
+ Utils.setClipboard(context, sb.toString())
+ }
+ } catch (e: Exception) {
+ e.printStackTrace()
+ return -1
+ }
+ return 0
+ }
+
+ /**
+ * share2QRCode
+ */
+ fun share2QRCode(guid: String): Bitmap? {
+ try {
+ val conf = shareConfig(guid)
+ if (TextUtils.isEmpty(conf)) {
+ return null
+ }
+ return QRCodeDecoder.createQRCode(conf)
+
+ } catch (e: Exception) {
+ e.printStackTrace()
+ return null
+ }
+ }
+
+ /**
+ * shareFullContent2Clipboard
+ */
+ fun shareFullContent2Clipboard(context: Context, guid: String?): Int {
+ try {
+ if (guid == null) return -1
+ val result = V2rayConfigUtil.getV2rayConfig(context, guid)
+ if (result.status) {
+ Utils.setClipboard(context, result.content)
+ } else {
+ return -1
+ }
+ } catch (e: Exception) {
+ e.printStackTrace()
+ return -1
+ }
+ return 0
+ }
+
+ /**
+ * upgrade
+ */
+ private fun upgradeServerVersion(vmess: AngConfig.VmessBean): Int {
+ try {
+ if (vmess.configVersion == 2) {
+ return 0
+ }
+
+ when (vmess.network) {
+ "ws", "h2" -> {
+ var path = ""
+ var host = ""
+ val lstParameter = vmess.requestHost.split(";")
+ if (lstParameter.isNotEmpty()) {
+ path = lstParameter[0].trim()
+ }
+ if (lstParameter.size > 1) {
+ path = lstParameter[0].trim()
+ host = lstParameter[1].trim()
+ }
+ vmess.path = path
+ vmess.requestHost = host
+ }
+ }
+ vmess.configVersion = 2
+ return 0
+ } catch (e: Exception) {
+ e.printStackTrace()
+ return -1
+ }
+ }
+
+ fun importBatchConfig(servers: String?, subid: String, append: Boolean): Int {
+ try {
+ if (servers == null) {
+ return 0
+ }
+ val removedSelectedServer =
+ if (!TextUtils.isEmpty(subid) && !append) {
+ MmkvManager.decodeServerConfig(mainStorage?.decodeString(KEY_SELECTED_SERVER) ?: "")?.let {
+ if (it.subscriptionId == subid) {
+ return@let it
+ }
+ return@let null
+ }
+ } else {
+ null
+ }
+ if(!append) {
+ MmkvManager.removeServerViaSubid(subid)
+ }
+// var servers = server
+// if (server.indexOf("vmess") >= 0 && server.indexOf("vmess") == server.lastIndexOf("vmess")) {
+// servers = server.replace("\n", "")
+// }
+
+ var count = 0
+ servers.lines()
+ .reversed()
+ .forEach {
+ val resId = importConfig(it, subid, removedSelectedServer)
+ if (resId == 0) {
+ count++
+ }
+ }
+ return count
+ } catch (e: Exception) {
+ e.printStackTrace()
+ }
+ return 0
+ }
+}
diff --git a/app/src/main/kotlin/com/v2ray/ang/util/AppManagerUtil.kt b/app/src/main/kotlin/com/v2ray/ang/util/AppManagerUtil.kt
new file mode 100644
index 0000000..b95b408
--- /dev/null
+++ b/app/src/main/kotlin/com/v2ray/ang/util/AppManagerUtil.kt
@@ -0,0 +1,43 @@
+package com.v2ray.ang.util
+
+import android.Manifest
+import android.content.Context
+import android.content.pm.ApplicationInfo
+import android.content.pm.PackageInfo
+import android.content.pm.PackageManager
+import com.v2ray.ang.dto.AppInfo
+import rx.Observable
+import java.util.*
+
+object AppManagerUtil {
+ fun loadNetworkAppList(ctx: Context): ArrayList {
+ val packageManager = ctx.packageManager
+ val packages = packageManager.getInstalledPackages(PackageManager.GET_PERMISSIONS)
+ val apps = ArrayList()
+
+ for (pkg in packages) {
+ if (!pkg.hasInternetPermission && pkg.packageName != "android") continue
+
+ val applicationInfo = pkg.applicationInfo
+
+ val appName = applicationInfo.loadLabel(packageManager).toString()
+ val appIcon = applicationInfo.loadIcon(packageManager)
+ val isSystemApp = (applicationInfo.flags and ApplicationInfo.FLAG_SYSTEM) > 0
+
+ val appInfo = AppInfo(appName, pkg.packageName, appIcon, isSystemApp, 0)
+ apps.add(appInfo)
+ }
+
+ return apps
+ }
+
+ fun rxLoadNetworkAppList(ctx: Context): Observable> = Observable.unsafeCreate {
+ it.onNext(loadNetworkAppList(ctx))
+ }
+
+ val PackageInfo.hasInternetPermission: Boolean
+ get() {
+ val permissions = requestedPermissions
+ return permissions?.any { it == Manifest.permission.INTERNET } ?: false
+ }
+}
diff --git a/app/src/main/kotlin/com/v2ray/ang/util/MessageUtil.kt b/app/src/main/kotlin/com/v2ray/ang/util/MessageUtil.kt
new file mode 100644
index 0000000..0f521f5
--- /dev/null
+++ b/app/src/main/kotlin/com/v2ray/ang/util/MessageUtil.kt
@@ -0,0 +1,45 @@
+package com.v2ray.ang.util
+
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import com.v2ray.ang.AppConfig
+import com.v2ray.ang.service.V2RayTestService
+import java.io.Serializable
+
+
+object MessageUtil {
+
+ fun sendMsg2Service(ctx: Context, what: Int, content: Serializable) {
+ sendMsg(ctx, AppConfig.BROADCAST_ACTION_SERVICE, what, content)
+ }
+
+ fun sendMsg2UI(ctx: Context, what: Int, content: Serializable) {
+ sendMsg(ctx, AppConfig.BROADCAST_ACTION_ACTIVITY, what, content)
+ }
+
+ fun sendMsg2TestService(ctx: Context, what: Int, content: Serializable) {
+ try {
+ val intent = Intent()
+ intent.component = ComponentName(ctx, V2RayTestService::class.java)
+ intent.putExtra("key", what)
+ intent.putExtra("content", content)
+ ctx.startService(intent)
+ } catch (e: Exception) {
+ e.printStackTrace()
+ }
+ }
+
+ private fun sendMsg(ctx: Context, action: String, what: Int, content: Serializable) {
+ try {
+ val intent = Intent()
+ intent.action = action
+ intent.`package` = AppConfig.ANG_PACKAGE
+ intent.putExtra("key", what)
+ intent.putExtra("content", content)
+ ctx.sendBroadcast(intent)
+ } catch (e: Exception) {
+ e.printStackTrace()
+ }
+ }
+}
diff --git a/app/src/main/kotlin/com/v2ray/ang/util/MmkvManager.kt b/app/src/main/kotlin/com/v2ray/ang/util/MmkvManager.kt
new file mode 100644
index 0000000..a81a554
--- /dev/null
+++ b/app/src/main/kotlin/com/v2ray/ang/util/MmkvManager.kt
@@ -0,0 +1,179 @@
+package com.v2ray.ang.util
+
+import com.google.gson.Gson
+import com.tencent.mmkv.MMKV
+import com.v2ray.ang.dto.ServerAffiliationInfo
+import com.v2ray.ang.dto.ServerConfig
+import com.v2ray.ang.dto.SubscriptionItem
+
+object MmkvManager {
+ const val ID_MAIN = "MAIN"
+ const val ID_SERVER_CONFIG = "SERVER_CONFIG"
+ const val ID_SERVER_RAW = "SERVER_RAW"
+ const val ID_SERVER_AFF = "SERVER_AFF"
+ const val ID_SUB = "SUB"
+ const val ID_SETTING = "SETTING"
+ const val KEY_SELECTED_SERVER = "SELECTED_SERVER"
+ const val KEY_ANG_CONFIGS = "ANG_CONFIGS"
+
+ private val mainStorage by lazy { MMKV.mmkvWithID(ID_MAIN, MMKV.MULTI_PROCESS_MODE) }
+ private val serverStorage by lazy { MMKV.mmkvWithID(ID_SERVER_CONFIG, MMKV.MULTI_PROCESS_MODE) }
+ private val serverAffStorage by lazy { MMKV.mmkvWithID(ID_SERVER_AFF, MMKV.MULTI_PROCESS_MODE) }
+ private val subStorage by lazy { MMKV.mmkvWithID(ID_SUB, MMKV.MULTI_PROCESS_MODE) }
+
+ fun decodeServerList(): MutableList {
+ val json = mainStorage?.decodeString(KEY_ANG_CONFIGS)
+ return if (json.isNullOrBlank()) {
+ mutableListOf()
+ } else {
+ Gson().fromJson(json, Array::class.java).toMutableList()
+ }
+ }
+
+ fun decodeServerConfig(guid: String): ServerConfig? {
+ if (guid.isBlank()) {
+ return null
+ }
+ val json = serverStorage?.decodeString(guid)
+ if (json.isNullOrBlank()) {
+ return null
+ }
+ return Gson().fromJson(json, ServerConfig::class.java)
+ }
+
+ fun encodeServerConfig(guid: String, config: ServerConfig): String {
+ val key = guid.ifBlank { Utils.getUuid() }
+ serverStorage?.encode(key, Gson().toJson(config))
+ val serverList = decodeServerList()
+ if (!serverList.contains(key)) {
+ serverList.add(0,key)
+ mainStorage?.encode(KEY_ANG_CONFIGS, Gson().toJson(serverList))
+ if (mainStorage?.decodeString(KEY_SELECTED_SERVER).isNullOrBlank()) {
+ mainStorage?.encode(KEY_SELECTED_SERVER, key)
+ }
+ }
+ return key
+ }
+
+ fun removeServer(guid: String) {
+ if (guid.isBlank()) {
+ return
+ }
+ if (mainStorage?.decodeString(KEY_SELECTED_SERVER) == guid) {
+ mainStorage?.remove(KEY_SELECTED_SERVER)
+ }
+ val serverList = decodeServerList()
+ serverList.remove(guid)
+ mainStorage?.encode(KEY_ANG_CONFIGS, Gson().toJson(serverList))
+ serverStorage?.remove(guid)
+ serverAffStorage?.remove(guid)
+ }
+
+ fun removeServerViaSubid(subid: String) {
+ if (subid.isBlank()) {
+ return
+ }
+ serverStorage?.allKeys()?.forEach { key ->
+ decodeServerConfig(key)?.let { config ->
+ if (config.subscriptionId == subid) {
+ removeServer(key)
+ }
+ }
+ }
+ }
+
+ fun decodeServerAffiliationInfo(guid: String): ServerAffiliationInfo? {
+ if (guid.isBlank()) {
+ return null
+ }
+ val json = serverAffStorage?.decodeString(guid)
+ if (json.isNullOrBlank()) {
+ return null
+ }
+ return Gson().fromJson(json, ServerAffiliationInfo::class.java)
+ }
+
+ fun encodeServerTestDelayMillis(guid: String, testResult: Long) {
+ if (guid.isBlank()) {
+ return
+ }
+ val aff = decodeServerAffiliationInfo(guid) ?: ServerAffiliationInfo()
+ aff.testDelayMillis = testResult
+ serverAffStorage?.encode(guid, Gson().toJson(aff))
+ }
+
+ fun clearAllTestDelayResults() {
+ serverAffStorage?.allKeys()?.forEach { key ->
+ decodeServerAffiliationInfo(key)?.let { aff ->
+ aff.testDelayMillis = 0
+ serverAffStorage?.encode(key, Gson().toJson(aff))
+ }
+ }
+ }
+
+ fun importUrlAsSubscription(url: String): Int {
+ val subscriptions = decodeSubscriptions()
+ subscriptions.forEach {
+ if (it.second.url == url) {
+ return 0
+ }
+ }
+ val subItem = SubscriptionItem()
+ subItem.remarks = "import sub"
+ subItem.url = url
+ subStorage?.encode(Utils.getUuid(), Gson().toJson(subItem))
+ return 1
+ }
+
+ fun decodeSubscriptions(): List> {
+ val subscriptions = mutableListOf>()
+ subStorage?.allKeys()?.forEach { key ->
+ val json = subStorage?.decodeString(key)
+ if (!json.isNullOrBlank()) {
+ subscriptions.add(Pair(key, Gson().fromJson(json, SubscriptionItem::class.java)))
+ }
+ }
+ subscriptions.sortedBy { (_, value) -> value.addedTime }
+ return subscriptions
+ }
+
+ fun removeSubscription(subid: String) {
+ subStorage?.remove(subid)
+ removeServerViaSubid(subid)
+ }
+
+ fun removeAllServer() {
+ mainStorage?.clearAll()
+ serverStorage?.clearAll()
+ serverAffStorage?.clearAll()
+ }
+
+ fun removeInvalidServer() {
+ serverAffStorage?.allKeys()?.forEach { key ->
+ decodeServerAffiliationInfo(key)?.let { aff ->
+ if (aff.testDelayMillis <= 0L) {
+ removeServer(key)
+ }
+ }
+ }
+ }
+
+ fun sortByTestResults( ) {
+ data class ServerDelay(var guid: String, var testDelayMillis: Long)
+
+ val serverDelays = mutableListOf()
+ val serverList = decodeServerList()
+ serverList.forEach { key ->
+ val delay = decodeServerAffiliationInfo(key)?.testDelayMillis ?: 0L
+ serverDelays.add(ServerDelay(key, if (delay <= 0L) 999999 else delay))
+ }
+ serverDelays.sortBy { it.testDelayMillis }
+
+ serverDelays.forEach {
+ serverList.remove(it.guid)
+ serverList.add(it.guid)
+ }
+
+ mainStorage?.encode(KEY_ANG_CONFIGS, Gson().toJson(serverList))
+ }
+}
diff --git a/app/src/main/kotlin/com/v2ray/ang/util/MyContextWrapper.kt b/app/src/main/kotlin/com/v2ray/ang/util/MyContextWrapper.kt
new file mode 100644
index 0000000..cf9bd35
--- /dev/null
+++ b/app/src/main/kotlin/com/v2ray/ang/util/MyContextWrapper.kt
@@ -0,0 +1,33 @@
+package com.v2ray.ang.util
+
+import android.content.Context
+import android.content.ContextWrapper
+import android.content.res.Configuration
+import android.content.res.Resources
+import android.os.Build
+import android.os.LocaleList
+import androidx.annotation.RequiresApi
+import java.util.*
+
+open class MyContextWrapper(base: Context?) : ContextWrapper(base) {
+ companion object {
+ @RequiresApi(Build.VERSION_CODES.N)
+ fun wrap(context: Context, newLocale: Locale?): ContextWrapper {
+ var mContext = context
+ val res: Resources = mContext.resources
+ val configuration: Configuration = res.configuration
+ //注意 Android 7.0 前后的不同处理方法
+ mContext = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ configuration.setLocale(newLocale)
+ val localeList = LocaleList(newLocale)
+ LocaleList.setDefault(localeList)
+ configuration.setLocales(localeList)
+ mContext.createConfigurationContext(configuration)
+ } else {
+ configuration.setLocale(newLocale)
+ mContext.createConfigurationContext(configuration)
+ }
+ return ContextWrapper(mContext)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/v2ray/ang/util/QRCodeDecoder.kt b/app/src/main/kotlin/com/v2ray/ang/util/QRCodeDecoder.kt
new file mode 100644
index 0000000..38b3983
--- /dev/null
+++ b/app/src/main/kotlin/com/v2ray/ang/util/QRCodeDecoder.kt
@@ -0,0 +1,132 @@
+package com.v2ray.ang.util
+
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import com.google.zxing.*
+import com.google.zxing.common.GlobalHistogramBinarizer
+import com.google.zxing.common.HybridBinarizer
+import com.google.zxing.qrcode.QRCodeWriter
+import java.util.*
+import kotlin.collections.HashMap
+
+/**
+ * 描述:解析二维码图片
+ */
+object QRCodeDecoder {
+ val HINTS: MutableMap = EnumMap(DecodeHintType::class.java)
+
+ /**
+ * Create QRCode using zxing
+ */
+ fun createQRCode(text: String, size: Int = 800): Bitmap? {
+ try {
+ val hints = HashMap()
+ hints[EncodeHintType.CHARACTER_SET] = "utf-8"
+ val bitMatrix = QRCodeWriter().encode(text,
+ BarcodeFormat.QR_CODE, size, size, hints)
+ val pixels = IntArray(size * size)
+ for (y in 0 until size) {
+ for (x in 0 until size) {
+ if (bitMatrix.get(x, y)) {
+ pixels[y * size + x] = 0xff000000.toInt()
+ } else {
+ pixels[y * size + x] = 0xffffffff.toInt()
+ }
+
+ }
+ }
+ val bitmap = Bitmap.createBitmap(size, size,
+ Bitmap.Config.ARGB_8888)
+ bitmap.setPixels(pixels, 0, size, 0, 0, size, size)
+ return bitmap
+ } catch (e: Exception) {
+ e.printStackTrace()
+ return null
+ }
+ }
+
+ /**
+ * 同步解析本地图片二维码。该方法是耗时操作,请在子线程中调用。
+ *
+ * @param picturePath 要解析的二维码图片本地路径
+ * @return 返回二维码图片里的内容 或 null
+ */
+ fun syncDecodeQRCode(picturePath: String): String? {
+ return syncDecodeQRCode(getDecodeAbleBitmap(picturePath))
+ }
+
+ /**
+ * 同步解析bitmap二维码。该方法是耗时操作,请在子线程中调用。
+ *
+ * @param bitmap 要解析的二维码图片
+ * @return 返回二维码图片里的内容 或 null
+ */
+ fun syncDecodeQRCode(bitmap: Bitmap?): String? {
+ var source: RGBLuminanceSource? = null
+ try {
+ val width = bitmap!!.width
+ val height = bitmap.height
+ val pixels = IntArray(width * height)
+ bitmap.getPixels(pixels, 0, width, 0, 0, width, height)
+ source = RGBLuminanceSource(width, height, pixels)
+ return MultiFormatReader().decode(BinaryBitmap(HybridBinarizer(source)), HINTS).text
+ } catch (e: Exception) {
+ e.printStackTrace()
+ }
+ if (source != null) {
+ try {
+ return MultiFormatReader().decode(BinaryBitmap(GlobalHistogramBinarizer(source)), HINTS).text
+ } catch (e2: Throwable) {
+ e2.printStackTrace()
+ }
+ }
+ return null
+ }
+
+ /**
+ * 将本地图片文件转换成可解码二维码的 Bitmap。为了避免图片太大,这里对图片进行了压缩。感谢 https://github.com/devilsen 提的 PR
+ *
+ * @param picturePath 本地图片文件路径
+ * @return
+ */
+ private fun getDecodeAbleBitmap(picturePath: String): Bitmap? {
+ return try {
+ val options = BitmapFactory.Options()
+ options.inJustDecodeBounds = true
+ BitmapFactory.decodeFile(picturePath, options)
+ var sampleSize = options.outHeight / 400
+ if (sampleSize <= 0) {
+ sampleSize = 1
+ }
+ options.inSampleSize = sampleSize
+ options.inJustDecodeBounds = false
+ BitmapFactory.decodeFile(picturePath, options)
+ } catch (e: Exception) {
+ null
+ }
+ }
+
+ init {
+ val allFormats: List = arrayListOf(
+ BarcodeFormat.AZTEC
+ ,BarcodeFormat.CODABAR
+ ,BarcodeFormat.CODE_39
+ ,BarcodeFormat.CODE_93
+ ,BarcodeFormat.CODE_128
+ ,BarcodeFormat.DATA_MATRIX
+ ,BarcodeFormat.EAN_8
+ ,BarcodeFormat.EAN_13
+ ,BarcodeFormat.ITF
+ ,BarcodeFormat.MAXICODE
+ ,BarcodeFormat.PDF_417
+ ,BarcodeFormat.QR_CODE
+ ,BarcodeFormat.RSS_14
+ ,BarcodeFormat.RSS_EXPANDED
+ ,BarcodeFormat.UPC_A
+ ,BarcodeFormat.UPC_E
+ ,BarcodeFormat.UPC_EAN_EXTENSION)
+ HINTS[DecodeHintType.TRY_HARDER] = BarcodeFormat.QR_CODE
+ HINTS[DecodeHintType.POSSIBLE_FORMATS] = allFormats
+ HINTS[DecodeHintType.CHARACTER_SET] = "utf-8"
+ }
+}
diff --git a/app/src/main/kotlin/com/v2ray/ang/util/SpeedtestUtil.kt b/app/src/main/kotlin/com/v2ray/ang/util/SpeedtestUtil.kt
new file mode 100644
index 0000000..1999ddf
--- /dev/null
+++ b/app/src/main/kotlin/com/v2ray/ang/util/SpeedtestUtil.kt
@@ -0,0 +1,142 @@
+package com.v2ray.ang.util
+
+import android.content.Context
+import android.os.SystemClock
+import android.text.TextUtils
+import android.util.Log
+import com.v2ray.ang.AppConfig
+import com.v2ray.ang.R
+import com.v2ray.ang.extension.responseLength
+import kotlinx.coroutines.isActive
+import libv2ray.Libv2ray
+import java.io.IOException
+import java.net.*
+import java.util.*
+import kotlin.coroutines.coroutineContext
+
+object SpeedtestUtil {
+
+ private val tcpTestingSockets = ArrayList()
+
+ suspend fun tcping(url: String, port: Int): Long {
+ var time = -1L
+ for (k in 0 until 2) {
+ val one = socketConnectTime(url, port)
+ if (!coroutineContext.isActive) {
+ break
+ }
+ if (one != -1L && (time == -1L || one < time)) {
+ time = one
+ }
+ }
+ return time
+ }
+
+ fun realPing(config: String): Long {
+ return try {
+ Libv2ray.measureOutboundDelay(config)
+ } catch (e: Exception) {
+ Log.d(AppConfig.ANG_PACKAGE, "realPing: $e")
+ -1L
+ }
+ }
+
+ fun ping(url: String): String {
+ try {
+ val command = "/system/bin/ping -c 3 $url"
+ val process = Runtime.getRuntime().exec(command)
+ val allText = process.inputStream.bufferedReader().use { it.readText() }
+ if (!TextUtils.isEmpty(allText)) {
+ val tempInfo = allText.substring(allText.indexOf("min/avg/max/mdev") + 19)
+ val temps = tempInfo.split("/".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
+ if (temps.count() > 0 && temps[0].length < 9999) {
+ return temps[0].toFloat().toInt().toString() + "ms"
+ }
+ }
+ } catch (e: Exception) {
+ e.printStackTrace()
+ }
+ return "-1ms"
+ }
+
+ fun socketConnectTime(url: String, port: Int): Long {
+ try {
+ val socket = Socket()
+ synchronized(this) {
+ tcpTestingSockets.add(socket)
+ }
+ val start = System.currentTimeMillis()
+ socket.connect(InetSocketAddress(url, port),3000)
+ val time = System.currentTimeMillis() - start
+ synchronized(this) {
+ tcpTestingSockets.remove(socket)
+ }
+ socket.close()
+ return time
+ } catch (e: UnknownHostException) {
+ e.printStackTrace()
+ } catch (e: IOException) {
+ Log.d(AppConfig.ANG_PACKAGE, "socketConnectTime IOException: $e")
+ } catch (e: Exception) {
+ e.printStackTrace()
+ }
+ return -1
+ }
+
+ fun closeAllTcpSockets() {
+ synchronized(this) {
+ tcpTestingSockets.forEach {
+ it?.close()
+ }
+ tcpTestingSockets.clear()
+ }
+ }
+
+ fun testConnection(context: Context, port: Int): String {
+ // return V2RayVpnService.measureV2rayDelay()
+ var result: String
+ var conn: HttpURLConnection? = null
+
+ try {
+ val url = URL("https",
+ "www.google.com",
+ "/generate_204")
+
+ conn = url.openConnection(
+ Proxy(Proxy.Type.HTTP,
+ InetSocketAddress("127.0.0.1", port))) as HttpURLConnection
+ conn.connectTimeout = 30000
+ conn.readTimeout = 30000
+ conn.setRequestProperty("Connection", "close")
+ conn.instanceFollowRedirects = false
+ conn.useCaches = false
+
+ val start = SystemClock.elapsedRealtime()
+ val code = conn.responseCode
+ val elapsed = SystemClock.elapsedRealtime() - start
+
+ if (code == 204 || code == 200 && conn.responseLength == 0L) {
+ result = context.getString(R.string.connection_test_available, elapsed)
+ } else {
+ throw IOException(context.getString(R.string.connection_test_error_status_code, code))
+ }
+ } catch (e: IOException) {
+ // network exception
+ Log.d(AppConfig.ANG_PACKAGE, "testConnection IOException: " + Log.getStackTraceString(e))
+ result = context.getString(R.string.connection_test_error, e.message)
+ } catch (e: Exception) {
+ // library exception, eg sumsung
+ Log.d(AppConfig.ANG_PACKAGE, "testConnection Exception: " + Log.getStackTraceString(e))
+ result = context.getString(R.string.connection_test_error, e.message)
+ } finally {
+ conn?.disconnect()
+ }
+
+ return result
+ }
+
+ fun getLibVersion(): String {
+ return Libv2ray.checkVersionX()
+ }
+
+}
diff --git a/app/src/main/kotlin/com/v2ray/ang/util/Utils.kt b/app/src/main/kotlin/com/v2ray/ang/util/Utils.kt
new file mode 100644
index 0000000..4baaca0
--- /dev/null
+++ b/app/src/main/kotlin/com/v2ray/ang/util/Utils.kt
@@ -0,0 +1,404 @@
+package com.v2ray.ang.util
+
+import android.content.ClipboardManager
+import android.content.Context
+import android.text.Editable
+import android.util.Base64
+import java.util.*
+import android.content.ClipData
+import android.content.Intent
+import android.content.res.Configuration.UI_MODE_NIGHT_MASK
+import android.content.res.Configuration.UI_MODE_NIGHT_YES
+import android.net.Uri
+import android.os.Build
+import android.os.LocaleList
+import android.util.Log
+import android.util.Patterns
+import android.webkit.URLUtil
+import com.tencent.mmkv.MMKV
+import com.v2ray.ang.AppConfig
+import com.v2ray.ang.AppConfig.ANG_PACKAGE
+import com.v2ray.ang.BuildConfig
+import com.v2ray.ang.R
+import com.v2ray.ang.extension.toast
+import java.net.*
+import com.v2ray.ang.service.V2RayServiceManager
+import java.io.IOException
+
+object Utils {
+
+ private val mainStorage by lazy { MMKV.mmkvWithID(MmkvManager.ID_MAIN, MMKV.MULTI_PROCESS_MODE) }
+ private val settingsStorage by lazy { MMKV.mmkvWithID(MmkvManager.ID_SETTING, MMKV.MULTI_PROCESS_MODE) }
+
+ /**
+ * convert string to editalbe for kotlin
+ *
+ * @param text
+ * @return
+ */
+ fun getEditable(text: String): Editable {
+ return Editable.Factory.getInstance().newEditable(text)
+ }
+
+ /**
+ * find value in array position
+ */
+ fun arrayFind(array: Array, value: String): Int {
+ for (i in array.indices) {
+ if (array[i] == value) {
+ return i
+ }
+ }
+ return -1
+ }
+
+ /**
+ * parseInt
+ */
+ fun parseInt(str: String): Int {
+ return parseInt(str, 0)
+ }
+
+ fun parseInt(str: String?, default: Int): Int {
+ str ?: return default
+ return try {
+ Integer.parseInt(str)
+ } catch (e: Exception) {
+ e.printStackTrace()
+ default
+ }
+ }
+
+ /**
+ * get text from clipboard
+ */
+ fun getClipboard(context: Context): String {
+ return try {
+ val cmb = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
+ cmb.primaryClip?.getItemAt(0)?.text.toString()
+ } catch (e: Exception) {
+ e.printStackTrace()
+ ""
+ }
+ }
+
+ /**
+ * set text to clipboard
+ */
+ fun setClipboard(context: Context, content: String) {
+ try {
+ val cmb = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
+ val clipData = ClipData.newPlainText(null, content)
+ cmb.setPrimaryClip(clipData)
+ } catch (e: Exception) {
+ e.printStackTrace()
+ }
+ }
+
+ /**
+ * base64 decode
+ */
+ fun decode(text: String): String {
+ tryDecodeBase64(text)?.let { return it }
+ if (text.endsWith('=')) {
+ // try again for some loosely formatted base64
+ tryDecodeBase64(text.trimEnd('='))?.let { return it }
+ }
+ return ""
+ }
+
+ fun tryDecodeBase64(text: String): String? {
+ try {
+ return Base64.decode(text, Base64.NO_WRAP).toString(charset("UTF-8"))
+ } catch (e: Exception) {
+ Log.i(ANG_PACKAGE, "Parse base64 standard failed $e")
+ }
+ try {
+ return Base64.decode(text, Base64.NO_WRAP.or(Base64.URL_SAFE)).toString(charset("UTF-8"))
+ } catch (e: Exception) {
+ Log.i(ANG_PACKAGE, "Parse base64 url safe failed $e")
+ }
+ return null
+ }
+
+ /**
+ * base64 encode
+ */
+ fun encode(text: String): String {
+ return try {
+ Base64.encodeToString(text.toByteArray(charset("UTF-8")), Base64.NO_WRAP)
+ } catch (e: Exception) {
+ e.printStackTrace()
+ ""
+ }
+ }
+
+ /**
+ * get remote dns servers from preference
+ */
+ fun getRemoteDnsServers(): List {
+ val remoteDns = settingsStorage?.decodeString(AppConfig.PREF_REMOTE_DNS) ?: AppConfig.DNS_AGENT
+ val ret = remoteDns.split(",").filter { isPureIpAddress(it) || isCoreDNSAddress(it) }
+ if (ret.isEmpty()) {
+ return listOf(AppConfig.DNS_AGENT)
+ }
+ return ret
+ }
+
+ fun getVpnDnsServers(): List {
+ val vpnDns = settingsStorage?.decodeString(AppConfig.PREF_VPN_DNS)
+ ?: settingsStorage?.decodeString(AppConfig.PREF_REMOTE_DNS)
+ ?: AppConfig.DNS_AGENT
+ return vpnDns.split(",").filter { isPureIpAddress(it) }
+ // allow empty, in that case dns will use system default
+ }
+
+ /**
+ * get remote dns servers from preference
+ */
+ fun getDomesticDnsServers(): List {
+ val domesticDns = settingsStorage?.decodeString(AppConfig.PREF_DOMESTIC_DNS) ?: AppConfig.DNS_DIRECT
+ val ret = domesticDns.split(",").filter { isPureIpAddress(it) || isCoreDNSAddress(it) }
+ if (ret.isEmpty()) {
+ return listOf(AppConfig.DNS_DIRECT)
+ }
+ return ret
+ }
+
+
+ /**
+ * is ip address
+ */
+ fun isIpAddress(value: String): Boolean {
+ try {
+ var addr = value
+ if (addr.isEmpty() || addr.isBlank()) {
+ return false
+ }
+ //CIDR
+ if (addr.indexOf("/") > 0) {
+ val arr = addr.split("/")
+ if (arr.count() == 2 && Integer.parseInt(arr[1]) > 0) {
+ addr = arr[0]
+ }
+ }
+
+ // "::ffff:192.168.173.22"
+ // "[::ffff:192.168.173.22]:80"
+ if (addr.startsWith("::ffff:") && '.' in addr) {
+ addr = addr.drop(7)
+ } else if (addr.startsWith("[::ffff:") && '.' in addr) {
+ addr = addr.drop(8).replace("]", "")
+ }
+
+ // addr = addr.toLowerCase()
+ val octets = addr.split('.').toTypedArray()
+ if (octets.size == 4) {
+ if (octets[3].indexOf(":") > 0) {
+ addr = addr.substring(0, addr.indexOf(":"))
+ }
+ return isIpv4Address(addr)
+ }
+
+ // Ipv6addr [2001:abc::123]:8080
+ return isIpv6Address(addr)
+ } catch (e: Exception) {
+ e.printStackTrace()
+ return false
+ }
+ }
+
+ fun isPureIpAddress(value: String): Boolean {
+ return (isIpv4Address(value) || isIpv6Address(value))
+ }
+
+ fun isIpv4Address(value: String): Boolean {
+ val regV4 = Regex("^([01]?[0-9]?[0-9]|2[0-4][0-9]|25[0-5])\\.([01]?[0-9]?[0-9]|2[0-4][0-9]|25[0-5])\\.([01]?[0-9]?[0-9]|2[0-4][0-9]|25[0-5])\\.([01]?[0-9]?[0-9]|2[0-4][0-9]|25[0-5])$")
+ return regV4.matches(value)
+ }
+
+ fun isIpv6Address(value: String): Boolean {
+ var addr = value
+ if (addr.indexOf("[") == 0 && addr.lastIndexOf("]") > 0) {
+ addr = addr.drop(1)
+ addr = addr.dropLast(addr.count() - addr.lastIndexOf("]"))
+ }
+ val regV6 = Regex("^((?:[0-9A-Fa-f]{1,4}))?((?::[0-9A-Fa-f]{1,4}))*::((?:[0-9A-Fa-f]{1,4}))?((?::[0-9A-Fa-f]{1,4}))*|((?:[0-9A-Fa-f]{1,4}))((?::[0-9A-Fa-f]{1,4})){7}$")
+ return regV6.matches(addr)
+ }
+
+ private fun isCoreDNSAddress(s: String): Boolean {
+ return s.startsWith("https") || s.startsWith("tcp") || s.startsWith("quic")
+ }
+
+ /**
+ * is valid url
+ */
+ fun isValidUrl(value: String?): Boolean {
+ try {
+ if (value != null && Patterns.WEB_URL.matcher(value).matches() || URLUtil.isValidUrl(value)) {
+ return true
+ }
+ } catch (e: Exception) {
+ e.printStackTrace()
+ return false
+ }
+ return false
+ }
+
+ fun startVServiceFromToggle(context: Context): Boolean {
+ if (mainStorage?.decodeString(MmkvManager.KEY_SELECTED_SERVER).isNullOrEmpty()) {
+ context.toast(R.string.app_tile_first_use)
+ return false
+ }
+ V2RayServiceManager.startV2Ray(context)
+ return true
+ }
+
+ /**
+ * stopVService
+ */
+ fun stopVService(context: Context) {
+ context.toast(R.string.toast_services_stop)
+ MessageUtil.sendMsg2Service(context, AppConfig.MSG_STATE_STOP, "")
+ }
+
+ fun openUri(context: Context, uriString: String) {
+ val uri = Uri.parse(uriString)
+ context.startActivity(Intent(Intent.ACTION_VIEW, uri))
+ }
+
+ /**
+ * uuid
+ */
+ fun getUuid(): String {
+ return try {
+ UUID.randomUUID().toString().replace("-", "")
+ } catch (e: Exception) {
+ e.printStackTrace()
+ ""
+ }
+ }
+
+ fun urlDecode(url: String): String {
+ return try {
+ URLDecoder.decode(URLDecoder.decode(url), "utf-8")
+ } catch (e: Exception) {
+ e.printStackTrace()
+ url
+ }
+ }
+
+ fun urlEncode(url: String): String {
+ return try {
+ URLEncoder.encode(url, "UTF-8")
+ } catch (e: Exception) {
+ e.printStackTrace()
+ url
+ }
+ }
+
+
+ /**
+ * readTextFromAssets
+ */
+ fun readTextFromAssets(context: Context, fileName: String): String {
+ val content = context.assets.open(fileName).bufferedReader().use {
+ it.readText()
+ }
+ return content
+ }
+
+ fun userAssetPath(context: Context?): String {
+ if (context == null)
+ return ""
+ val extDir = context.getExternalFilesDir(AppConfig.DIR_ASSETS)
+ ?: return context.getDir(AppConfig.DIR_ASSETS, 0).absolutePath
+ return extDir.absolutePath
+ }
+
+ fun getUrlContext(url: String, timeout: Int): String {
+ var result: String
+ var conn: HttpURLConnection? = null
+
+ try {
+ conn = URL(url).openConnection() as HttpURLConnection
+ conn.connectTimeout = timeout
+ conn.readTimeout = timeout
+ conn.setRequestProperty("Connection", "close")
+ conn.instanceFollowRedirects = false
+ conn.useCaches = false
+ //val code = conn.responseCode
+ result = conn.inputStream.bufferedReader().readText()
+ } catch (e: Exception) {
+ result = ""
+ } finally {
+ conn?.disconnect()
+ }
+ return result
+ }
+
+ @Throws(IOException::class)
+ fun getUrlContentWithCustomUserAgent(urlStr: String?): String {
+ val url = URL(urlStr)
+ val conn = url.openConnection()
+ conn.setRequestProperty("Connection", "close")
+ conn.setRequestProperty("User-agent", "v2rayNG/${BuildConfig.VERSION_NAME}")
+ url.userInfo?.let {
+ conn.setRequestProperty("Authorization",
+ "Basic ${encode(urlDecode(it))}")
+ }
+ conn.useCaches = false
+ return conn.inputStream.use {
+ it.bufferedReader().readText()
+ }
+ }
+
+ fun getDarkModeStatus(context: Context): Boolean {
+ val mode = context.resources.configuration.uiMode and UI_MODE_NIGHT_MASK
+ return mode == UI_MODE_NIGHT_YES
+ }
+
+ fun getIpv6Address(address: String): String {
+ return if (isIpv6Address(address)) {
+ String.format("[%s]", address)
+ } else {
+ address
+ }
+ }
+
+ fun getLocale(context: Context): Locale =
+ when (settingsStorage?.decodeString(AppConfig.PREF_LANGUAGE) ?: "auto") {
+ "auto" -> getSysLocale()
+ "en" -> Locale("en")
+ "zh-rCN" -> Locale("zh", "CN")
+ "zh-rTW" -> Locale("zh", "TW")
+ "vi" -> Locale("vi")
+ "ru" -> Locale("ru")
+ "fa" -> Locale("fa")
+ else -> getSysLocale()
+ }
+
+ private fun getSysLocale(): Locale = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ LocaleList.getDefault()[0]
+ } else {
+ Locale.getDefault()
+ }
+
+ fun fixIllegalUrl(str: String): String {
+ return str
+ .replace(" ","%20")
+ .replace("|","%7C")
+ }
+
+ fun removeWhiteSpace(str: String?): String? {
+ return str?.replace(" ", "")
+ }
+
+ fun idnToASCII(str: String): String {
+ val url = URL(str)
+ return URL(url.protocol, IDN.toASCII(url.host, IDN.ALLOW_UNASSIGNED), url.port, url.file)
+ .toExternalForm()
+ }
+}
+
diff --git a/app/src/main/kotlin/com/v2ray/ang/util/V2rayConfigUtil.kt b/app/src/main/kotlin/com/v2ray/ang/util/V2rayConfigUtil.kt
new file mode 100644
index 0000000..acf69ed
--- /dev/null
+++ b/app/src/main/kotlin/com/v2ray/ang/util/V2rayConfigUtil.kt
@@ -0,0 +1,537 @@
+package com.v2ray.ang.util
+
+import android.content.Context
+import android.text.TextUtils
+import android.util.Log
+import com.google.gson.*
+import com.tencent.mmkv.MMKV
+import com.v2ray.ang.AppConfig
+import com.v2ray.ang.AppConfig.ANG_PACKAGE
+import com.v2ray.ang.dto.V2rayConfig
+import com.v2ray.ang.dto.EConfigType
+import com.v2ray.ang.dto.ERoutingMode
+import com.v2ray.ang.dto.V2rayConfig.Companion.DEFAULT_NETWORK
+import com.v2ray.ang.dto.V2rayConfig.Companion.HTTP
+
+object V2rayConfigUtil {
+ private val serverRawStorage by lazy {
+ MMKV.mmkvWithID(
+ MmkvManager.ID_SERVER_RAW,
+ MMKV.MULTI_PROCESS_MODE
+ )
+ }
+ private val settingsStorage by lazy {
+ MMKV.mmkvWithID(
+ MmkvManager.ID_SETTING,
+ MMKV.MULTI_PROCESS_MODE
+ )
+ }
+
+ data class Result(var status: Boolean, var content: String)
+
+ /**
+ * 生成v2ray的客户端配置文件
+ */
+ fun getV2rayConfig(context: Context, guid: String): Result {
+ try {
+ val config = MmkvManager.decodeServerConfig(guid) ?: return Result(false, "")
+ if (config.configType == EConfigType.CUSTOM) {
+ val raw = serverRawStorage?.decodeString(guid)
+ val customConfig = if (raw.isNullOrBlank()) {
+ config.fullConfig?.toPrettyPrinting() ?: return Result(false, "")
+ } else {
+ raw
+ }
+ Log.d(ANG_PACKAGE, customConfig)
+ return Result(true, customConfig)
+ }
+ val outbound = config.getProxyOutbound() ?: return Result(false, "")
+ val result = getV2rayNonCustomConfig(context, outbound)
+ Log.d(ANG_PACKAGE, result.content)
+ return result
+ } catch (e: Exception) {
+ e.printStackTrace()
+ return Result(false, "")
+ }
+ }
+
+ /**
+ * 生成v2ray的客户端配置文件
+ */
+ private fun getV2rayNonCustomConfig(
+ context: Context,
+ outbound: V2rayConfig.OutboundBean
+ ): Result {
+ val result = Result(false, "")
+ //取得默认配置
+ val assets = Utils.readTextFromAssets(context, "v2ray_config.json")
+ if (TextUtils.isEmpty(assets)) {
+ return result
+ }
+
+ //转成Json
+ val v2rayConfig = Gson().fromJson(assets, V2rayConfig::class.java) ?: return result
+
+ v2rayConfig.log.loglevel = settingsStorage?.decodeString(AppConfig.PREF_LOGLEVEL)
+ ?: "warning"
+
+ inbounds(v2rayConfig)
+
+ httpRequestObject(outbound)
+
+ mux(outbound)
+
+ v2rayConfig.outbounds[0] = outbound
+
+ routing(v2rayConfig)
+
+ fakedns(v2rayConfig)
+
+ dns(v2rayConfig)
+
+ if (settingsStorage?.decodeBool(AppConfig.PREF_LOCAL_DNS_ENABLED) == true) {
+ customLocalDns(v2rayConfig)
+ }
+ if (settingsStorage?.decodeBool(AppConfig.PREF_SPEED_ENABLED) != true) {
+ v2rayConfig.stats = null
+ v2rayConfig.policy = null
+ }
+ result.status = true
+ result.content = v2rayConfig.toPrettyPrinting()
+ return result
+ }
+
+ /**
+ *
+ */
+ private fun inbounds(v2rayConfig: V2rayConfig): Boolean {
+ try {
+ val socksPort = Utils.parseInt(
+ settingsStorage?.decodeString(AppConfig.PREF_SOCKS_PORT),
+ AppConfig.PORT_SOCKS.toInt()
+ )
+ val httpPort = Utils.parseInt(
+ settingsStorage?.decodeString(AppConfig.PREF_HTTP_PORT),
+ AppConfig.PORT_HTTP.toInt()
+ )
+
+ v2rayConfig.inbounds.forEach { curInbound ->
+ if (settingsStorage?.decodeBool(AppConfig.PREF_PROXY_SHARING) != true) {
+ //bind all inbounds to localhost if the user requests
+ curInbound.listen = "127.0.0.1"
+ }
+ }
+ v2rayConfig.inbounds[0].port = socksPort
+ val fakedns = settingsStorage?.decodeBool(AppConfig.PREF_FAKE_DNS_ENABLED)
+ ?: false
+ val sniffAllTlsAndHttp =
+ settingsStorage?.decodeBool(AppConfig.PREF_SNIFFING_ENABLED, true)
+ ?: true
+ v2rayConfig.inbounds[0].sniffing?.disabled = fakedns || sniffAllTlsAndHttp
+ if (!sniffAllTlsAndHttp) {
+ v2rayConfig.inbounds[0].sniffing?.destOverride?.clear()
+ }
+ if (fakedns) {
+ v2rayConfig.inbounds[0].sniffing?.destOverride?.add("fakedns")
+ }
+
+ v2rayConfig.inbounds[1].port = httpPort
+
+// if (httpPort > 0) {
+// val httpCopy = v2rayConfig.inbounds[0].copy()
+// httpCopy.port = httpPort
+// httpCopy.protocol = "http"
+// v2rayConfig.inbounds.add(httpCopy)
+// }
+ } catch (e: Exception) {
+ e.printStackTrace()
+ return false
+ }
+ return true
+ }
+
+ private fun fakedns(v2rayConfig: V2rayConfig) {
+ if (settingsStorage?.decodeBool(AppConfig.PREF_FAKE_DNS_ENABLED) == true) {
+ v2rayConfig.fakedns = listOf(V2rayConfig.FakednsBean())
+ v2rayConfig.outbounds.filter { it.protocol == "freedom" }.forEach {
+ it.settings?.domainStrategy = "UseIP"
+ }
+ }
+ }
+
+ /**
+ * routing
+ */
+ private fun routing(v2rayConfig: V2rayConfig): Boolean {
+ try {
+ routingUserRule(
+ settingsStorage?.decodeString(AppConfig.PREF_V2RAY_ROUTING_AGENT)
+ ?: "", AppConfig.TAG_AGENT, v2rayConfig
+ )
+ routingUserRule(
+ settingsStorage?.decodeString(AppConfig.PREF_V2RAY_ROUTING_DIRECT)
+ ?: "", AppConfig.TAG_DIRECT, v2rayConfig
+ )
+ routingUserRule(
+ settingsStorage?.decodeString(AppConfig.PREF_V2RAY_ROUTING_BLOCKED)
+ ?: "", AppConfig.TAG_BLOCKED, v2rayConfig
+ )
+
+ v2rayConfig.routing.domainStrategy =
+ settingsStorage?.decodeString(AppConfig.PREF_ROUTING_DOMAIN_STRATEGY)
+ ?: "IPIfNonMatch"
+// v2rayConfig.routing.domainMatcher = "mph"
+ val routingMode = settingsStorage?.decodeString(AppConfig.PREF_ROUTING_MODE)
+ ?: ERoutingMode.GLOBAL_PROXY.value
+
+ // Hardcode googleapis.cn
+ val googleapisRoute = V2rayConfig.RoutingBean.RulesBean(
+ type = "field",
+ outboundTag = AppConfig.TAG_AGENT,
+ domain = arrayListOf("domain:googleapis.cn")
+ )
+
+ when (routingMode) {
+ ERoutingMode.BYPASS_LAN.value -> {
+ routingGeo("ip", "private", AppConfig.TAG_DIRECT, v2rayConfig)
+ }
+ ERoutingMode.BYPASS_MAINLAND.value -> {
+ routingGeo("", "cn", AppConfig.TAG_DIRECT, v2rayConfig)
+ v2rayConfig.routing.rules.add(0, googleapisRoute)
+ }
+ ERoutingMode.BYPASS_LAN_MAINLAND.value -> {
+ routingGeo("ip", "private", AppConfig.TAG_DIRECT, v2rayConfig)
+ routingGeo("", "cn", AppConfig.TAG_DIRECT, v2rayConfig)
+ v2rayConfig.routing.rules.add(0, googleapisRoute)
+ }
+ ERoutingMode.GLOBAL_DIRECT.value -> {
+ val globalDirect = V2rayConfig.RoutingBean.RulesBean(
+ type = "field",
+ outboundTag = AppConfig.TAG_DIRECT,
+ port = "0-65535"
+ )
+ v2rayConfig.routing.rules.add(globalDirect)
+ }
+ }
+ } catch (e: Exception) {
+ e.printStackTrace()
+ return false
+ }
+ return true
+ }
+
+ private fun routingGeo(
+ ipOrDomain: String,
+ code: String,
+ tag: String,
+ v2rayConfig: V2rayConfig
+ ) {
+ try {
+ if (!TextUtils.isEmpty(code)) {
+ //IP
+ if (ipOrDomain == "ip" || ipOrDomain == "") {
+ val rulesIP = V2rayConfig.RoutingBean.RulesBean()
+ rulesIP.type = "field"
+ rulesIP.outboundTag = tag
+ rulesIP.ip = ArrayList()
+ rulesIP.ip?.add("geoip:$code")
+ v2rayConfig.routing.rules.add(rulesIP)
+ }
+
+ if (ipOrDomain == "domain" || ipOrDomain == "") {
+ //Domain
+ val rulesDomain = V2rayConfig.RoutingBean.RulesBean()
+ rulesDomain.type = "field"
+ rulesDomain.outboundTag = tag
+ rulesDomain.domain = ArrayList()
+ rulesDomain.domain?.add("geosite:$code")
+ v2rayConfig.routing.rules.add(rulesDomain)
+ }
+ }
+ } catch (e: Exception) {
+ e.printStackTrace()
+ }
+ }
+
+ private fun routingUserRule(userRule: String, tag: String, v2rayConfig: V2rayConfig) {
+ try {
+ if (!TextUtils.isEmpty(userRule)) {
+ //Domain
+ val rulesDomain = V2rayConfig.RoutingBean.RulesBean()
+ rulesDomain.type = "field"
+ rulesDomain.outboundTag = tag
+ rulesDomain.domain = ArrayList()
+
+ //IP
+ val rulesIP = V2rayConfig.RoutingBean.RulesBean()
+ rulesIP.type = "field"
+ rulesIP.outboundTag = tag
+ rulesIP.ip = ArrayList()
+
+ userRule.split(",").map { it.trim() }.forEach {
+ if (Utils.isIpAddress(it) || it.startsWith("geoip:")) {
+ rulesIP.ip?.add(it)
+ } else if (it.isNotEmpty())
+// if (Utils.isValidUrl(it)
+// || it.startsWith("geosite:")
+// || it.startsWith("regexp:")
+// || it.startsWith("domain:")
+// || it.startsWith("full:"))
+ {
+ rulesDomain.domain?.add(it)
+ }
+ }
+ if (rulesDomain.domain?.size!! > 0) {
+ v2rayConfig.routing.rules.add(rulesDomain)
+ }
+ if (rulesIP.ip?.size!! > 0) {
+ v2rayConfig.routing.rules.add(rulesIP)
+ }
+ }
+ } catch (e: Exception) {
+ e.printStackTrace()
+ }
+ }
+
+ private fun userRule2Domian(userRule: String): ArrayList {
+ val domain = ArrayList()
+ userRule.split(",").map { it.trim() }.forEach {
+ if (it.startsWith("geosite:") || it.startsWith("domain:")) {
+ domain.add(it)
+ }
+ }
+ return domain
+ }
+
+ /**
+ * Custom Dns
+ */
+ private fun customLocalDns(v2rayConfig: V2rayConfig): Boolean {
+ try {
+ if (settingsStorage?.decodeBool(AppConfig.PREF_FAKE_DNS_ENABLED) == true) {
+ val geositeCn = arrayListOf("geosite:cn")
+ val proxyDomain = userRule2Domian(
+ settingsStorage?.decodeString(AppConfig.PREF_V2RAY_ROUTING_AGENT)
+ ?: ""
+ )
+ val directDomain = userRule2Domian(
+ settingsStorage?.decodeString(AppConfig.PREF_V2RAY_ROUTING_DIRECT)
+ ?: ""
+ )
+ // fakedns with all domains to make it always top priority
+ v2rayConfig.dns.servers?.add(
+ 0,
+ V2rayConfig.DnsBean.ServersBean(
+ address = "fakedns",
+ domains = geositeCn.plus(proxyDomain).plus(directDomain)
+ )
+ )
+ }
+
+ // DNS inbound对象
+ val remoteDns = Utils.getRemoteDnsServers()
+ if (v2rayConfig.inbounds.none { e -> e.protocol == "dokodemo-door" && e.tag == "dns-in" }) {
+ val dnsInboundSettings = V2rayConfig.InboundBean.InSettingsBean(
+ address = if (Utils.isPureIpAddress(remoteDns.first())) remoteDns.first() else "1.1.1.1",
+ port = 53,
+ network = "tcp,udp"
+ )
+
+ val localDnsPort = Utils.parseInt(
+ settingsStorage?.decodeString(AppConfig.PREF_LOCAL_DNS_PORT),
+ AppConfig.PORT_LOCAL_DNS.toInt()
+ )
+ v2rayConfig.inbounds.add(
+ V2rayConfig.InboundBean(
+ tag = "dns-in",
+ port = localDnsPort,
+ listen = "127.0.0.1",
+ protocol = "dokodemo-door",
+ settings = dnsInboundSettings,
+ sniffing = null
+ )
+ )
+ }
+
+ // DNS outbound对象
+ if (v2rayConfig.outbounds.none { e -> e.protocol == "dns" && e.tag == "dns-out" }) {
+ v2rayConfig.outbounds.add(
+ V2rayConfig.OutboundBean(
+ protocol = "dns",
+ tag = "dns-out",
+ settings = null,
+ streamSettings = null,
+ mux = null
+ )
+ )
+ }
+
+ // DNS routing tag
+ v2rayConfig.routing.rules.add(
+ 0, V2rayConfig.RoutingBean.RulesBean(
+ type = "field",
+ inboundTag = arrayListOf("dns-in"),
+ outboundTag = "dns-out",
+ domain = null
+ )
+ )
+ } catch (e: Exception) {
+ e.printStackTrace()
+ return false
+ }
+ return true
+ }
+
+ private fun dns(v2rayConfig: V2rayConfig): Boolean {
+ try {
+ val hosts = mutableMapOf()
+ val servers = ArrayList()
+ val remoteDns = Utils.getRemoteDnsServers()
+ val proxyDomain = userRule2Domian(
+ settingsStorage?.decodeString(AppConfig.PREF_V2RAY_ROUTING_AGENT)
+ ?: ""
+ )
+
+ remoteDns.forEach {
+ servers.add(it)
+ }
+ if (proxyDomain.size > 0) {
+ servers.add(
+ V2rayConfig.DnsBean.ServersBean(
+ remoteDns.first(),
+ 53,
+ proxyDomain,
+ null
+ )
+ )
+ }
+
+ // domestic DNS
+ val directDomain = userRule2Domian(
+ settingsStorage?.decodeString(AppConfig.PREF_V2RAY_ROUTING_DIRECT)
+ ?: ""
+ )
+ val routingMode = settingsStorage?.decodeString(AppConfig.PREF_ROUTING_MODE)
+ ?: ERoutingMode.GLOBAL_PROXY.value
+ if (directDomain.size > 0 || routingMode == ERoutingMode.BYPASS_MAINLAND.value || routingMode == ERoutingMode.BYPASS_LAN_MAINLAND.value) {
+ val domesticDns = Utils.getDomesticDnsServers()
+ val geositeCn = arrayListOf("geosite:cn")
+ val geoipCn = arrayListOf("geoip:cn")
+ if (directDomain.size > 0) {
+ servers.add(
+ V2rayConfig.DnsBean.ServersBean(
+ domesticDns.first(),
+ 53,
+ directDomain,
+ geoipCn
+ )
+ )
+ }
+ if (routingMode == ERoutingMode.BYPASS_MAINLAND.value || routingMode == ERoutingMode.BYPASS_LAN_MAINLAND.value) {
+ servers.add(
+ V2rayConfig.DnsBean.ServersBean(
+ domesticDns.first(),
+ 53,
+ geositeCn,
+ geoipCn
+ )
+ )
+ }
+ if (Utils.isPureIpAddress(domesticDns.first())) {
+ v2rayConfig.routing.rules.add(
+ 0, V2rayConfig.RoutingBean.RulesBean(
+ type = "field",
+ outboundTag = AppConfig.TAG_DIRECT,
+ port = "53",
+ ip = arrayListOf(domesticDns.first()),
+ domain = null
+ )
+ )
+ }
+ }
+
+ val blkDomain = userRule2Domian(
+ settingsStorage?.decodeString(AppConfig.PREF_V2RAY_ROUTING_BLOCKED)
+ ?: ""
+ )
+ if (blkDomain.size > 0) {
+ hosts.putAll(blkDomain.map { it to "127.0.0.1" })
+ }
+
+ // hardcode googleapi rule to fix play store problems
+ hosts["domain:googleapis.cn"] = "googleapis.com"
+
+ // DNS dns对象
+ v2rayConfig.dns = V2rayConfig.DnsBean(
+ servers = servers,
+ hosts = hosts
+ )
+
+ // DNS routing
+ if (Utils.isPureIpAddress(remoteDns.first())) {
+ v2rayConfig.routing.rules.add(
+ 0, V2rayConfig.RoutingBean.RulesBean(
+ type = "field",
+ outboundTag = AppConfig.TAG_AGENT,
+ port = "53",
+ ip = arrayListOf(remoteDns.first()),
+ domain = null
+ )
+ )
+ }
+ } catch (e: Exception) {
+ e.printStackTrace()
+ return false
+ }
+ return true
+ }
+
+ private fun httpRequestObject(outbound: V2rayConfig.OutboundBean): Boolean {
+ try {
+ if (outbound.streamSettings?.network == DEFAULT_NETWORK
+ && outbound.streamSettings?.tcpSettings?.header?.type == HTTP
+ ) {
+ val path = outbound.streamSettings?.tcpSettings?.header?.request?.path
+ val host = outbound.streamSettings?.tcpSettings?.header?.request?.headers?.Host
+
+ val requestString: String by lazy {
+ """{"version":"1.1","method":"GET","headers":{"User-Agent":["Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/53.0.2785.143 Safari/537.36","Mozilla/5.0 (iPhone; CPU iPhone OS 10_0_2 like Mac OS X) AppleWebKit/601.1 (KHTML, like Gecko) CriOS/53.0.2785.109 Mobile/14A456 Safari/601.1.46"],"Accept-Encoding":["gzip, deflate"],"Connection":["keep-alive"],"Pragma":"no-cache"}}"""
+ }
+ outbound.streamSettings?.tcpSettings?.header?.request = Gson().fromJson(
+ requestString,
+ V2rayConfig.OutboundBean.StreamSettingsBean.TcpSettingsBean.HeaderBean.RequestBean::class.java
+ )
+ outbound.streamSettings?.tcpSettings?.header?.request?.path =
+ if (path.isNullOrEmpty()) {
+ listOf("/")
+ } else {
+ path
+ }
+ outbound.streamSettings?.tcpSettings?.header?.request?.headers?.Host = host!!
+ }
+
+ } catch (e: Exception) {
+ e.printStackTrace()
+ return false
+ }
+ return true
+ }
+
+ private fun mux(outbound: V2rayConfig.OutboundBean): Boolean {
+ try {
+ val enableMux = settingsStorage?.decodeBool(AppConfig.PREF_MUX_ENABLED, false)
+ ?: true
+ val setConcurrency = Utils.parseInt(
+ settingsStorage?.decodeString(AppConfig.PREF_MUX_CONCURRENCY),
+ AppConfig.MUX_CONCURRENCY.toInt()
+ )
+ outbound.mux?.enabled = enableMux
+ outbound.mux?.concurrency = setConcurrency
+
+ } catch (e: Exception) {
+ e.printStackTrace()
+ return false
+ }
+ return true
+ }
+}
diff --git a/app/src/main/kotlin/com/v2ray/ang/viewmodel/MainViewModel.kt b/app/src/main/kotlin/com/v2ray/ang/viewmodel/MainViewModel.kt
new file mode 100644
index 0000000..abf8a1c
--- /dev/null
+++ b/app/src/main/kotlin/com/v2ray/ang/viewmodel/MainViewModel.kt
@@ -0,0 +1,255 @@
+package com.v2ray.ang.viewmodel
+
+import android.app.Application
+import android.content.*
+import android.util.Log
+import android.view.LayoutInflater
+import android.widget.ArrayAdapter
+import androidx.appcompat.app.AlertDialog
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.viewModelScope
+import com.google.gson.Gson
+import com.tencent.mmkv.MMKV
+import com.v2ray.ang.AngApplication
+import com.v2ray.ang.AppConfig
+import com.v2ray.ang.AppConfig.ANG_PACKAGE
+import com.v2ray.ang.R
+import com.v2ray.ang.databinding.DialogConfigFilterBinding
+import com.v2ray.ang.dto.*
+import com.v2ray.ang.extension.toast
+import com.v2ray.ang.util.*
+import com.v2ray.ang.util.MmkvManager.KEY_ANG_CONFIGS
+import kotlinx.coroutines.*
+import java.util.*
+
+class MainViewModel(application: Application) : AndroidViewModel(application) {
+ private val mainStorage by lazy { MMKV.mmkvWithID(MmkvManager.ID_MAIN, MMKV.MULTI_PROCESS_MODE) }
+ private val serverRawStorage by lazy { MMKV.mmkvWithID(MmkvManager.ID_SERVER_RAW, MMKV.MULTI_PROCESS_MODE) }
+
+ var serverList = MmkvManager.decodeServerList()
+ var subscriptionId: String = ""
+ var keywordFilter: String = ""
+ private set
+ val serversCache = mutableListOf()
+ val isRunning by lazy { MutableLiveData() }
+ val updateListAction by lazy { MutableLiveData() }
+ val updateTestResultAction by lazy { MutableLiveData() }
+
+ private val tcpingTestScope by lazy { CoroutineScope(Dispatchers.IO) }
+
+ fun startListenBroadcast() {
+ isRunning.value = false
+ getApplication().registerReceiver(mMsgReceiver, IntentFilter(AppConfig.BROADCAST_ACTION_ACTIVITY))
+ MessageUtil.sendMsg2Service(getApplication(), AppConfig.MSG_REGISTER_CLIENT, "")
+ }
+
+ override fun onCleared() {
+ getApplication().unregisterReceiver(mMsgReceiver)
+ tcpingTestScope.coroutineContext[Job]?.cancelChildren()
+ SpeedtestUtil.closeAllTcpSockets()
+ Log.i(ANG_PACKAGE, "Main ViewModel is cleared")
+ super.onCleared()
+ }
+
+ fun reloadServerList() {
+ serverList = MmkvManager.decodeServerList()
+ updateCache()
+ updateListAction.value = -1
+ }
+
+ fun removeServer(guid: String) {
+ serverList.remove(guid)
+ MmkvManager.removeServer(guid)
+ val index = getPosition(guid)
+ if(index >= 0){
+ serversCache.removeAt(index)
+ }
+ }
+
+ fun appendCustomConfigServer(server: String) {
+ val config = ServerConfig.create(EConfigType.CUSTOM)
+ config.remarks = System.currentTimeMillis().toString()
+ config.subscriptionId = subscriptionId
+ config.fullConfig = Gson().fromJson(server, V2rayConfig::class.java)
+ val key = MmkvManager.encodeServerConfig("", config)
+ serverRawStorage?.encode(key, server)
+ serverList.add(0,key)
+ serversCache.add(0,ServersCache(key,config))
+ }
+
+ fun swapServer(fromPosition: Int, toPosition: Int) {
+ Collections.swap(serverList, fromPosition, toPosition)
+ Collections.swap(serversCache, fromPosition, toPosition)
+ mainStorage?.encode(KEY_ANG_CONFIGS, Gson().toJson(serverList))
+ }
+
+ @Synchronized
+ fun updateCache() {
+ serversCache.clear()
+ for (guid in serverList) {
+ val config = MmkvManager.decodeServerConfig(guid) ?: continue
+ if (subscriptionId.isNotEmpty() && subscriptionId != config.subscriptionId) {
+ continue
+ }
+
+ if (keywordFilter.isEmpty() || config.remarks.contains(keywordFilter)) {
+ serversCache.add(ServersCache(guid, config))
+ }
+ }
+ }
+
+ fun testAllTcping() {
+ tcpingTestScope.coroutineContext[Job]?.cancelChildren()
+ SpeedtestUtil.closeAllTcpSockets()
+ MmkvManager.clearAllTestDelayResults()
+ updateListAction.value = -1 // update all
+
+ getApplication().toast(R.string.connection_test_testing)
+ for (item in serversCache) {
+ item.config.getProxyOutbound()?.let { outbound ->
+ val serverAddress = outbound.getServerAddress()
+ val serverPort = outbound.getServerPort()
+ if (serverAddress != null && serverPort != null) {
+ tcpingTestScope.launch {
+ val testResult = SpeedtestUtil.tcping(serverAddress, serverPort)
+ launch(Dispatchers.Main) {
+ MmkvManager.encodeServerTestDelayMillis(item.guid, testResult)
+ updateListAction.value = getPosition(item.guid)
+ }
+ }
+ }
+ }
+ }
+ }
+
+ fun testAllRealPing() {
+ MessageUtil.sendMsg2TestService(getApplication(), AppConfig.MSG_MEASURE_CONFIG_CANCEL, "")
+ MmkvManager.clearAllTestDelayResults()
+ updateListAction.value = -1 // update all
+
+ getApplication().toast(R.string.connection_test_testing)
+ viewModelScope.launch(Dispatchers.Default) { // without Dispatchers.Default viewModelScope will launch in main thread
+ for (item in serversCache) {
+ val config = V2rayConfigUtil.getV2rayConfig(getApplication(), item.guid)
+ if (config.status) {
+ MessageUtil.sendMsg2TestService(getApplication(), AppConfig.MSG_MEASURE_CONFIG, Pair(item.guid, config.content))
+ }
+ }
+ }
+ }
+
+ fun testCurrentServerRealPing() {
+ MessageUtil.sendMsg2Service(getApplication(), AppConfig.MSG_MEASURE_DELAY, "")
+ }
+
+ fun filterConfig(context :Context) {
+ val subscriptions = MmkvManager.decodeSubscriptions()
+ val listId = subscriptions.map { it.first }.toList().toMutableList()
+ val listRemarks = subscriptions.map { it.second.remarks }.toList().toMutableList()
+ listRemarks += context.getString(R.string.filter_config_all)
+ val checkedItem = if (subscriptionId.isNotEmpty()) {
+ listId.indexOf(subscriptionId)
+ } else {
+ listRemarks.count() - 1
+ }
+
+ val ivBinding = DialogConfigFilterBinding.inflate(LayoutInflater.from(context))
+ ivBinding.spSubscriptionId.adapter = ArrayAdapter( context, android.R.layout.simple_spinner_dropdown_item, listRemarks)
+ ivBinding.spSubscriptionId.setSelection(checkedItem)
+ ivBinding.etKeyword.text = Utils.getEditable(keywordFilter)
+ val builder = AlertDialog.Builder(context).setView(ivBinding.root)
+ builder.setTitle(R.string.title_filter_config)
+ builder.setPositiveButton(R.string.tasker_setting_confirm) { dialogInterface: DialogInterface?, _: Int ->
+ try {
+ val position = ivBinding.spSubscriptionId.selectedItemPosition
+ subscriptionId = if (listRemarks.count() - 1 == position) {
+ ""
+ } else {
+ subscriptions[position].first
+ }
+ keywordFilter = ivBinding.etKeyword.text.toString()
+ reloadServerList()
+
+ dialogInterface?.dismiss()
+ } catch (e: Exception) {
+ e.printStackTrace()
+ }
+ }
+ builder.show()
+// AlertDialog.Builder(context)
+// .setSingleChoiceItems(listRemarks.toTypedArray(), checkedItem) { dialog, i ->
+// try {
+// subscriptionId = if (listRemarks.count() - 1 == i) {
+// ""
+// } else {
+// subscriptions[i].first
+// }
+// reloadServerList()
+// dialog.dismiss()
+// } catch (e: Exception) {
+// e.printStackTrace()
+// }
+// }.show()
+ }
+
+ fun getPosition(guid: String) : Int {
+ serversCache.forEachIndexed { index, it ->
+ if (it.guid == guid)
+ return index
+ }
+ return -1
+ }
+
+ fun removeDuplicateServer() {
+ val deleteServer = mutableListOf()
+ serversCache.forEachIndexed { index, it ->
+ val outbound = it.config.getProxyOutbound()
+ serversCache.forEachIndexed { index2, it2 ->
+ if(index2 > index){
+ val outbound2 = it2.config.getProxyOutbound()
+ if( outbound == outbound2 && !deleteServer.contains(it2.guid))
+ {
+ deleteServer.add(it2.guid)
+ }
+ }
+ }
+ }
+ for(it in deleteServer){
+ MmkvManager.removeServer(it)
+ }
+ reloadServerList()
+ getApplication().toast(getApplication().getString(R.string.title_del_duplicate_config_count, deleteServer.count()))
+ }
+
+ private val mMsgReceiver = object : BroadcastReceiver() {
+ override fun onReceive(ctx: Context?, intent: Intent?) {
+ when (intent?.getIntExtra("key", 0)) {
+ AppConfig.MSG_STATE_RUNNING -> {
+ isRunning.value = true
+ }
+ AppConfig.MSG_STATE_NOT_RUNNING -> {
+ isRunning.value = false
+ }
+ AppConfig.MSG_STATE_START_SUCCESS -> {
+ isRunning.value = true
+ }
+ AppConfig.MSG_STATE_START_FAILURE -> {
+ getApplication().toast(R.string.toast_services_failure)
+ isRunning.value = false
+ }
+ AppConfig.MSG_STATE_STOP_SUCCESS -> {
+ isRunning.value = false
+ }
+ AppConfig.MSG_MEASURE_DELAY_SUCCESS -> {
+ updateTestResultAction.value = intent.getStringExtra("content")
+ }
+ AppConfig.MSG_MEASURE_CONFIG_SUCCESS -> {
+ val resultPair = intent.getSerializableExtra("content") as Pair
+ MmkvManager.encodeServerTestDelayMillis(resultPair.first, resultPair.second)
+ updateListAction.value = getPosition(resultPair.first)
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/kotlin/com/v2ray/ang/viewmodel/SettingsViewModel.kt b/app/src/main/kotlin/com/v2ray/ang/viewmodel/SettingsViewModel.kt
new file mode 100644
index 0000000..f163b01
--- /dev/null
+++ b/app/src/main/kotlin/com/v2ray/ang/viewmodel/SettingsViewModel.kt
@@ -0,0 +1,77 @@
+package com.v2ray.ang.viewmodel
+
+import android.app.Application
+import android.content.SharedPreferences
+import android.util.Log
+import androidx.lifecycle.AndroidViewModel
+import androidx.preference.PreferenceManager
+import com.tencent.mmkv.MMKV
+import com.v2ray.ang.AngApplication
+import com.v2ray.ang.AppConfig
+import com.v2ray.ang.R
+import com.v2ray.ang.extension.toast
+import com.v2ray.ang.util.MmkvManager
+
+@Suppress("DUPLICATE_LABEL_IN_WHEN")
+class SettingsViewModel(application: Application) : AndroidViewModel(application), SharedPreferences.OnSharedPreferenceChangeListener {
+
+ private val settingsStorage by lazy { MMKV.mmkvWithID(MmkvManager.ID_SETTING, MMKV.MULTI_PROCESS_MODE) }
+
+ fun startListenPreferenceChange() {
+ PreferenceManager.getDefaultSharedPreferences(getApplication()).registerOnSharedPreferenceChangeListener(this)
+ }
+
+ override fun onCleared() {
+ PreferenceManager.getDefaultSharedPreferences(getApplication()).unregisterOnSharedPreferenceChangeListener(this)
+ Log.i(AppConfig.ANG_PACKAGE, "Settings ViewModel is cleared")
+ super.onCleared()
+ }
+
+ override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String?) {
+ Log.d(AppConfig.ANG_PACKAGE, "Observe settings changed: $key")
+ when(key) {
+ AppConfig.PREF_MODE,
+ AppConfig.PREF_VPN_DNS,
+ AppConfig.PREF_REMOTE_DNS,
+ AppConfig.PREF_DOMESTIC_DNS,
+ AppConfig.PREF_LOCAL_DNS_PORT,
+ AppConfig.PREF_SOCKS_PORT,
+ AppConfig.PREF_HTTP_PORT,
+ AppConfig.PREF_LOGLEVEL,
+ AppConfig.PREF_LANGUAGE,
+ AppConfig.PREF_ROUTING_DOMAIN_STRATEGY,
+ AppConfig.PREF_ROUTING_MODE,
+ AppConfig.PREF_V2RAY_ROUTING_AGENT,
+ AppConfig.PREF_V2RAY_ROUTING_BLOCKED,
+ AppConfig.PREF_V2RAY_ROUTING_DIRECT,
+ AppConfig.PREF_MUX_CONCURRENCY, -> {
+ settingsStorage?.encode(key, sharedPreferences.getString(key, "8"))
+ getApplication().toast(R.string.restart_service_required)
+ }
+ AppConfig.PREF_SPEED_ENABLED,
+ AppConfig.PREF_PROXY_SHARING,
+ AppConfig.PREF_LOCAL_DNS_ENABLED,
+ AppConfig.PREF_FAKE_DNS_ENABLED,
+ AppConfig.PREF_ALLOW_INSECURE,
+ AppConfig.PREF_PREFER_IPV6,
+ AppConfig.PREF_PER_APP_PROXY,
+ AppConfig.PREF_BYPASS_APPS,
+ AppConfig.PREF_CONFIRM_REMOVE,
+ AppConfig.PREF_START_SCAN_IMMEDIATE,
+ AppConfig.PREF_MUX_ENABLED, -> {
+ settingsStorage?.encode(key, sharedPreferences.getBoolean(key, false))
+ getApplication().toast(R.string.restart_service_required)
+ }
+ AppConfig.PREF_SNIFFING_ENABLED -> {
+ settingsStorage?.encode(key, sharedPreferences.getBoolean(key, false))
+ getApplication().toast(R.string.restart_service_required)
+ }
+ AppConfig.PREF_CONFIRM_REMOVE -> {
+ settingsStorage?.encode(key, sharedPreferences.getBoolean(key, true))
+ }
+ AppConfig.PREF_PER_APP_PROXY_SET -> {
+ settingsStorage?.encode(key, sharedPreferences.getStringSet(key, setOf()))
+ }
+ }
+ }
+}
diff --git a/app/src/main/res/anim/fade_in.xml b/app/src/main/res/anim/fade_in.xml
new file mode 100644
index 0000000..29e0432
--- /dev/null
+++ b/app/src/main/res/anim/fade_in.xml
@@ -0,0 +1,6 @@
+
+
diff --git a/app/src/main/res/anim/fade_out.xml b/app/src/main/res/anim/fade_out.xml
new file mode 100644
index 0000000..2b8bb1c
--- /dev/null
+++ b/app/src/main/res/anim/fade_out.xml
@@ -0,0 +1,6 @@
+
+
diff --git a/app/src/main/res/color/color_highlight_material.xml b/app/src/main/res/color/color_highlight_material.xml
new file mode 100644
index 0000000..5029d3e
--- /dev/null
+++ b/app/src/main/res/color/color_highlight_material.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable-hdpi/ic_stat_direct.png b/app/src/main/res/drawable-hdpi/ic_stat_direct.png
new file mode 100644
index 0000000..a9fe7cd
Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_stat_direct.png differ
diff --git a/app/src/main/res/drawable-hdpi/ic_stat_name.png b/app/src/main/res/drawable-hdpi/ic_stat_name.png
new file mode 100644
index 0000000..1f0f31f
Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_stat_name.png differ
diff --git a/app/src/main/res/drawable-hdpi/ic_stat_name_black.png b/app/src/main/res/drawable-hdpi/ic_stat_name_black.png
new file mode 100644
index 0000000..5361ddf
Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_stat_name_black.png differ
diff --git a/app/src/main/res/drawable-hdpi/ic_stat_name_grey.png b/app/src/main/res/drawable-hdpi/ic_stat_name_grey.png
new file mode 100644
index 0000000..79dc815
Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_stat_name_grey.png differ
diff --git a/app/src/main/res/drawable-hdpi/ic_stat_proxy.png b/app/src/main/res/drawable-hdpi/ic_stat_proxy.png
new file mode 100644
index 0000000..048bb16
Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_stat_proxy.png differ
diff --git a/app/src/main/res/drawable-mdpi/ic_stat_direct.png b/app/src/main/res/drawable-mdpi/ic_stat_direct.png
new file mode 100644
index 0000000..0f0d00c
Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_stat_direct.png differ
diff --git a/app/src/main/res/drawable-mdpi/ic_stat_name.png b/app/src/main/res/drawable-mdpi/ic_stat_name.png
new file mode 100644
index 0000000..ac44340
Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_stat_name.png differ
diff --git a/app/src/main/res/drawable-mdpi/ic_stat_name_black.png b/app/src/main/res/drawable-mdpi/ic_stat_name_black.png
new file mode 100644
index 0000000..01dc4ef
Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_stat_name_black.png differ
diff --git a/app/src/main/res/drawable-mdpi/ic_stat_name_grey.png b/app/src/main/res/drawable-mdpi/ic_stat_name_grey.png
new file mode 100644
index 0000000..4370323
Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_stat_name_grey.png differ
diff --git a/app/src/main/res/drawable-mdpi/ic_stat_proxy.png b/app/src/main/res/drawable-mdpi/ic_stat_proxy.png
new file mode 100644
index 0000000..f0a39da
Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_stat_proxy.png differ
diff --git a/app/src/main/res/drawable-xhdpi/ic_stat_direct.png b/app/src/main/res/drawable-xhdpi/ic_stat_direct.png
new file mode 100644
index 0000000..2dcd5d9
Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_stat_direct.png differ
diff --git a/app/src/main/res/drawable-xhdpi/ic_stat_name.png b/app/src/main/res/drawable-xhdpi/ic_stat_name.png
new file mode 100644
index 0000000..7af529f
Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_stat_name.png differ
diff --git a/app/src/main/res/drawable-xhdpi/ic_stat_name_black.png b/app/src/main/res/drawable-xhdpi/ic_stat_name_black.png
new file mode 100644
index 0000000..00207a5
Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_stat_name_black.png differ
diff --git a/app/src/main/res/drawable-xhdpi/ic_stat_name_grey.png b/app/src/main/res/drawable-xhdpi/ic_stat_name_grey.png
new file mode 100644
index 0000000..def2c8a
Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_stat_name_grey.png differ
diff --git a/app/src/main/res/drawable-xhdpi/ic_stat_proxy.png b/app/src/main/res/drawable-xhdpi/ic_stat_proxy.png
new file mode 100644
index 0000000..36bba56
Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_stat_proxy.png differ
diff --git a/app/src/main/res/drawable-xxhdpi/donate.png b/app/src/main/res/drawable-xxhdpi/donate.png
new file mode 100644
index 0000000..8825c53
Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/donate.png differ
diff --git a/app/src/main/res/drawable-xxhdpi/ic_stat_direct.png b/app/src/main/res/drawable-xxhdpi/ic_stat_direct.png
new file mode 100644
index 0000000..f8bd77e
Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_stat_direct.png differ
diff --git a/app/src/main/res/drawable-xxhdpi/ic_stat_name.png b/app/src/main/res/drawable-xxhdpi/ic_stat_name.png
new file mode 100644
index 0000000..0ac2448
Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_stat_name.png differ
diff --git a/app/src/main/res/drawable-xxhdpi/ic_stat_name_black.png b/app/src/main/res/drawable-xxhdpi/ic_stat_name_black.png
new file mode 100644
index 0000000..516f089
Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_stat_name_black.png differ
diff --git a/app/src/main/res/drawable-xxhdpi/ic_stat_name_grey.png b/app/src/main/res/drawable-xxhdpi/ic_stat_name_grey.png
new file mode 100644
index 0000000..01f688a
Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_stat_name_grey.png differ
diff --git a/app/src/main/res/drawable-xxhdpi/ic_stat_proxy.png b/app/src/main/res/drawable-xxhdpi/ic_stat_proxy.png
new file mode 100644
index 0000000..a10f0fe
Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_stat_proxy.png differ
diff --git a/app/src/main/res/drawable-xxhdpi/side_nav_bar.xml b/app/src/main/res/drawable-xxhdpi/side_nav_bar.xml
new file mode 100644
index 0000000..6d81870
--- /dev/null
+++ b/app/src/main/res/drawable-xxhdpi/side_nav_bar.xml
@@ -0,0 +1,9 @@
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable-xxxhdpi/ic_stat_direct.png b/app/src/main/res/drawable-xxxhdpi/ic_stat_direct.png
new file mode 100644
index 0000000..406a0c7
Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_stat_direct.png differ
diff --git a/app/src/main/res/drawable-xxxhdpi/ic_stat_name.png b/app/src/main/res/drawable-xxxhdpi/ic_stat_name.png
new file mode 100644
index 0000000..dd313da
Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_stat_name.png differ
diff --git a/app/src/main/res/drawable-xxxhdpi/ic_stat_name_black.png b/app/src/main/res/drawable-xxxhdpi/ic_stat_name_black.png
new file mode 100644
index 0000000..a18be25
Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_stat_name_black.png differ
diff --git a/app/src/main/res/drawable-xxxhdpi/ic_stat_name_grey.png b/app/src/main/res/drawable-xxxhdpi/ic_stat_name_grey.png
new file mode 100644
index 0000000..b1478e0
Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_stat_name_grey.png differ
diff --git a/app/src/main/res/drawable-xxxhdpi/ic_stat_proxy.png b/app/src/main/res/drawable-xxxhdpi/ic_stat_proxy.png
new file mode 100644
index 0000000..daef597
Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_stat_proxy.png differ
diff --git a/app/src/main/res/drawable/ic_action_done.xml b/app/src/main/res/drawable/ic_action_done.xml
new file mode 100644
index 0000000..33a117f
--- /dev/null
+++ b/app/src/main/res/drawable/ic_action_done.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_action_done_black.xml b/app/src/main/res/drawable/ic_action_done_black.xml
new file mode 100644
index 0000000..5635bea
--- /dev/null
+++ b/app/src/main/res/drawable/ic_action_done_black.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_add_black_24dp.xml b/app/src/main/res/drawable/ic_add_black_24dp.xml
new file mode 100644
index 0000000..0258249
--- /dev/null
+++ b/app/src/main/res/drawable/ic_add_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_add_white_24dp.xml b/app/src/main/res/drawable/ic_add_white_24dp.xml
new file mode 100644
index 0000000..b9b8eca
--- /dev/null
+++ b/app/src/main/res/drawable/ic_add_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_attach_money_black_24dp.xml b/app/src/main/res/drawable/ic_attach_money_black_24dp.xml
new file mode 100644
index 0000000..b520fc9
--- /dev/null
+++ b/app/src/main/res/drawable/ic_attach_money_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_attach_money_white_24dp.xml b/app/src/main/res/drawable/ic_attach_money_white_24dp.xml
new file mode 100644
index 0000000..2b65f0c
--- /dev/null
+++ b/app/src/main/res/drawable/ic_attach_money_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_close_grey_800_24dp.xml b/app/src/main/res/drawable/ic_close_grey_800_24dp.xml
new file mode 100644
index 0000000..8a9a522
--- /dev/null
+++ b/app/src/main/res/drawable/ic_close_grey_800_24dp.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_cloud_download_black_24dp.xml b/app/src/main/res/drawable/ic_cloud_download_black_24dp.xml
new file mode 100644
index 0000000..aebcd40
--- /dev/null
+++ b/app/src/main/res/drawable/ic_cloud_download_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_cloud_download_white_24dp.xml b/app/src/main/res/drawable/ic_cloud_download_white_24dp.xml
new file mode 100644
index 0000000..171a9b3
--- /dev/null
+++ b/app/src/main/res/drawable/ic_cloud_download_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_copy_black.xml b/app/src/main/res/drawable/ic_copy_black.xml
new file mode 100644
index 0000000..8a894a3
--- /dev/null
+++ b/app/src/main/res/drawable/ic_copy_black.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_copy_white.xml b/app/src/main/res/drawable/ic_copy_white.xml
new file mode 100644
index 0000000..e50927b
--- /dev/null
+++ b/app/src/main/res/drawable/ic_copy_white.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_delete_black_24dp.xml b/app/src/main/res/drawable/ic_delete_black_24dp.xml
new file mode 100644
index 0000000..2f5557a
--- /dev/null
+++ b/app/src/main/res/drawable/ic_delete_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_delete_white_24dp.xml b/app/src/main/res/drawable/ic_delete_white_24dp.xml
new file mode 100644
index 0000000..ab38bb6
--- /dev/null
+++ b/app/src/main/res/drawable/ic_delete_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_description_black_24dp.xml b/app/src/main/res/drawable/ic_description_black_24dp.xml
new file mode 100644
index 0000000..38c3335
--- /dev/null
+++ b/app/src/main/res/drawable/ic_description_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_description_white_24dp.xml b/app/src/main/res/drawable/ic_description_white_24dp.xml
new file mode 100644
index 0000000..7e0d28e
--- /dev/null
+++ b/app/src/main/res/drawable/ic_description_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_edit_black_24dp.xml b/app/src/main/res/drawable/ic_edit_black_24dp.xml
new file mode 100644
index 0000000..2ab2fb7
--- /dev/null
+++ b/app/src/main/res/drawable/ic_edit_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_edit_white_24dp.xml b/app/src/main/res/drawable/ic_edit_white_24dp.xml
new file mode 100644
index 0000000..35a774a
--- /dev/null
+++ b/app/src/main/res/drawable/ic_edit_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_fab_check.xml b/app/src/main/res/drawable/ic_fab_check.xml
new file mode 100644
index 0000000..54f825f
--- /dev/null
+++ b/app/src/main/res/drawable/ic_fab_check.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_fab_check_black.xml b/app/src/main/res/drawable/ic_fab_check_black.xml
new file mode 100644
index 0000000..6c1b167
--- /dev/null
+++ b/app/src/main/res/drawable/ic_fab_check_black.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_feedback_black_24dp.xml b/app/src/main/res/drawable/ic_feedback_black_24dp.xml
new file mode 100644
index 0000000..29f9baa
--- /dev/null
+++ b/app/src/main/res/drawable/ic_feedback_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_feedback_white_24dp.xml b/app/src/main/res/drawable/ic_feedback_white_24dp.xml
new file mode 100644
index 0000000..3e08fae
--- /dev/null
+++ b/app/src/main/res/drawable/ic_feedback_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_file_black_24dp.xml b/app/src/main/res/drawable/ic_file_black_24dp.xml
new file mode 100644
index 0000000..7fcdda2
--- /dev/null
+++ b/app/src/main/res/drawable/ic_file_black_24dp.xml
@@ -0,0 +1,34 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_file_white_24dp.xml b/app/src/main/res/drawable/ic_file_white_24dp.xml
new file mode 100644
index 0000000..f7f9df2
--- /dev/null
+++ b/app/src/main/res/drawable/ic_file_white_24dp.xml
@@ -0,0 +1,34 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_image_photo.xml b/app/src/main/res/drawable/ic_image_photo.xml
new file mode 100644
index 0000000..c701a63
--- /dev/null
+++ b/app/src/main/res/drawable/ic_image_photo.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_image_photo_black.xml b/app/src/main/res/drawable/ic_image_photo_black.xml
new file mode 100644
index 0000000..913d9d8
--- /dev/null
+++ b/app/src/main/res/drawable/ic_image_photo_black.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_info_black_24dp.xml b/app/src/main/res/drawable/ic_info_black_24dp.xml
new file mode 100644
index 0000000..34b8202
--- /dev/null
+++ b/app/src/main/res/drawable/ic_info_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_info_white_24dp.xml b/app/src/main/res/drawable/ic_info_white_24dp.xml
new file mode 100644
index 0000000..f7b49e8
--- /dev/null
+++ b/app/src/main/res/drawable/ic_info_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_logcat_black_24dp.xml b/app/src/main/res/drawable/ic_logcat_black_24dp.xml
new file mode 100644
index 0000000..a07a0f9
--- /dev/null
+++ b/app/src/main/res/drawable/ic_logcat_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_logcat_white_24dp.xml b/app/src/main/res/drawable/ic_logcat_white_24dp.xml
new file mode 100644
index 0000000..e7d3eb3
--- /dev/null
+++ b/app/src/main/res/drawable/ic_logcat_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_outline_filter_alt_black_24.xml b/app/src/main/res/drawable/ic_outline_filter_alt_black_24.xml
new file mode 100644
index 0000000..d574422
--- /dev/null
+++ b/app/src/main/res/drawable/ic_outline_filter_alt_black_24.xml
@@ -0,0 +1,5 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_outline_filter_alt_white_24.xml b/app/src/main/res/drawable/ic_outline_filter_alt_white_24.xml
new file mode 100644
index 0000000..6cb82ef
--- /dev/null
+++ b/app/src/main/res/drawable/ic_outline_filter_alt_white_24.xml
@@ -0,0 +1,5 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_qu_scan_black_24dp.xml b/app/src/main/res/drawable/ic_qu_scan_black_24dp.xml
new file mode 100644
index 0000000..e0b32bf
--- /dev/null
+++ b/app/src/main/res/drawable/ic_qu_scan_black_24dp.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/ic_qu_settings_black_24dp.xml b/app/src/main/res/drawable/ic_qu_settings_black_24dp.xml
new file mode 100644
index 0000000..063171a
--- /dev/null
+++ b/app/src/main/res/drawable/ic_qu_settings_black_24dp.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_qu_switch_black_24dp.xml b/app/src/main/res/drawable/ic_qu_switch_black_24dp.xml
new file mode 100644
index 0000000..97f8859
--- /dev/null
+++ b/app/src/main/res/drawable/ic_qu_switch_black_24dp.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/ic_rounded_corner_grey.xml b/app/src/main/res/drawable/ic_rounded_corner_grey.xml
new file mode 100644
index 0000000..50af166
--- /dev/null
+++ b/app/src/main/res/drawable/ic_rounded_corner_grey.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_rounded_corner_theme.xml b/app/src/main/res/drawable/ic_rounded_corner_theme.xml
new file mode 100644
index 0000000..afc2118
--- /dev/null
+++ b/app/src/main/res/drawable/ic_rounded_corner_theme.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/ic_save_black_24dp.xml b/app/src/main/res/drawable/ic_save_black_24dp.xml
new file mode 100644
index 0000000..a561d63
--- /dev/null
+++ b/app/src/main/res/drawable/ic_save_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_save_white_24dp.xml b/app/src/main/res/drawable/ic_save_white_24dp.xml
new file mode 100644
index 0000000..a7a81a2
--- /dev/null
+++ b/app/src/main/res/drawable/ic_save_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_scan_black_24dp.xml b/app/src/main/res/drawable/ic_scan_black_24dp.xml
new file mode 100644
index 0000000..a31063b
--- /dev/null
+++ b/app/src/main/res/drawable/ic_scan_black_24dp.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/ic_scan_white_24dp.xml b/app/src/main/res/drawable/ic_scan_white_24dp.xml
new file mode 100644
index 0000000..5068d6e
--- /dev/null
+++ b/app/src/main/res/drawable/ic_scan_white_24dp.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/ic_select_all_black_24dp.xml b/app/src/main/res/drawable/ic_select_all_black_24dp.xml
new file mode 100644
index 0000000..966290f
--- /dev/null
+++ b/app/src/main/res/drawable/ic_select_all_black_24dp.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_select_all_white_24dp.xml b/app/src/main/res/drawable/ic_select_all_white_24dp.xml
new file mode 100644
index 0000000..a24c01b
--- /dev/null
+++ b/app/src/main/res/drawable/ic_select_all_white_24dp.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_settings_black_24dp.xml b/app/src/main/res/drawable/ic_settings_black_24dp.xml
new file mode 100644
index 0000000..ace746c
--- /dev/null
+++ b/app/src/main/res/drawable/ic_settings_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_settings_white_24dp.xml b/app/src/main/res/drawable/ic_settings_white_24dp.xml
new file mode 100644
index 0000000..ce997a7
--- /dev/null
+++ b/app/src/main/res/drawable/ic_settings_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_share_black_24dp.xml b/app/src/main/res/drawable/ic_share_black_24dp.xml
new file mode 100644
index 0000000..e3fe874
--- /dev/null
+++ b/app/src/main/res/drawable/ic_share_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_share_white_24dp.xml b/app/src/main/res/drawable/ic_share_white_24dp.xml
new file mode 100644
index 0000000..9040666
--- /dev/null
+++ b/app/src/main/res/drawable/ic_share_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_shortcut_background.xml b/app/src/main/res/drawable/ic_shortcut_background.xml
new file mode 100644
index 0000000..a62b720
--- /dev/null
+++ b/app/src/main/res/drawable/ic_shortcut_background.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/ic_subscriptions_black_24dp.xml b/app/src/main/res/drawable/ic_subscriptions_black_24dp.xml
new file mode 100644
index 0000000..6f0ed45
--- /dev/null
+++ b/app/src/main/res/drawable/ic_subscriptions_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_subscriptions_white_24dp.xml b/app/src/main/res/drawable/ic_subscriptions_white_24dp.xml
new file mode 100644
index 0000000..bc20a83
--- /dev/null
+++ b/app/src/main/res/drawable/ic_subscriptions_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_vpnhotspot.xml b/app/src/main/res/drawable/ic_vpnhotspot.xml
new file mode 100755
index 0000000..4e72383
--- /dev/null
+++ b/app/src/main/res/drawable/ic_vpnhotspot.xml
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/ic_whatshot_black_24dp.xml b/app/src/main/res/drawable/ic_whatshot_black_24dp.xml
new file mode 100644
index 0000000..1cbc037
--- /dev/null
+++ b/app/src/main/res/drawable/ic_whatshot_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_whatshot_white_24dp.xml b/app/src/main/res/drawable/ic_whatshot_white_24dp.xml
new file mode 100644
index 0000000..ad460f3
--- /dev/null
+++ b/app/src/main/res/drawable/ic_whatshot_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/layout/activity_bypass_list.xml b/app/src/main/res/layout/activity_bypass_list.xml
new file mode 100644
index 0000000..9baff9a
--- /dev/null
+++ b/app/src/main/res/layout/activity_bypass_list.xml
@@ -0,0 +1,92 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/activity_logcat.xml b/app/src/main/res/layout/activity_logcat.xml
new file mode 100644
index 0000000..06916be
--- /dev/null
+++ b/app/src/main/res/layout/activity_logcat.xml
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
new file mode 100644
index 0000000..f2db381
--- /dev/null
+++ b/app/src/main/res/layout/activity_main.xml
@@ -0,0 +1,124 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_none.xml b/app/src/main/res/layout/activity_none.xml
new file mode 100644
index 0000000..d768169
--- /dev/null
+++ b/app/src/main/res/layout/activity_none.xml
@@ -0,0 +1,5 @@
+
+
+
diff --git a/app/src/main/res/layout/activity_routing_settings.xml b/app/src/main/res/layout/activity_routing_settings.xml
new file mode 100644
index 0000000..7052cc5
--- /dev/null
+++ b/app/src/main/res/layout/activity_routing_settings.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_server_custom_config.xml b/app/src/main/res/layout/activity_server_custom_config.xml
new file mode 100644
index 0000000..368386c
--- /dev/null
+++ b/app/src/main/res/layout/activity_server_custom_config.xml
@@ -0,0 +1,81 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_server_shadowsocks.xml b/app/src/main/res/layout/activity_server_shadowsocks.xml
new file mode 100644
index 0000000..aba1b74
--- /dev/null
+++ b/app/src/main/res/layout/activity_server_shadowsocks.xml
@@ -0,0 +1,130 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/activity_server_socks.xml b/app/src/main/res/layout/activity_server_socks.xml
new file mode 100644
index 0000000..0cac6ea
--- /dev/null
+++ b/app/src/main/res/layout/activity_server_socks.xml
@@ -0,0 +1,131 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/activity_server_trojan.xml b/app/src/main/res/layout/activity_server_trojan.xml
new file mode 100644
index 0000000..7781ed4
--- /dev/null
+++ b/app/src/main/res/layout/activity_server_trojan.xml
@@ -0,0 +1,198 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/activity_server_vless.xml b/app/src/main/res/layout/activity_server_vless.xml
new file mode 100644
index 0000000..4132237
--- /dev/null
+++ b/app/src/main/res/layout/activity_server_vless.xml
@@ -0,0 +1,237 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_server_vmess.xml b/app/src/main/res/layout/activity_server_vmess.xml
new file mode 100644
index 0000000..8c1c89b
--- /dev/null
+++ b/app/src/main/res/layout/activity_server_vmess.xml
@@ -0,0 +1,236 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_settings.xml b/app/src/main/res/layout/activity_settings.xml
new file mode 100644
index 0000000..f674f2a
--- /dev/null
+++ b/app/src/main/res/layout/activity_settings.xml
@@ -0,0 +1,8 @@
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/activity_sub_edit.xml b/app/src/main/res/layout/activity_sub_edit.xml
new file mode 100644
index 0000000..1c84dd5
--- /dev/null
+++ b/app/src/main/res/layout/activity_sub_edit.xml
@@ -0,0 +1,129 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/activity_sub_setting.xml b/app/src/main/res/layout/activity_sub_setting.xml
new file mode 100644
index 0000000..bb87f38
--- /dev/null
+++ b/app/src/main/res/layout/activity_sub_setting.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/activity_tasker.xml b/app/src/main/res/layout/activity_tasker.xml
new file mode 100644
index 0000000..5c798c3
--- /dev/null
+++ b/app/src/main/res/layout/activity_tasker.xml
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/dialog_config_filter.xml b/app/src/main/res/layout/dialog_config_filter.xml
new file mode 100644
index 0000000..115371d
--- /dev/null
+++ b/app/src/main/res/layout/dialog_config_filter.xml
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_routing_settings.xml b/app/src/main/res/layout/fragment_routing_settings.xml
new file mode 100644
index 0000000..7acc1c4
--- /dev/null
+++ b/app/src/main/res/layout/fragment_routing_settings.xml
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/item_qrcode.xml b/app/src/main/res/layout/item_qrcode.xml
new file mode 100644
index 0000000..3c53162
--- /dev/null
+++ b/app/src/main/res/layout/item_qrcode.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
diff --git a/app/src/main/res/layout/item_recycler_bypass_list.xml b/app/src/main/res/layout/item_recycler_bypass_list.xml
new file mode 100644
index 0000000..03ad10a
--- /dev/null
+++ b/app/src/main/res/layout/item_recycler_bypass_list.xml
@@ -0,0 +1,52 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/item_recycler_footer.xml b/app/src/main/res/layout/item_recycler_footer.xml
new file mode 100644
index 0000000..911ef7f
--- /dev/null
+++ b/app/src/main/res/layout/item_recycler_footer.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/item_recycler_main.xml b/app/src/main/res/layout/item_recycler_main.xml
new file mode 100644
index 0000000..7a42c50
--- /dev/null
+++ b/app/src/main/res/layout/item_recycler_main.xml
@@ -0,0 +1,189 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/item_recycler_sub_setting.xml b/app/src/main/res/layout/item_recycler_sub_setting.xml
new file mode 100644
index 0000000..a4467e2
--- /dev/null
+++ b/app/src/main/res/layout/item_recycler_sub_setting.xml
@@ -0,0 +1,109 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/item_recycler_user_asset.xml b/app/src/main/res/layout/item_recycler_user_asset.xml
new file mode 100644
index 0000000..5465177
--- /dev/null
+++ b/app/src/main/res/layout/item_recycler_user_asset.xml
@@ -0,0 +1,60 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/nav_view.xml b/app/src/main/res/layout/nav_view.xml
new file mode 100644
index 0000000..9223c8d
--- /dev/null
+++ b/app/src/main/res/layout/nav_view.xml
@@ -0,0 +1,8 @@
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/tls_layout.xml b/app/src/main/res/layout/tls_layout.xml
new file mode 100644
index 0000000..dcbf2e1
--- /dev/null
+++ b/app/src/main/res/layout/tls_layout.xml
@@ -0,0 +1,167 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/widget_switch.xml b/app/src/main/res/layout/widget_switch.xml
new file mode 100644
index 0000000..209489d
--- /dev/null
+++ b/app/src/main/res/layout/widget_switch.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
diff --git a/app/src/main/res/menu/action_server.xml b/app/src/main/res/menu/action_server.xml
new file mode 100644
index 0000000..1be0f15
--- /dev/null
+++ b/app/src/main/res/menu/action_server.xml
@@ -0,0 +1,14 @@
+
+
\ No newline at end of file
diff --git a/app/src/main/res/menu/action_sub_setting.xml b/app/src/main/res/menu/action_sub_setting.xml
new file mode 100644
index 0000000..87137e0
--- /dev/null
+++ b/app/src/main/res/menu/action_sub_setting.xml
@@ -0,0 +1,19 @@
+
+
\ No newline at end of file
diff --git a/app/src/main/res/menu/menu_asset.xml b/app/src/main/res/menu/menu_asset.xml
new file mode 100644
index 0000000..e10736c
--- /dev/null
+++ b/app/src/main/res/menu/menu_asset.xml
@@ -0,0 +1,14 @@
+
+
\ No newline at end of file
diff --git a/app/src/main/res/menu/menu_bypass_list.xml b/app/src/main/res/menu/menu_bypass_list.xml
new file mode 100644
index 0000000..fef2457
--- /dev/null
+++ b/app/src/main/res/menu/menu_bypass_list.xml
@@ -0,0 +1,34 @@
+
+
diff --git a/app/src/main/res/menu/menu_drawer.xml b/app/src/main/res/menu/menu_drawer.xml
new file mode 100644
index 0000000..0179c03
--- /dev/null
+++ b/app/src/main/res/menu/menu_drawer.xml
@@ -0,0 +1,38 @@
+
+
diff --git a/app/src/main/res/menu/menu_logcat.xml b/app/src/main/res/menu/menu_logcat.xml
new file mode 100644
index 0000000..63d0af8
--- /dev/null
+++ b/app/src/main/res/menu/menu_logcat.xml
@@ -0,0 +1,14 @@
+
+
\ No newline at end of file
diff --git a/app/src/main/res/menu/menu_main.xml b/app/src/main/res/menu/menu_main.xml
new file mode 100644
index 0000000..96bbe7e
--- /dev/null
+++ b/app/src/main/res/menu/menu_main.xml
@@ -0,0 +1,107 @@
+
+
\ No newline at end of file
diff --git a/app/src/main/res/menu/menu_routing.xml b/app/src/main/res/menu/menu_routing.xml
new file mode 100644
index 0000000..bae83a8
--- /dev/null
+++ b/app/src/main/res/menu/menu_routing.xml
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/menu/menu_scanner.xml b/app/src/main/res/menu/menu_scanner.xml
new file mode 100644
index 0000000..168cdfa
--- /dev/null
+++ b/app/src/main/res/menu/menu_scanner.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..e202e4b
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 0000000..036d09b
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000..cb9b3d3
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..4d38fcc
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
new file mode 100644
index 0000000..76cd646
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000..aa289ac
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..bac8f39
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
new file mode 100644
index 0000000..010f682
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000..3c04c3b
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..7da5174
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..e24ac5d
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000..1fde924
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..2bc746a
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..448a478
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000..647b0a2
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..dc7e393
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..ad44d89
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/raw/licenses.xml b/app/src/main/res/raw/licenses.xml
new file mode 100644
index 0000000..0672334
--- /dev/null
+++ b/app/src/main/res/raw/licenses.xml
@@ -0,0 +1,49 @@
+
+
+ Android Compatibility Library v4
+ http://source.android.com
+ Copyright(C) 2008-2011 The Android Open Source Project
+ Apache Software License 2.0
+
+
+ Android Compatibility Library v7
+ http://source.android.com
+ Copyright(C) 2008-2011 The Android Open Source Project
+ Apache Software License 2.0
+
+
+ Android Design Library
+ http://source.android.com
+ Copyright(C) 2008-2011 The Android Open Source Project
+ Apache Software License 2.0
+
+
+ Google Gson
+ https://github.com/google/gson
+ Copyright 2008-2011 Google Inc.
+ Apache Software License 2.0
+
+
+ kotlin
+ http://kotlinlang.org/
+ Copyright 2010-2016 JetBrains s.r.o.
+ Apache Software License 2.0
+
+
+ Logger
+ https://github.com/orhanobut/logger
+ Copyright 2015 Orhan Obut
+ Apache Software License 2.0
+
+
+ LeakCanary
+ https://github.com/square/leakcanary
+ Copyright 2015 Square, Inc.
+ Apache Software License 2.0
+
+
+ RxPermissions
+ https://github.com/tbruyelle/RxPermissions
+ Apache Software License 2.0
+
+
diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml
new file mode 100644
index 0000000..57ec67c
--- /dev/null
+++ b/app/src/main/res/values-night/colors.xml
@@ -0,0 +1,7 @@
+
+
+ #FB7279
+ #000000
+ #CCCCCC
+ #CCCCCC
+
diff --git a/app/src/main/res/values-sw360dp-v13/values-preference.xml b/app/src/main/res/values-sw360dp-v13/values-preference.xml
new file mode 100644
index 0000000..9915362
--- /dev/null
+++ b/app/src/main/res/values-sw360dp-v13/values-preference.xml
@@ -0,0 +1,5 @@
+
+
+ false
+ 0dp
+
\ No newline at end of file
diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml
new file mode 100644
index 0000000..cc424ee
--- /dev/null
+++ b/app/src/main/res/values-vi/strings.xml
@@ -0,0 +1,249 @@
+
+
+ Kết nối ngay
+ Kết nối ngay
+ Vui lòng thêm một cấu hình vào v2rayNG để sử dụng!
+ Mở Menu ứng dụng
+ Đóng Menu ứng dụng
+ Đã chuyển dữ liệu!
+ Không thể chuyển dữ liệu!
+
+
+ Ngắt kết nối v2rayNG
+ Vui lòng cấp quyền cần thiết cho v2rayNG! Bạn đã từ chối các quyền cần thiết như Camera hay Bộ nhớ?
+ Nhấn để biết thêm
+ Đang bắt đầu v2rayNG...
+ Đã dừng v2rayNG!
+ Đã bắt đầu v2rayNG!
+ Không thể bắt đầu v2rayNG, kiểm tra lại cấu hình hoặc khởi động lại thiết bị.
+ Khởi động lại dịch vụ v2rayNG để có hiệu lực!
+ Đang mở ứng dụng VPN Hotspot...
+ Đang mở trong trình duyệt...
+ Cẩn thận với các trang cung cấp gói đăng ký độc hại hoặc lừa đảo!
+
+
+ V2RayNG
+ Thêm cấu hình
+ Lưu cấu hình
+ Xoá cấu hình
+ Xóa cấu hình bị trùng lặp
+ Xóa %d cấu hình bị trùng lặp
+ Nhập cấu hình từ mã QR
+ Nhập cấu hình từ bộ nhớ tạm thời
+ Nhập thủ công [Vmess]
+ Nhập thủ công [VLESS]
+ Nhập thủ công [Shadowsocks]
+ Nhập thủ công [Socks]
+ Nhập thủ công [Trojan]
+ Nâng cao / Cấu hình tùy chỉnh
+ Nhập cấu hình tùy chỉnh từ bộ nhớ tạm thời
+ Nhập cấu hình tùy chỉnh từ Tệp
+ Nhập cấu hình tùy chỉnh từ URL
+ Nhập cấu hình tùy chỉnh quét URL
+ Bạn có muốn xóa cấu hình không?
+ Tên cấu hình
+ Địa chỉ
+ Cổng
+ ID
+ ID thay thế
+ Bảo mật
+ Kiểu kết nối
+ Nâng cao
+ Loại Head
+ Chế độ gRPC
+ Yêu cầu host (Host/WS/H2) / Bảo mật QUIC
+ Đường dẫn (WS/H2) / Khóa QUIC / KCP seed / Dịch vụ gRPC
+ TLS
+ Bỏ qua xác minh chứng chỉ (allowinsecure)
+ Địa chỉ SNI
+ Địa chỉ
+ Cổng
+ Mật khẩu
+ Bảo mật
+ Mật khẩu (Bổ sung)
+ Tên người dùng (Bổ sung)
+ Mã hoá
+ Kiểm soát lưu lượng
+ Thành công!
+ Đã xảy ra lỗi, vui lòng thử lại!
+ Không có gì ở đây
+ Không đúng Protocol
+ Không thể giải mã
+ Vui lòng chọn tệp cấu hình!
+ Vui lòng cài đặt trình quản lý tệp để tiếp tục!
+ Cấu hình tùy chỉnh
+ Cấu hình không hợp lệ!
+ Nội dung
+ Không có dữ liệu nào trong Clipboard!
+ URL không hợp lệ hoặc trống!
+ Vui lòng đảm bảo cấu hình tùy chỉnh này không bị lỗi trước khi sử dụng.
+ Cấu hình không hợp lệ!
+ Host (SNI) (Bổ sung)
+ Không thể sao chép tệp tin, hãy dùng trình quản lý tệp.
+ Thêm tệp
+ Tải xuống tệp tin
+
+
+ Đang tải...
+ Tìm kiếm
+ Chọn tất cả
+ Nhập từ khoá
+ Bỏ qua kết nối VPN
+ Tự động chọn ứng dụng Proxy
+ Đang tải xuống nội dung...
+ Xuất và sao chép
+ Nhập từ bộ nhớ tạm thời
+
+
+
+ Cài đặt
+ Cài đặt nâng cao
+ Cài đặt VPN
+ Proxy ứng dụng
+ - Chung: Ứng dụng đã chọn sẽ kết nối Proxy, chưa lựa chọn sẽ kết nối trực tiếp. \n- Bỏ qua kết nối: Ứng dụng được chọn sẽ trực tiếp kết nối, không lựa chọn sẽ kết nối qua Proxy. \n- Lựa chọn để tự động chọn ứng dụng Proxy trong Menu.
+
+ Cho phép Mux
+ Bật lên có thể làm tăng tốc độ mạng và chuyển mạng nhanh hơn.
+ Mức concurrency mux
+ Tùy chọn Mux (Tăng tốc kết nối)
+
+ Cho phép hiển thị tốc độ mạng
+ Hiển thị tốc độ mạng hiện tại trên thanh thông báo.\nBiểu tượng trên thanh trạng thái có thể thay đổi tùy vào mức sử dụng.
+
+ Cho phép Sniffing
+ Thử chuyển kết nối hiện tại của bạn qua trung gian để trung gian xử lý kết nối về lại cho bạn. (Đừng bật nó nếu kết nối không ổn định)
+
+ Cho phép DNS cục bộ
+ DNS được xử lý bởi mô đun của lõi DNS. (Khuyến nghị, nếu cần định tuyến Bỏ qua mạng LAN và địa chỉ mainland)
+
+ Cho phép DNS giả
+ DNS cục bộ trả về địa chỉ IP giả. (Nhanh hơn, nhưng có thể không hoạt động với một số ứng dụng)
+
+ Ưu tiên IPv6
+ Ưu tiên sử dụng địa chỉ IPv6 cho kết nối và lộ trình.
+
+ Lộ trình
+ Tùy chọn tên miền
+ Tùy chỉnh quy tắc lộ trình
+ Tùy chỉnh lộ trình
+
+ Điều khiển DNS (Bổ sung)
+ DNS
+
+ VPN DNS (Chỉ IPv4/IPv6)
+
+ DNS trong nước (Bổ sung)
+ DNS
+
+ Cho phép kết nối từ mạng LAN
+ Các thiết bị khác có thể kết nối đến Proxy bởi địa chỉ IP thông qua Socks/HTTP, chỉ bật khi bạn tin tưởng kết nối đó.
+ Cho phép kết nối từ mạng LAN, đảm bảo rằng bạn tin tưởng kết nối hiện tại!
+
+ Cho phép đặt lại Bỏ qua xác minh chứng chỉ
+ Khi kết nối TLS, đặt cài đặt Bỏ qua xác minh chứng chỉ (allowinsecure) thành mặc định.
+
+ Cổng Proxy SOCKS5
+ Cổng Proxy SOCKS5
+
+ Cổng Proxy HTTP
+ Cổng Proxy HTTP
+
+ Cổng DNS cục bộ
+ Cổng DNS cục bộ
+
+ Hiển thị thông báo xác nhận xoá cấu hình
+ Hiển thị thông báo xác nhận xoá cấu hình khi bạn xoá một cấu hình.
+
+ Phản hồi lỗi
+ Phản hồi cải tiến hoặc lỗi lên GitLab
+ Tham gia nhóm Telegram
+ Không tìm thấy ứng dụng Telegram
+
+ Mức độ nhật ký
+ Chế độ kết nối
+ Ngôn ngữ
+
+ Nhật ký hoạt động
+ Sao chép nhật ký
+ Xoá nhật ký
+ Kết nối lại v2rayNG
+ Xoá tất cả cấu hình
+ Xoá cấu hình lỗi (Kiểm tra trước)
+ Xuất và sao chép tất cả cấu hình
+ Các gói đăng ký
+ Tên các gói đăng ký
+ URL gói đăng ký
+ Sử dụng gói đăng ký này
+ Cập nhật các gói đăng ký
+ Ping tất cả máy chủ
+ Kiểm tra máy chủ
+ Tệp Geo asset
+ Sắp xếp lại theo lần kiểm tra cuối cùng
+ Lọc cấu hình theo các gói đăng ký
+ Hiển thị tất cả các gói đăng ký
+
+ Bắt đầu v2rayNG
+ Xác nhận
+
+ Cài đặt lộ trình
+ Được phân cách bằng dấu phẩy (,). Hãy nhớ nó để lưu lại!
+ Lưu lại
+ Xoá
+ Quét và thay thế
+ Quét và nối
+ Đặt luật cho lộ trình mặc định
+
+ Kiểm tra kết nối
+ Đang kiểm tra kết nối mạng...
+ Kiểm tra kết nối mạng thành công! Ping hiện tại là %d
+ Lỗi kết nối mạng, hãy thử đổi cấu hình hoặc kiểm tra lại! Mã lỗi: %s
+ Không có kết nối mạng!
+ Mã lỗi: #%d
+ Đã kết nối, hãy nhấn vào đây để kiểm tra kết nối mạng!
+ Chưa kết nối, hãy chọn một cấu hình để kết nối!
+ Bắt đầu quét mã QR ngay lập tức
+ Mở giao diện quét mã QR ngay khi nhấn "Nhập cấu hình từ mã QR", hoặc thay vào đó bạn có thể chọn để mở camera hay chọn hình ảnh trên thanh công cụ.
+
+ Xem mã nguồn tại GitLab
+ Tôi luôn thích và mở mã nguồn ứng dụng này :3
+ Giới thiệu về ứng dụng này
+ Ủng hộ Cuynu
+ Giúp tôi tìm hiểu và cải thiện chất lượng mã nguồn mở của ứng dụng này bằng cách đóng góp một khoản tiền nhỏ
+ Tự động cập nhật gói đăng ký
+ Tự động cập nhật gói đăng ký của bạn với khoảng thời gian được chỉ định trong nền
+ Tự động cập nhật gói đăng ký
+ Khoảng thời gian cập nhật gói đăng ký (Phút) (Mặc định : 1440 phút)
+ Thông tin về bản dựng (build)
+ Phiên bản ứng dụng
+ Cài đặt thông báo
+ Bật chế độ VPN luôn bật
+ Điều này sẽ mở cài đặt thiết bị!
+ Mở VPN Hotspot (root)
+ Đảm bảo rằng cấu hình này đang không được sử dụng để có thể lưu thay đổi.
+
+
+ - Xuất ra mã QR (Chụp màn hình để lưu)
+ - Sao chép cấu hình này
+ - Sao chép thành cấu hình tùy chỉnh
+
+
+
+ - Proxy URL hoặc IP
+ - Direct URL hoặc IP
+ - URL đã chặn hoặc IP
+
+
+
+ - Proxy toàn cầu
+ - Bỏ qua địa chỉ LAN rồi Proxy
+ - Bỏ qua địa chỉ mainland rồi Proxy
+ - Bỏ qua LAN và địa chỉ mainland rồi Proxy
+ - Kết nối trực tiếp toàn cầu
+
+
+
+ - Chế độ VPN
+ - Chế độ Proxy
+
+
+
diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml
new file mode 100644
index 0000000..06ee164
--- /dev/null
+++ b/app/src/main/res/values/arrays.xml
@@ -0,0 +1,167 @@
+
+
+
+ - chacha20-poly1305
+ - aes-128-gcm
+ - auto
+ - none
+ - zero
+
+
+ - aes-256-gcm
+ - aes-128-gcm
+ - chacha20-poly1305
+ - chacha20-ietf-poly1305
+ - xchacha20-poly1305
+ - xchacha20-ietf-poly1305
+ - none
+ - plain
+ - 2022-blake3-aes-128-gcm
+ - 2022-blake3-aes-256-gcm
+ - 2022-blake3-chacha20-poly1305
+
+
+
+ - tcp
+ - kcp
+ - ws
+ - h2
+ - quic
+ - grpc
+
+
+
+ - none
+ - http
+
+
+
+ - none
+ - srtp
+ - utp
+ - wechat-video
+ - dtls
+ - wireguard
+
+
+
+ - gun
+ - multi
+
+
+
+
+
+ - tls
+
+
+
+ - tls
+ - reality
+
+
+
+
+ - chrome
+ - ios
+ - android
+ - random
+ - randomized
+
+
+
+
+ - h2
+ - http/1.1
+ - h2,http/1.1
+
+
+
+
+ - true
+ - false
+
+
+
+ - 0
+ - 1
+ - 2
+ - 3
+ - 4
+
+
+
+ - AsIs
+ - IPIfNonMatch
+ - IPOnDemand
+
+
+
+ - debug
+ - info
+ - warning
+ - error
+ - none
+
+
+
+ - VPN
+ - Proxy only
+
+
+
+
+ - xtls-rprx-vision
+ - xtls-rprx-vision-udp443
+
+
+
+
+
+ - 0.0.0.0/5
+ - 8.0.0.0/7
+ - 11.0.0.0/8
+ - 12.0.0.0/6
+ - 16.0.0.0/4
+ - 32.0.0.0/3
+ - 64.0.0.0/2
+ - 128.0.0.0/3
+ - 160.0.0.0/5
+ - 168.0.0.0/6
+ - 172.0.0.0/12
+ - 172.32.0.0/11
+ - 172.64.0.0/10
+ - 172.128.0.0/9
+ - 173.0.0.0/8
+ - 174.0.0.0/7
+ - 176.0.0.0/4
+ - 192.0.0.0/9
+ - 192.128.0.0/11
+ - 192.160.0.0/13
+ - 192.169.0.0/16
+ - 192.170.0.0/15
+ - 192.172.0.0/14
+ - 192.176.0.0/12
+ - 192.192.0.0/10
+ - 193.0.0.0/8
+ - 194.0.0.0/7
+ - 196.0.0.0/6
+ - 200.0.0.0/5
+ - 208.0.0.0/4
+ - 240.0.0.0/4
+
+
+
+
+ - auto
+ - English
+ - Tiếng Việt
+
+
+
+
+ - auto
+ - en
+ - vi
+
+
diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml
new file mode 100644
index 0000000..1f970e5
--- /dev/null
+++ b/app/src/main/res/values/attrs.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..b90cc5f
--- /dev/null
+++ b/app/src/main/res/values/colors.xml
@@ -0,0 +1,15 @@
+
+
+ #FB7279
+ #FF0099
+ #727272
+ #FB7299
+ #727272
+
+
+ #FB7299
+ #000000
+ #FB7279
+ #000000
+ #000000
+
diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml
new file mode 100644
index 0000000..d082409
--- /dev/null
+++ b/app/src/main/res/values/dimens.xml
@@ -0,0 +1,16 @@
+
+
+ 50dp
+ 16dp
+ 16dp
+ 8dp
+ 50dp
+ 24dp
+ 72dp
+ 60dp
+
+ 16dp
+ 16dp
+ 8dp
+ 160dp
+
diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml
new file mode 100644
index 0000000..c5d5899
--- /dev/null
+++ b/app/src/main/res/values/ic_launcher_background.xml
@@ -0,0 +1,4 @@
+
+
+ #FFFFFF
+
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..d954ffe
--- /dev/null
+++ b/app/src/main/res/values/strings.xml
@@ -0,0 +1,258 @@
+
+
+ v2rayNG
+ Switch
+ Switch
+ First use of this feature, please use the app to add server
+ Open navigation drawer
+ Close navigation drawer
+ Data migration success!
+ Data migration failed!
+
+
+ Stop
+ Unable to obtain the permission
+ click for more
+ Start Services
+ Stop Services
+ Start Services Success
+ Start Services Failure
+ Restart v2rayNG service to take effects!
+ Opening VPN Hotspot app...
+ Opening on default browser...
+ Carefully with malicious or scam subscription site!
+
+
+ Configuration file
+ Add config
+ Save config
+ Delete config
+ Delete duplicate config
+ Delete %d duplicate c
+ Import config from QRcode
+ Import config from Clipboard
+ Type manually[Vmess]
+ Type manually[VLESS]
+ Type manually[Shadowsocks]
+ Type manually[Socks]
+ Type manually[Trojan]
+ Custom config
+ Import custom config from Clipboard
+ Import custom config from locally
+ Import custom config from URL
+ Import custom config scan URL
+ Confirm delete?
+ remarks
+ address
+ port
+ id
+ alterId
+ security
+ Network
+ Transport
+ head type
+ gRPC mode
+ request host(host/ws host/h2 host)/QUIC security
+ path(ws path/h2 path)/QUIC key/kcp seed/gRPC serviceName
+ TLS
+ Fingerprint
+ Alpn
+ allowInsecure
+ SNI
+ address
+ port
+ password
+ security
+ Password(Optional)
+ User(Optional)
+ encryption
+ flow
+ Public key
+ Short Id
+ SpiderX
+ Success
+ Failure
+ There is nothing
+ Incorrect protocol
+ Decoding failed
+ Select a Config File
+ Please install a File Manager.
+ Customize Config
+ Invalid Config
+ Content
+ There is no data in the clipboard
+ Invalid URL
+ Ensure inbounds port is consistent with the settings
+ Config malformed
+ Host(SNI)(Optional)
+ File copy failed, please use File Manager
+ Add files
+ Download files
+
+
+ Loading
+ Search
+ Select all
+ Enter keywords
+ Bypass Mode
+ Auto select proxy app
+ Downloading content
+ Export to Clipboard
+ Import from Clipboard
+
+
+
+ Settings
+ Advanced/Misc Settings
+ VPN Settings
+ Per-app proxy
+ General: Checked App is proxy, unchecked direct connection; \nbypass mode: checked app directly connected, unchecked proxy. \nThe option to automatically select the proxy application in the menu
+ Enable Mux
+ Enable maybe speed up network and switch network maybe flash
+ Mux Concurrency
+ Mux options
+
+ Enable speed display
+ Display current speed in the notification.\nNotification icon would change based on
+ usage.
+
+ Enable Sniffing
+ Try sniff domain from the packet
+
+ Enable local DNS
+ DNS processed by core‘s DNS module (Recommended, if need routing Bypassing LAN and
+ mainland address)
+
+ Enable fake DNS
+ local DNS returns fake IP address (faster, but it may not work for some apps)
+
+ Prefer IPv6
+ Prefer IPv6 address and routes
+
+ Routing
+ Domain strategy
+ Predefined rules
+ Custom rules
+
+ Remote DNS (Optional)
+ DNS
+
+ VPN DNS (only IPv4/v6)
+
+ Domestic DNS (Optional)
+ DNS
+
+ Allow connections from the LAN
+ Other devices can connect to proxy by your ip address through socks/http, Only enable in trusted network to avoid unauthorized connection
+ Allow connections from the LAN, Make sure you are in a trusted network
+
+ allowInsecure
+ When TLS, the default allowInsecure
+
+ SOCKS5 proxy port
+ SOCKS5 proxy port
+
+ HTTP proxy port
+ HTTP proxy port
+
+ Local DNS port
+ Local DNS port
+
+ Start scanning immediately
+ Open the camera to scan immediately at startup, otherwise you can choose to scan the code or select a photo in the toolbar
+ Delete configuration file confirmation
+ Whether to delete the configuration file requires a second confirmation by the user
+
+ Feedback
+ Feedback enhancements or bugs to GitLab
+ Join Telegram Group
+ Telegram app not found
+
+
+ Log Level
+ Mode
+ Language
+
+ Logcat
+ Copy
+ Clear
+ Service restart
+ Delete all config
+ Delete invalid config(Test first)
+ Export non-custom configs to clipboard
+ Subscription group setting
+ remarks
+ Optional URL
+ enable update
+ Update subscription
+ Tcping all configuration
+ Real delay all configuration
+ Geo asset files
+ Sorting by test results
+ Filter configuration file
+ All subscription groups
+
+ Start Service
+ Confirm
+
+ Routing Settings
+ Separated by commas(,),remember to save
+ Save
+ Clear
+ Scan and replace
+ Scan and append
+ set default routing rules
+
+ Check Connectivity
+ Testing…
+ Success: HTTP connection took %dms
+ Fail to detect internet connection: %s
+ Internet Unavailable
+ Error code: #%d
+ Connected, tap to check connection
+ Not connected
+
+ View source code in GitLab
+ I always love and open source this app :3
+ About this app
+ Support Cuynu
+ Support me learn and improve open source code quality by donate some little money
+ Auto update
+ Update your subscriptions automatically with an interval in background
+ Automatic update subscriptions
+ Subscription Update Interval (Minutes)
+ Build information
+ app version
+ Notification settings
+ Enable VPN always on
+ This will open your device settings!
+ Open VPN Hotspot (root)
+ Ensure this configuration are not being used to able to save changes.
+
+
+
+ - QRcode
+ - Export to clipboard
+ - Export full configuration to clipboard
+
+
+
+ - proxy URL or IP
+ - direct URL or IP
+ - blocked URL or IP
+
+
+
+ - Global proxy
+ - Bypassing the LAN address then proxy
+ - Bypass mainland address then proxy
+ - Bypassing LAN and mainland address then proxy
+ - Global direct
+
+
+
+ - VPN
+ - Proxy only
+
+
+
diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml
new file mode 100644
index 0000000..70d0270
--- /dev/null
+++ b/app/src/main/res/values/styles.xml
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/xml/app_widget_provider.xml b/app/src/main/res/xml/app_widget_provider.xml
new file mode 100644
index 0000000..f457545
--- /dev/null
+++ b/app/src/main/res/xml/app_widget_provider.xml
@@ -0,0 +1,6 @@
+
+
\ No newline at end of file
diff --git a/app/src/main/res/xml/pref_settings.xml b/app/src/main/res/xml/pref_settings.xml
new file mode 100644
index 0000000..d61de6d
--- /dev/null
+++ b/app/src/main/res/xml/pref_settings.xml
@@ -0,0 +1,179 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/xml/shortcuts.xml b/app/src/main/res/xml/shortcuts.xml
new file mode 100644
index 0000000..3e5f365
--- /dev/null
+++ b/app/src/main/res/xml/shortcuts.xml
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/test/java/com/v2ray/ang/ExampleUnitTest.java b/app/src/test/java/com/v2ray/ang/ExampleUnitTest.java
new file mode 100644
index 0000000..5c6cdaa
--- /dev/null
+++ b/app/src/test/java/com/v2ray/ang/ExampleUnitTest.java
@@ -0,0 +1,15 @@
+package com.v2ray.ang;
+
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+
+/**
+ * To work on unit tests, switch the Test Artifact in the Build Variants view.
+ */
+public class ExampleUnitTest {
+ @Test
+ public void addition_isCorrect() throws Exception {
+ assertEquals(4, 2 + 2);
+ }
+}
\ No newline at end of file
diff --git a/app/src/test/kotlin/com/v2ray/ang/ExampleUnitTest.kt b/app/src/test/kotlin/com/v2ray/ang/ExampleUnitTest.kt
new file mode 100644
index 0000000..dd21d55
--- /dev/null
+++ b/app/src/test/kotlin/com/v2ray/ang/ExampleUnitTest.kt
@@ -0,0 +1,37 @@
+import org.junit.Assert.*
+import org.junit.Test
+import com.v2ray.ang.util.Utils
+
+class UtilTest {
+
+ @Test
+ fun test_parseInt() {
+ assertEquals(Utils.parseInt("1234"), 1234)
+ }
+
+ @Test
+ fun test_isIpAddress() {
+ assertFalse(Utils.isIpAddress("114.113.112.266"))
+ assertFalse(Utils.isIpAddress("666.666.666.666"))
+ assertFalse(Utils.isIpAddress("256.0.0.0" ))
+ assertFalse(Utils.isIpAddress("::ffff:127.0.0.0.1" ))
+ assertFalse(Utils.isIpAddress("baidu.com"))
+ assertFalse(Utils.isIpAddress(""))
+
+ assertTrue(Utils.isIpAddress("127.0.0.1" ))
+ assertTrue(Utils.isIpAddress("127.0.0.1:80" ))
+ assertTrue(Utils.isIpAddress("::1" ))
+ assertTrue(Utils.isIpAddress("[::1]:80" ))
+ assertTrue(Utils.isIpAddress("2605:2700:0:3::4713:93e3" ))
+ assertTrue(Utils.isIpAddress("[2605:2700:0:3::4713:93e3]:80" ))
+ assertTrue(Utils.isIpAddress("::ffff:192.168.173.22" ))
+ assertTrue(Utils.isIpAddress("[::ffff:192.168.173.22]:80" ))
+ assertTrue(Utils.isIpAddress("1::" ))
+ assertTrue(Utils.isIpAddress("::" ))
+ assertTrue(Utils.isIpAddress("10.24.56.0/24" ))
+ assertTrue(Utils.isIpAddress("2001:4321::1" ))
+ assertTrue(Utils.isIpAddress("240e:1234:abcd:12::6666" ))
+ assertTrue(Utils.isIpAddress("240e:1234:abcd:12::/64" ))
+ }
+}
+
diff --git a/build.gradle b/build.gradle
new file mode 100644
index 0000000..7680af1
--- /dev/null
+++ b/build.gradle
@@ -0,0 +1,6 @@
+// Top-level build file where you can add configuration options common to all sub-projects/modules.
+plugins {
+ id 'com.android.application' version '8.1.3' apply false
+ id 'com.android.library' version '8.1.3' apply false
+ id 'org.jetbrains.kotlin.android' version '1.8.0' apply false
+}
\ No newline at end of file
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 0000000..962a7e7
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,12 @@
+
+buildToolsVer=33.0.2
+compileSdkVer=33
+targetSdkVer=33
+kotlin.incremental=true
+android.useAndroidX=true
+android.enableJetifier=true
+kotlin.code.style=official
+android.nonTransitiveRClass=true
+org.gradle.jvmargs=-Xmx2048m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
+android.defaults.buildfeatures.buildconfig=true
+android.nonFinalResIds=false
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..13372ae
Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..4a1af78
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,6 @@
+#Sun Jun 21 12:33:19 CST 2020
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-all.zip
diff --git a/gradlew b/gradlew
new file mode 100644
index 0000000..9d82f78
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,160 @@
+#!/usr/bin/env bash
+
+##############################################################################
+##
+## Gradle start up script for UN*X
+##
+##############################################################################
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS=""
+
+APP_NAME="Gradle"
+APP_BASE_NAME=`basename "$0"`
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD="maximum"
+
+warn ( ) {
+ echo "$*"
+}
+
+die ( ) {
+ echo
+ echo "$*"
+ echo
+ exit 1
+}
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+case "`uname`" in
+ CYGWIN* )
+ cygwin=true
+ ;;
+ Darwin* )
+ darwin=true
+ ;;
+ MINGW* )
+ msys=true
+ ;;
+esac
+
+# Attempt to set APP_HOME
+# Resolve links: $0 may be a link
+PRG="$0"
+# Need this for relative symlinks.
+while [ -h "$PRG" ] ; do
+ ls=`ls -ld "$PRG"`
+ link=`expr "$ls" : '.*-> \(.*\)$'`
+ if expr "$link" : '/.*' > /dev/null; then
+ PRG="$link"
+ else
+ PRG=`dirname "$PRG"`"/$link"
+ fi
+done
+SAVED="`pwd`"
+cd "`dirname \"$PRG\"`/" >/dev/null
+APP_HOME="`pwd -P`"
+cd "$SAVED" >/dev/null
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD="$JAVA_HOME/jre/sh/java"
+ else
+ JAVACMD="$JAVA_HOME/bin/java"
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD="java"
+ which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+fi
+
+# Increase the maximum file descriptors if we can.
+if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then
+ MAX_FD_LIMIT=`ulimit -H -n`
+ if [ $? -eq 0 ] ; then
+ if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
+ MAX_FD="$MAX_FD_LIMIT"
+ fi
+ ulimit -n $MAX_FD
+ if [ $? -ne 0 ] ; then
+ warn "Could not set maximum file descriptor limit: $MAX_FD"
+ fi
+ else
+ warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
+ fi
+fi
+
+# For Darwin, add options to specify how the application appears in the dock
+if $darwin; then
+ GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
+fi
+
+# For Cygwin, switch paths to Windows format before running java
+if $cygwin ; then
+ APP_HOME=`cygpath --path --mixed "$APP_HOME"`
+ CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
+ JAVACMD=`cygpath --unix "$JAVACMD"`
+
+ # We build the pattern for arguments to be converted via cygpath
+ ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
+ SEP=""
+ for dir in $ROOTDIRSRAW ; do
+ ROOTDIRS="$ROOTDIRS$SEP$dir"
+ SEP="|"
+ done
+ OURCYGPATTERN="(^($ROOTDIRS))"
+ # Add a user-defined pattern to the cygpath arguments
+ if [ "$GRADLE_CYGPATTERN" != "" ] ; then
+ OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
+ fi
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ i=0
+ for arg in "$@" ; do
+ CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
+ CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
+
+ if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
+ eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
+ else
+ eval `echo args$i`="\"$arg\""
+ fi
+ i=$((i+1))
+ done
+ case $i in
+ (0) set -- ;;
+ (1) set -- "$args0" ;;
+ (2) set -- "$args0" "$args1" ;;
+ (3) set -- "$args0" "$args1" "$args2" ;;
+ (4) set -- "$args0" "$args1" "$args2" "$args3" ;;
+ (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
+ (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
+ (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
+ (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
+ (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
+ esac
+fi
+
+# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules
+function splitJvmOpts() {
+ JVM_OPTS=("$@")
+}
+eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS
+JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME"
+
+exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 0000000..8a0b282
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,90 @@
+@if "%DEBUG%" == "" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS=
+
+set DIRNAME=%~dp0
+if "%DIRNAME%" == "" set DIRNAME=.
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if "%ERRORLEVEL%" == "0" goto init
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto init
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:init
+@rem Get command-line arguments, handling Windowz variants
+
+if not "%OS%" == "Windows_NT" goto win9xME_args
+if "%@eval[2+2]" == "4" goto 4NT_args
+
+:win9xME_args
+@rem Slurp the command line arguments.
+set CMD_LINE_ARGS=
+set _SKIP=2
+
+:win9xME_args_slurp
+if "x%~1" == "x" goto execute
+
+set CMD_LINE_ARGS=%*
+goto execute
+
+:4NT_args
+@rem Get arguments from the 4NT Shell from JP Software
+set CMD_LINE_ARGS=%$
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
+
+:end
+@rem End local scope for the variables with windows NT shell
+if "%ERRORLEVEL%"=="0" goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
+exit /b 1
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/local.properties b/local.properties
new file mode 100644
index 0000000..431e326
--- /dev/null
+++ b/local.properties
@@ -0,0 +1,8 @@
+## This file must *NOT* be checked into Version Control Systems,
+# as it contains information specific to your local configuration.
+#
+# Location of the SDK. This is only used by Gradle.
+# For customization when using a Version Control System, please read the
+# header note.
+#Wed Feb 14 02:34:32 ICT 2024
+sdk.dir=/home/cuynu/Android/Sdk
diff --git a/settings.gradle b/settings.gradle
new file mode 100644
index 0000000..2b0e160
--- /dev/null
+++ b/settings.gradle
@@ -0,0 +1,17 @@
+pluginManagement {
+ repositories {
+ gradlePluginPortal()
+ google()
+ mavenCentral()
+ }
+}
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ jcenter()
+ }
+}
+rootProject.name = "V2rayNG"
+include ':app'