diff --git a/app/build.gradle b/app/build.gradle index 363bfea..8dd5919 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -22,6 +22,10 @@ android { proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' } } + applicationVariants.all { variant -> + variant.resValue "string", "coreName", "4.17.0" + variant.resValue "string", "versionName", variant.versionName + } productFlavors { } } @@ -36,6 +40,7 @@ dependencies { implementation 'com.google.android.material:material:1.1.0-alpha02' implementation "androidx.paging:paging-runtime:2.1.0-rc01" implementation 'com.beust:klaxon:3.0.1' + implementation(name: 'tun2socks', ext: 'aar') implementation 'androidx.room:room-runtime:2.1.0-alpha03' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6eb3a65..8e2a19f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -16,7 +16,8 @@ + android:theme="@style/AppTheme.NoActionBar" + android:windowSoftInputMode="stateHidden"> @@ -59,6 +60,12 @@ android:parentActivityName=".ui.perapp.PerAppActivity"> + + + diff --git a/app/src/main/java/fun/kitsunebi/kitsunebi4android/common/contants.kt b/app/src/main/java/fun/kitsunebi/kitsunebi4android/common/contants.kt new file mode 100644 index 0000000..134b709 --- /dev/null +++ b/app/src/main/java/fun/kitsunebi/kitsunebi4android/common/contants.kt @@ -0,0 +1,200 @@ +package `fun`.kitsunebi.kitsunebi4android.common + +class Constants { + companion object { + val PREFERENCE_CONFIG_KEY = "preference_config_key" + val SUBSCRIBE_CONFIG_URL_KEY = "subscribe_config_url_key" + val DEFAULT_CONFIG = """ + { + "outbounds": [ + { + "protocol": "vmess", + "settings": { + "vnext": [ + { + "address": "1.2.3.4", + "port": 10086, + "users": [ + { + "id": "0e8575fb-a71f-455b-877f-b74e19d3f495" + } + ] + } + ] + }, + "streamSettings": { + "network": "tcp" + }, + "tag": "proxy" + }, + { + "protocol": "freedom", + "settings": { + "domainStrategy": "UseIP" + }, + "streamSettings": {}, + "tag": "direct" + }, + { + "protocol": "blackhole", + "settings": {}, + "tag": "block" + }, + { + "protocol": "dns", + "tag": "dns-out" + } + ], + "dns": { + "clientIp": "115.239.211.92", + "hosts": { + "localhost": "127.0.0.1" + }, + "servers": [ + "114.114.114.114", + { + "address": "8.8.8.8", + "domains": [ + "google", + "android", + "fbcdn", + "facebook", + "domain:fb.com", + "instagram", + "whatsapp", + "akamai", + "domain:line-scdn.net", + "domain:line.me", + "domain:naver.jp" + ], + "port": 53 + } + ] + }, + "log": { + "loglevel": "warning" + }, + "policy": { + "levels": { + "0": { + "bufferSize": 4096, + "connIdle": 30, + "downlinkOnly": 0, + "handshake": 4, + "uplinkOnly": 0 + } + } + }, + "routing": { + "domainStrategy": "IPIfNonMatch", + "rules": [ + { + "inboundTag": ["tun2socks"], + "network": "udp", + "port": 53, + "outboundTag": "dns-out", + "type": "field" + }, + { + "domain": [ + "domain:setup.icloud.com" + ], + "outboundTag": "proxy", + "type": "field" + }, + { + "ip": [ + "8.8.8.8/32", + "8.8.4.4/32", + "1.1.1.1/32", + "1.0.0.1/32", + "9.9.9.9/32", + "149.112.112.112/32", + "208.67.222.222/32", + "208.67.220.220/32" + ], + "outboundTag": "proxy", + "type": "field" + }, + { + "ip": [ + "geoip:cn", + "geoip:private" + ], + "outboundTag": "direct", + "type": "field" + }, + { + "outboundTag": "direct", + "port": "123", + "type": "field" + }, + { + "domain": [ + "domain:pstatp.com", + "domain:snssdk.com", + "domain:toutiao.com", + "domain:ixigua.com", + "domain:apple.com", + "domain:crashlytics.com", + "domain:icloud.com", + "cctv", + "umeng", + "domain:weico.cc", + "domain:jd.com", + "domain:360buy.com", + "domain:360buyimg.com", + "domain:douyu.tv", + "domain:douyu.com", + "domain:douyucdn.cn", + "geosite:cn" + ], + "outboundTag": "direct", + "type": "field" + }, + { + "ip": [ + "149.154.167.0/24", + "149.154.175.0/24", + "91.108.56.0/24", + "125.209.222.0/24" + ], + "outboundTag": "proxy", + "type": "field" + }, + { + "domain": [ + "twitter", + "domain:twimg.com", + "domain:t.co", + "google", + "domain:ggpht.com", + "domain:gstatic.com", + "domain:youtube.com", + "domain:ytimg.com", + "pixiv", + "domain:pximg.net", + "tumblr", + "instagram", + "domain:line-scdn.net", + "domain:line.me", + "domain:naver.jp", + "domain:facebook.com", + "domain:fbcdn.net", + "pinterest", + "github", + "dropbox", + "netflix", + "domain:medium.com", + "domain:fivecdm.com" + ], + "outboundTag": "proxy", + "type": "field" + } + ], + "strategy": "rules" + } +} + """.trimIndent() + } +} \ No newline at end of file diff --git a/app/src/main/java/fun/kitsunebi/kitsunebi4android/common/utils.kt b/app/src/main/java/fun/kitsunebi/kitsunebi4android/common/utils.kt index 6748e75..aae9f7d 100644 --- a/app/src/main/java/fun/kitsunebi/kitsunebi4android/common/utils.kt +++ b/app/src/main/java/fun/kitsunebi/kitsunebi4android/common/utils.kt @@ -1,5 +1,8 @@ package `fun`.kitsunebi.kitsunebi4android.common +import android.app.AlertDialog +import android.content.Context + open class SingletonHolder(creator: (A) -> T) { private var creator: ((A) -> T)? = creator @Volatile private var instance: T? = null @@ -30,4 +33,11 @@ public fun humanReadableByteCount(bytes: Long, si: Boolean): String { val exp = (Math.log(bytes.toDouble()) / Math.log(unit.toDouble())).toInt() val pre = (if (si) "kMGTPE" else "KMGTPE")[exp - 1] + if (si) "" else "i" return String.format("%.1f %sB", bytes / Math.pow(unit.toDouble(), exp.toDouble()), pre) +} + +public fun showAlert(context: Context, msg: String) { + val dialog = AlertDialog.Builder(context).setTitle("Message").setMessage(msg) + .setPositiveButton("Ok", { dialog, i -> + }) + dialog.show() } \ No newline at end of file diff --git a/app/src/main/java/fun/kitsunebi/kitsunebi4android/service/SimpleVpnService.kt b/app/src/main/java/fun/kitsunebi/kitsunebi4android/service/SimpleVpnService.kt index 136f4e2..c673dc1 100644 --- a/app/src/main/java/fun/kitsunebi/kitsunebi4android/service/SimpleVpnService.kt +++ b/app/src/main/java/fun/kitsunebi/kitsunebi4android/service/SimpleVpnService.kt @@ -1,6 +1,7 @@ package `fun`.kitsunebi.kitsunebi4android.service import `fun`.kitsunebi.kitsunebi4android.R +import `fun`.kitsunebi.kitsunebi4android.common.Constants import `fun`.kitsunebi.kitsunebi4android.storage.PROXY_LOG_DB_NAME import `fun`.kitsunebi.kitsunebi4android.storage.Preferences import `fun`.kitsunebi.kitsunebi4android.storage.ProxyLog @@ -160,14 +161,19 @@ open class SimpleVpnService : VpnService() { } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - configString = Preferences.getString(applicationContext, getString(R.string.preference_config_key), getString(R.string.default_config)) + configString = Preferences.getString(applicationContext, Constants.PREFERENCE_CONFIG_KEY, Constants.DEFAULT_CONFIG) bgThread = thread(start = true) { - val config = Klaxon().parse(configString) + val config = try { Klaxon().parse(configString) } catch (e: Exception) { + sendBroadcast(android.content.Intent("vpn_start_err_config")) + stopVPN() + return@thread + } if (config != null) { if (config.dns == null || config.dns.servers == null || config.dns.servers.size == 0) { println("must configure dns servers since v2ray will use localhost if there isn't any dns servers") sendBroadcast(Intent("vpn_start_err_dns")) + stopVPN() return@thread } @@ -176,12 +182,14 @@ open class SimpleVpnService : VpnService() { if (dnsServer != null && dnsServer == "localhost") { println("using local dns resolver is not allowed since it will cause infinite loop") sendBroadcast(Intent("vpn_start_err_dns")) + stopVPN() return@thread } } } else { println("parsing v2ray config failed") sendBroadcast(Intent("vpn_start_err")) + stopVPN() return@thread } @@ -191,7 +199,6 @@ open class SimpleVpnService : VpnService() { .setMtu(1500) .addAddress("10.233.233.233", 30) .addDnsServer(localDns) - .addSearchDomain("local") .addRoute("0.0.0.0", 0) val isEnablePerAppVpn = Preferences.getBool(applicationContext, getString(R.string.is_enable_per_app_vpn), null) @@ -225,6 +232,7 @@ open class SimpleVpnService : VpnService() { if ((pfd == null) || !Tun2socks.setNonblock(pfd!!.fd.toLong(), false)) { println("failed to put tunFd in blocking mode") sendBroadcast(Intent("vpn_start_err")) + stopVPN() return@thread } @@ -244,23 +252,49 @@ open class SimpleVpnService : VpnService() { } val files = filesDir.list() - if (!files.contains("geoip.dat") || !files.contains("geosite.dat")) { - val geoipBytes = resources.openRawResource(R.raw.geoip).readBytes() - val fos = openFileOutput("geoip.dat", Context.MODE_PRIVATE) - fos.write(geoipBytes) - fos.close() - - val geositeBytes = resources.openRawResource(R.raw.geosite).readBytes() - val fos2 = openFileOutput("geosite.dat", Context.MODE_PRIVATE) - fos2.write(geositeBytes) - fos2.close() - } + // FIXME copy only when update + val geoipBytes = resources.openRawResource(R.raw.geoip).readBytes() + val fos = openFileOutput("geoip.dat", Context.MODE_PRIVATE) + fos.write(geoipBytes) + fos.close() + + val geositeBytes = resources.openRawResource(R.raw.geosite).readBytes() + val fos2 = openFileOutput("geosite.dat", Context.MODE_PRIVATE) + fos2.write(geositeBytes) + fos2.close() + +// if (!files.contains("geoip.dat") || !files.contains("geosite.dat")) { +// val geoipBytes = resources.openRawResource(R.raw.geoip).readBytes() +// val fos = openFileOutput("geoip.dat", Context.MODE_PRIVATE) +// fos.write(geoipBytes) +// fos.close() +// +// val geositeBytes = resources.openRawResource(R.raw.geosite).readBytes() +// val fos2 = openFileOutput("geosite.dat", Context.MODE_PRIVATE) +// fos2.write(geositeBytes) +// fos2.close() +// } ProxyLogDatabase.getInstance(applicationContext).proxyLogDao().getAllCount() - val dbPath = getDatabasePath(PROXY_LOG_DB_NAME).absolutePath + + var sniffing = Preferences.getString(applicationContext, getString(R.string.sniffing), "http,tls") + // Just ensure no whitespaces in the the string. + val sniffingList = sniffing.split(",") + var sniffings = ArrayList() + for (s in sniffingList) { + sniffings.add(s.trim()) + } + sniffing = sniffings.joinToString(",") + + val inboundTag = Preferences.getString(applicationContext, getString(R.string.inbound_tag), "tun2socks") Tun2socks.setLocalDNS("$localDns:53") - Tun2socks.startV2Ray(flow, service, dbService, configString.toByteArray(), filesDir.absolutePath, dbPath) + val ret = Tun2socks.startV2Ray(flow, service, dbService, configString.toByteArray(), inboundTag, sniffing, filesDir.absolutePath) + if (ret.toInt() != 0) { + sendBroadcast(Intent("vpn_start_err_config")) + stopVPN() + return@thread + } sendBroadcast(Intent("vpn_started")) Preferences.putBool(applicationContext, getString(R.string.vpn_is_running), true) diff --git a/app/src/main/java/fun/kitsunebi/kitsunebi4android/storage/ProxyLog.kt b/app/src/main/java/fun/kitsunebi/kitsunebi4android/storage/ProxyLog.kt index 3329ab8..27212d5 100644 --- a/app/src/main/java/fun/kitsunebi/kitsunebi4android/storage/ProxyLog.kt +++ b/app/src/main/java/fun/kitsunebi/kitsunebi4android/storage/ProxyLog.kt @@ -31,7 +31,7 @@ interface ProxyLogDao { @Query("SELECT * FROM proxy_log ORDER BY end_time DESC") fun getAllPaged(): DataSource.Factory - @Query("SELECT * FROM proxy_log WHERE record_type != 1 ORDER BY end_time DESC") + @Query("SELECT * FROM proxy_log WHERE record_type != 1 AND target NOT LIKE 'udp:%:53' ORDER BY end_time DESC") fun getAllNonDnsPaged(): DataSource.Factory @Insert diff --git a/app/src/main/java/fun/kitsunebi/kitsunebi4android/ui/LogcatActivity.kt b/app/src/main/java/fun/kitsunebi/kitsunebi4android/ui/LogcatActivity.kt index b63c7c4..e47be7a 100644 --- a/app/src/main/java/fun/kitsunebi/kitsunebi4android/ui/LogcatActivity.kt +++ b/app/src/main/java/fun/kitsunebi/kitsunebi4android/ui/LogcatActivity.kt @@ -10,6 +10,7 @@ import android.view.Menu import android.view.MenuItem import android.widget.TextView import androidx.appcompat.app.AppCompatActivity +import kotlinx.android.synthetic.main.activity_logcat.* import java.io.BufferedReader import java.io.IOException import java.io.InputStreamReader @@ -29,7 +30,7 @@ class LogcatActivity : AppCompatActivity() { super.onCreate(savedInstanceState) setContentView(R.layout.activity_logcat) logcatTextView = findViewById(R.id.logcat_text) - logcatTextView.movementMethod = ScrollingMovementMethod() + logcatScroll.isSmoothScrollingEnabled = true bgThread = object : Thread() { override fun run() { diff --git a/app/src/main/java/fun/kitsunebi/kitsunebi4android/ui/MainActivity.kt b/app/src/main/java/fun/kitsunebi/kitsunebi4android/ui/MainActivity.kt index 3ff8e64..d7cd5f2 100644 --- a/app/src/main/java/fun/kitsunebi/kitsunebi4android/ui/MainActivity.kt +++ b/app/src/main/java/fun/kitsunebi/kitsunebi4android/ui/MainActivity.kt @@ -1,13 +1,13 @@ package `fun`.kitsunebi.kitsunebi4android.ui import `fun`.kitsunebi.kitsunebi4android.R +import `fun`.kitsunebi.kitsunebi4android.common.Constants +import `fun`.kitsunebi.kitsunebi4android.common.showAlert import `fun`.kitsunebi.kitsunebi4android.service.SimpleVpnService import `fun`.kitsunebi.kitsunebi4android.storage.Preferences -import `fun`.kitsunebi.kitsunebi4android.ui.perapp.PerAppActivity import `fun`.kitsunebi.kitsunebi4android.ui.proxylog.ProxyLogActivity import `fun`.kitsunebi.kitsunebi4android.ui.settings.SettingsActivity import android.app.Activity -import android.app.AlertDialog import android.content.BroadcastReceiver import android.content.Context import android.content.Intent @@ -23,6 +23,11 @@ import kotlinx.android.synthetic.main.activity_main.* import kotlinx.android.synthetic.main.content_main.* import org.json.JSONException import org.json.JSONObject +import android.view.MotionEvent +import android.view.View.OnTouchListener +import android.view.View + + class MainActivity : AppCompatActivity() { @@ -30,11 +35,13 @@ class MainActivity : AppCompatActivity() { var running = false private var starting = false private var stopping = false + private lateinit var configString: String + // val mNotificationId = 1 // var mNotificationManager: NotificationManager? = null val broadcastReceiver = object : BroadcastReceiver() { - override fun onReceive(contxt: Context?, intent: Intent?) { + override fun onReceive(context: Context?, intent: Intent?) { when (intent?.action) { "vpn_stopped" -> { running = false @@ -52,13 +59,25 @@ class MainActivity : AppCompatActivity() { running = false starting = false fab.setImageResource(android.R.drawable.ic_media_play) - showAlert("Start VPN service failed") + context?.let { + showAlert(it, "Start VPN service failed") + } } "vpn_start_err_dns" -> { running = false starting = false fab.setImageResource(android.R.drawable.ic_media_play) - showAlert("Start VPN service failed: Not configuring DNS right, must has at least 1 dns server and mustn't include \"localhost\"") + context?.let { + showAlert(it, "Start VPN service failed: Not configuring DNS right, must has at least 1 dns server and mustn't include \"localhost\"") + } + } + "vpn_start_err_config" -> { + running = false + starting = false + fab.setImageResource(android.R.drawable.ic_media_play) + context?.let { + showAlert(it, "Start VPN service failed: Invalid V2Ray config.") + } } "pong" -> { fab.setImageResource(android.R.drawable.ic_media_pause) @@ -69,6 +88,15 @@ class MainActivity : AppCompatActivity() { } } + private fun updateUI() { + configString = Preferences.getString(applicationContext, Constants.PREFERENCE_CONFIG_KEY, Constants.DEFAULT_CONFIG) + configString?.let { + formatJsonString(it).let { + configView.setText(it, TextView.BufferType.EDITABLE) + } + } + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) @@ -78,25 +106,26 @@ class MainActivity : AppCompatActivity() { registerReceiver(broadcastReceiver, IntentFilter("vpn_stopped")) registerReceiver(broadcastReceiver, IntentFilter("vpn_started")) + + // TODO make a list registerReceiver(broadcastReceiver, IntentFilter("vpn_start_err")) registerReceiver(broadcastReceiver, IntentFilter("vpn_start_err_dns")) + registerReceiver(broadcastReceiver, IntentFilter("vpn_start_err_config")) + registerReceiver(broadcastReceiver, IntentFilter("pong")) sendBroadcast(Intent("ping")) - var configString = Preferences.getString(applicationContext, getString(R.string.preference_config_key), getString(R.string.default_config)) - configString?.let { - formatJsonString(it).let { - configView.setText(it, TextView.BufferType.EDITABLE) - } - } + updateUI() + + configScroll.isSmoothScrollingEnabled = true fab.setOnClickListener { view -> if (!running && !starting) { starting = true fab.setImageResource(android.R.drawable.ic_media_ff) configString = configView.text.toString() - Preferences.putString(applicationContext, getString(R.string.preference_config_key), configString) + Preferences.putString(applicationContext, Constants.Companion.PREFERENCE_CONFIG_KEY, configString) val intent = VpnService.prepare(this) if (intent != null) { startActivityForResult(intent, 1) @@ -119,6 +148,7 @@ class MainActivity : AppCompatActivity() { override fun onResume() { super.onResume() + updateUI() sendBroadcast(Intent("ping")) } @@ -133,6 +163,11 @@ class MainActivity : AppCompatActivity() { // automatically handle clicks on the Home/Up button, so long // as you specify a parent activity in AndroidManifest.xml. return when (item.itemId) { + R.id.subscribe_config_btn -> { + val intent = Intent(this, SubscribeConfigActivity::class.java) + startActivity(intent) + return true + } R.id.format_btn -> { val prettyText = formatJsonString(configView.text.toString()) prettyText?.let { @@ -144,10 +179,10 @@ class MainActivity : AppCompatActivity() { val config = configView.text.toString() val prettyText = formatJsonString(config) if (prettyText == null) { - showAlert("Invalid JSON") + showAlert(this, "Invalid JSON") return true } - Preferences.putString(applicationContext, getString(R.string.preference_config_key), prettyText) + Preferences.putString(applicationContext, Constants.PREFERENCE_CONFIG_KEY, prettyText) return true } R.id.log_btn -> { @@ -183,18 +218,11 @@ class MainActivity : AppCompatActivity() { return try { JSONObject(json).toString(2) } catch (e: JSONException) { - showAlert("Invalid JSON") + showAlert(this, "Invalid JSON") return null } } - fun showAlert(msg: String) { - val dialog = AlertDialog.Builder(this).setTitle("Message").setMessage(msg) - .setPositiveButton("Ok", { dialog, i -> - }) - dialog.show() - } - // private fun startNotification() { // // Build Notification , setOngoing keeps the notification always in status bar // val mBuilder = NotificationCompat.Builder(this, getString(R.string.notification_channel_id)) diff --git a/app/src/main/java/fun/kitsunebi/kitsunebi4android/ui/SubscribeConfigActivity.kt b/app/src/main/java/fun/kitsunebi/kitsunebi4android/ui/SubscribeConfigActivity.kt new file mode 100644 index 0000000..3dd903e --- /dev/null +++ b/app/src/main/java/fun/kitsunebi/kitsunebi4android/ui/SubscribeConfigActivity.kt @@ -0,0 +1,67 @@ +package `fun`.kitsunebi.kitsunebi4android.ui + +import `fun`.kitsunebi.kitsunebi4android.R +import `fun`.kitsunebi.kitsunebi4android.common.Constants +import `fun`.kitsunebi.kitsunebi4android.common.showAlert +import `fun`.kitsunebi.kitsunebi4android.storage.Preferences +import android.content.Context +import android.os.AsyncTask +import android.os.Bundle +import android.widget.Button +import android.widget.EditText +import androidx.appcompat.app.AppCompatActivity +import java.io.BufferedReader +import java.io.InputStreamReader +import java.net.URL +import javax.net.ssl.HttpsURLConnection + +class SubscribeConfigActivity : AppCompatActivity() { + + private lateinit var subUrl: EditText + private lateinit var subBtn: Button + + class RetrieveConfigurationTask internal constructor(context: Context): AsyncTask() { + private var ctx: Context = context + + override fun doInBackground(vararg p0: String?): String? { + if (p0[0] != null) { + val obj = URL(p0[0]) + with(obj.openConnection() as HttpsURLConnection) { + BufferedReader(InputStreamReader(inputStream)).use { + val response = StringBuffer() + var inputLine = it.readLine() + while (inputLine != null) { + response.append(inputLine) + inputLine = it.readLine() + } + it.close() + return response.toString() + } + } + } + return null + } + + override fun onPostExecute(result: String?) { + if (result != null) { + Preferences.putString(ctx, Constants.PREFERENCE_CONFIG_KEY, result) + showAlert(ctx, "Configuration updated!") + } + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_subscribe_config) + subUrl = findViewById(R.id.sub_url) + subBtn = findViewById