main activity

This commit is contained in:
Kr328 committed 2019-11-21 23:40:35 +08:00
1 parent 565415d8d1
commit 7d04980e30
18 files changed
+276 -139

No files matched your search

+15 -4
View File
@@ -19,6 +19,13 @@ android {
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility = 1.8
targetCompatibility = 1.8
}
kotlinOptions {
jvmTarget = "1.8"
}
}
dependencies {
@@ -26,12 +33,16 @@ dependencies {
implementation project(":core")
implementation project(":service")
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation "androidx.appcompat:appcompat:1.1.0"
implementation "androidx.core:core-ktx:1.1.0"
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
implementation "androidx.lifecycle:lifecycle-livedata:2.1.0"
implementation "androidx.lifecycle:lifecycle-common-java8:2.1.0"
implementation "androidx.constraintlayout:constraintlayout:1.1.3"
implementation 'androidx.browser:browser:1.0.0'
implementation 'androidx.preference:preference:1.1.0'
implementation 'androidx.recyclerview:recyclerview:1.1.0-rc01'
implementation 'androidx.core:core-ktx:1.2.0-beta02'
implementation 'androidx.fragment:fragment-ktx:1.2.0-rc02'
implementation 'androidx.appcompat:appcompat:1.1.0'
implementation 'androidx.constraintlayout:constraintlayout:2.0.0-beta3'
implementation "androidx.room:room-runtime:$room_version"
implementation "com.google.android.material:material:1.2.0-alpha02"
}
@@ -1,10 +1,56 @@
package com.github.kr328.clash
import android.app.Activity
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.net.VpnService
import android.os.Bundle
import android.os.Handler
import android.os.IBinder
import android.view.View
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.observe
import com.github.kr328.clash.core.ClashProcessStatus
import com.github.kr328.clash.service.ClashService
import com.github.kr328.clash.service.IClashObserver
import com.github.kr328.clash.service.IClashService
import com.github.kr328.clash.service.TunService
import com.github.kr328.clash.service.data.ClashDatabase
import kotlinx.android.synthetic.main.activity_main.*
import kotlinx.android.synthetic.main.activity_main_clash_status.*
import kotlinx.android.synthetic.main.activity_main_profiles.*
import kotlinx.android.synthetic.main.activity_main_proxy_manage.*
class MainActivity : AppCompatActivity() {
companion object {
const val VPN_REQUEST_CODE = 233
}
private val handler = Handler()
private var clash: IClashService? = null
private val connection = object: ServiceConnection {
override fun onServiceDisconnected(name: ComponentName?) {
finish()
}
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
clash = IClashService.Stub.asInterface(service)
clash?.apply {
registerObserver("main_activity",true , object: IClashObserver.Stub() {
override fun onStatusChanged(status: ClashProcessStatus?) {
clashStatus.postValue(status ?: ClashProcessStatus(ClashProcessStatus.STATUS_STOPPED_INT))
}
})
}
}
}
private val clashStatus = MutableLiveData<ClashProcessStatus>(ClashProcessStatus(ClashProcessStatus.STATUS_STOPPED_INT))
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
@@ -12,5 +58,74 @@ class MainActivity : AppCompatActivity() {
setSupportActionBar(activity_main_toolbar.apply {
setLogo(R.mipmap.ic_launcher_foreground)
})
clashStatus.observe(this) {
handler.removeMessages(0)
handler.postDelayed({
when ( it.status ) {
ClashProcessStatus.STATUS_STARTED_INT -> {
activity_main_clash_status_icon.setImageResource(R.drawable.ic_clash_started)
activity_main_clash_status_title.text = getString(R.string.clash_status_started)
activity_main_clash_status_summary.text = getString(R.string.clash_status_forwarded_traffic, "1.4GiB")
activity_main_clash_proxies.visibility = View.VISIBLE
activity_main_clash_logs.visibility = View.VISIBLE
}
else -> {
activity_main_clash_status_icon.setImageResource(R.drawable.ic_clash_stopped)
activity_main_clash_status_title.text = getString(R.string.clash_status_stopped)
activity_main_clash_status_summary.text = getString(R.string.clash_status_click_to_start)
activity_main_clash_proxies.visibility = View.GONE
activity_main_clash_logs.visibility = View.GONE
}
}
}, 300)
}
activity_main_clash_status.setOnClickListener {
when ( clashStatus.value?.status ?: ClashProcessStatus.STATUS_STOPPED_INT ) {
ClashProcessStatus.STATUS_STARTED_INT -> {}
ClashProcessStatus.STATUS_STOPPED_INT -> {
VpnService.prepare(this)?.apply {
startActivityForResult(this, VPN_REQUEST_CODE)
} ?: startService(Intent(this, TunService::class.java))
}
}
}
ClashDatabase.getInstance(this)
.openClashProfileDao()
.observeDefaultProfileUrl()
.observe(this) {
activity_main_clash_profiles_summary.text =
if ( it == null )
getString(R.string.clash_profiles_summary_unselected)
else
getString(R.string.clash_profiles_summary_selected, it)
}
}
override fun onStart() {
super.onStart()
bindService(Intent(this, ClashService::class.java),
connection,
Context.BIND_AUTO_CREATE)
}
override fun onStop() {
super.onStop()
unbindService(connection)
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
when ( requestCode ) {
VPN_REQUEST_CODE -> {
if ( resultCode == Activity.RESULT_OK )
startService(Intent(this, TunService::class.java))
}
}
super.onActivityResult(requestCode, resultCode, data)
}
}
@@ -16,8 +16,8 @@
<RelativeLayout
android:paddingStart="18dp"
android:paddingEnd="18dp"
android:paddingTop="24dp"
android:paddingBottom="24dp"
android:paddingTop="20dp"
android:paddingBottom="20dp"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<ImageView
@@ -42,7 +42,7 @@
android:text="@string/clash_status_click_to_start"
android:textColor="@color/light_gray"
android:textAppearance="@style/TextAppearance.AppCompat.Small"
android:layout_marginTop="2dp"
android:layout_marginTop="5dp"
android:layout_alignStart="@id/activity_main_clash_status_title"
android:layout_below="@id/activity_main_clash_status_title"
android:layout_width="wrap_content"
@@ -40,7 +40,7 @@
<TextView
android:id="@+id/activity_main_clash_profiles_summary"
android:text="@string/clash_profiles_summary"
android:text="@string/clash_profiles_summary_unselected"
android:textColor="@android:color/darker_gray"
android:textAppearance="@style/TextAppearance.AppCompat.Small"
android:layout_marginTop="2dp"
@@ -2,7 +2,7 @@
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/activity_main_clash_proxies"
android:layout_marginTop="18dp"
android:layout_marginTop="10dp"
android:layout_marginStart="30dp"
android:layout_marginEnd="30dp"
android:layout_width="match_parent"
+3 -2
View File
@@ -3,14 +3,15 @@
<string name="clash_status_stopped">Clash Stopped</string>
<string name="clash_status_click_to_start">Click to start</string>
<string name="clash_status_running">Clash Started</string>
<string name="clash_status_started">Clash Started</string>
<string name="clash_status_forwarded_traffic">%s Traffic Forwarded</string>
<string name="clash_proxy_manage">Proxy Manage</string>
<string name="clash_proxy_manage_summary">%d Proxies</string>
<string name="clash_profiles">Profiles</string>
<string name="clash_profiles_summary">%s Selected</string>
<string name="clash_profiles_summary_selected">%s Selected</string>
<string name="clash_profiles_summary_unselected">Unselected</string>
<string name="clash_logs">Logs</string>
<string name="clash_settings">Settings</string>
+7 -2
View File
@@ -28,11 +28,15 @@ android {
jniLibs.srcDirs += ["$buildDir/intermediates/native_output"]
}
}
compileOptions {
sourceCompatibility = 1.8
targetCompatibility = 1.8
}
}
dependencies {
implementation "androidx.core:core-ktx:1.1.0"
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
}
afterEvaluate {
@@ -52,7 +56,7 @@ afterEvaluate {
def ds = tasks.register("downloadMMDB", MMDBDowloadTask.class) {
onlyIf {
System.currentTimeMillis() - file("$buildDir/intermediates/cache/Country.tar.gz").lastModified() > 30 * 24 * 3600L
System.currentTimeMillis() - file("$buildDir/intermediates/cache/Country.tar.gz").lastModified() > 24 * 3600L
}
output = "$buildDir/intermediates/cache/Country.tar.gz"
@@ -69,6 +73,7 @@ afterEvaluate {
into "$buildDir/intermediates/cache/mmdb"
doLast {
file("$buildDir/intermediates/dynamic_assets").mkdirs()
fileTree("$buildDir/intermediates/cache/mmdb").visit { FileVisitDetails details ->
if ( details.path.endsWith("Country.mmdb") )
details.file.renameTo(file("$buildDir/intermediates/dynamic_assets/Country.mmdb"))
+1
View File
@@ -29,6 +29,7 @@ func main() {
return
}
fmt.Println("[PID]", os.Getpid())
fmt.Println("[CONTROLLER] STARTED")
profile.LoadDefault()
+1 -1
View File
@@ -87,7 +87,7 @@ func newTunProxy(fd int, mtu int) (*tun, error) {
// IPv6 [::]/0
subnet, _ = tcpip.NewSubnet(tcpip.Address(strings.Repeat("\x00", 16)), tcpip.AddressMask(strings.Repeat("\x00", 16)))
ipstack.AddAddressRange(1, ipv4.ProtocolNumber, subnet)
ipstack.AddAddressRange(1, ipv6.ProtocolNumber, subnet)
// TCP handler
tcpFwd := tcp.NewForwarder(ipstack, 0, 16, func(r *tcp.ForwarderRequest) {
@@ -5,6 +5,7 @@ import android.util.Log
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.lang.Exception
import kotlin.concurrent.thread
abstract class ClashProcess(private val context: Context,
@@ -14,11 +15,9 @@ abstract class ClashProcess(private val context: Context,
companion object {
const val TAG = "ClashForAndroid"
private const val CLASH_COMMAND = "\"{CLASH}\" \"{CONTROLLER}\" 2>&1"
private const val CONTROLLER_STATUS_PREFIX = "[CONTROLLER]"
private val PID_PATTERN = Regex("PID=(\\d+)")
private val PID_PATTERN = Regex("\\[PID]\\s*(\\d+)")
private val CONTROLLER_ERROR_PATTERN = Regex("\\[CONTROLLER] ERROR=\\{(.+)\\}")
}
@@ -30,71 +29,72 @@ abstract class ClashProcess(private val context: Context,
if ( pid > 0 )
return
extractMMDB()
try {
clashDir.mkdirs()
controllerPath.parentFile?.mkdirs()
controllerPath.delete()
clashDir.mkdirs()
controllerPath.parentFile?.mkdirs()
controllerPath.delete()
extractMMDB()
val p = ProcessBuilder().apply {
command("sh")
directory(clashDir)
}.start()
val clashPath = File(context.applicationInfo.nativeLibraryDir,"libclash.so").absolutePath
val clashPath = File(context.applicationInfo.nativeLibraryDir,"libclash.so").absolutePath
val command = CLASH_COMMAND
.replace("{CLASH}", clashPath)
.replace("{CONTROLLER}", controllerPath.absolutePath)
val p = ProcessBuilder().apply {
command(clashPath, controllerPath.absolutePath)
directory(clashDir)
}.start()
Log.i(TAG, "Starting clash [$command]")
Log.i(TAG, "Starting clash [$clashPath]")
p.outputStream.use {
it.write("echo PID=$$\n".toByteArray())
it.write("exec $command\n".toByteArray())
it.write("exit\n".toByteArray())
it.flush()
}
val reader = p.inputStream.bufferedReader()
var line = ""
var line = ""
// Parse pid
var currentPid = 0
while ( reader.readLine()?.apply { line = this.trim() } != null ) {
Log.d(TAG, line)
// Parse pid
var currentPid = 0
while ( p.inputStream.bufferedReader().readLine()?.apply { line = this.trim() } != null ) {
if ( PID_PATTERN.matchEntire(line)?.apply { currentPid = groups[1]!!.value.toInt() } != null )
break
}
while ( p.inputStream.bufferedReader().readLine()?.apply { line = this.trim() } != null ) {
if ( line.startsWith(CONTROLLER_STATUS_PREFIX) ) {
val error = CONTROLLER_ERROR_PATTERN.matchEntire(line)?.groups?.get(1)?.value
if ( error != null )
throw IOException("Controller: $error")
break
}
}
process = p
pid = currentPid
listener(ClashProcessStatus(ClashProcessStatus.STATUS_STARTED))
Log.i(TAG, "Clash started pid = $pid")
thread {
// Redirect stdout to log
while ( p.inputStream.bufferedReader().readLine()?.apply { line = this } != null ) {
Log.i(TAG, line.trim())
if ( PID_PATTERN.matchEntire(line)?.apply { currentPid = groups[1]!!.value.toInt() } != null )
break
}
synchronized(this@ClashProcess) {
p.destroy()
process = null
pid = -1
while ( reader.readLine()?.apply { line = this.trim() } != null ) {
Log.d(TAG, line)
if ( line.startsWith(CONTROLLER_STATUS_PREFIX) ) {
val error = CONTROLLER_ERROR_PATTERN.matchEntire(line)?.groups?.get(1)?.value
if ( error != null )
throw IOException("Controller: $error")
break
}
}
listener(ClashProcessStatus(ClashProcessStatus.STATUS_STOPPED))
process = p
pid = currentPid
listener(ClashProcessStatus.STATUS_STARTED)
Log.i(TAG, "Clash started pid = $pid")
thread {
// Redirect stdout to log
while ( reader.readLine()?.apply { line = this } != null ) {
Log.i(TAG, line.trim())
}
synchronized(this@ClashProcess) {
p.destroy()
process = null
pid = -1
}
listener(ClashProcessStatus.STATUS_STOPPED)
}
}
catch (e: Exception) {
listener(ClashProcessStatus.STATUS_STOPPED)
throw e
}
}
@@ -5,8 +5,10 @@ import android.os.Parcelable
data class ClashProcessStatus(val status: Int) : Parcelable {
companion object {
const val STATUS_STOPPED = 0
const val STATUS_STARTED = 1
const val STATUS_STOPPED_INT = 0
const val STATUS_STARTED_INT = 1
val STATUS_STOPPED = ClashProcessStatus(STATUS_STOPPED_INT)
val STATUS_STARTED = ClashProcessStatus(STATUS_STARTED_INT)
@JvmField
val CREATOR = object: Parcelable.Creator<ClashProcessStatus> {
+1 -2
View File
@@ -36,7 +36,6 @@ dependencies {
implementation project(":core")
implementation "androidx.lifecycle:lifecycle-livedata:2.1.0"
implementation "androidx.lifecycle:lifecycle-common-java8:2.1.0"
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation "androidx.core:core-ktx:1.1.0"
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
implementation "androidx.room:room-runtime:$room_version"
}
-4
View File
@@ -1,10 +1,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.github.kr328.clash.service">
<application>
<activity
android:name=".VpnRequestActivity"
android:exported="false"
android:theme="@android:style/Theme.NoDisplay" />
<service
android:name="ClashService"
android:exported="false"
@@ -3,6 +3,7 @@ package com.github.kr328.clash.service
import android.app.Service
import android.content.Intent
import android.net.Uri
import android.os.Handler
import android.os.IBinder
import android.os.ParcelFileDescriptor
import android.os.RemoteException
@@ -19,11 +20,12 @@ class ClashService : Service() {
private val TAG = "ClashForAndroid"
}
private val handler = Handler()
private val executor = Executors.newSingleThreadExecutor()
private lateinit var clash: Clash
private var lastProfile: String? = null
private var status = ClashProcessStatus(ClashProcessStatus.STATUS_STOPPED)
private var status = ClashProcessStatus(ClashProcessStatus.STATUS_STOPPED_INT)
set(value) {
field = value
@@ -38,6 +40,8 @@ class ClashService : Service() {
private val defaultProfileObserver = object: Observer<String?> {
override fun onChanged(url: String?) {
executor.submit {
Log.i(TAG, "Loading profile $url")
if (url == null)
clash.stop()
if (url == lastProfile)
@@ -55,7 +59,7 @@ class ClashService : Service() {
lastProfile = null
ClashDatabase.getInstance(this@ClashService)
.openClashProfileDao()
.setDefaultProfile(p ?: "")
.setSelectedProfile(p ?: "")
}
}
}
@@ -97,6 +101,8 @@ class ClashService : Service() {
try {
clash.start()
} catch (e: IOException) {
Log.e(TAG, "Start failure", e)
throw RemoteException(e.message)
}
}
@@ -128,12 +134,19 @@ class ClashService : Service() {
) {
this.status = it
when (it.status) {
ClashProcessStatus.STATUS_STARTED ->
ClashDatabase.getInstance(this)
.openClashProfileDao()
.observeDefaultProfileUrl()
.observeForever(defaultProfileObserver)
handler.post {
when (it.status) {
ClashProcessStatus.STATUS_STARTED_INT ->
ClashDatabase.getInstance(this)
.openClashProfileDao()
.observeDefaultProfileUrl()
.observeForever(defaultProfileObserver)
ClashProcessStatus.STATUS_STOPPED_INT ->
ClashDatabase.getInstance(this)
.openClashProfileDao()
.observeDefaultProfileUrl()
.removeObserver(defaultProfileObserver)
}
}
}
}
@@ -154,11 +167,6 @@ class ClashService : Service() {
clash.stop()
executor.shutdown()
ClashDatabase.getInstance(this)
.openClashProfileDao()
.observeDefaultProfileUrl()
.removeObserver(defaultProfileObserver)
super.onDestroy()
}
}
@@ -20,7 +20,7 @@ class TunService : VpnService() {
private const val PRIVATE_VLAN6_ROUTER = "fdfe:dcba:9876::2"
}
private lateinit var fileDescriptor: ParcelFileDescriptor
private var fileDescriptor: ParcelFileDescriptor? = null
private var clash: IClashService? = null
private val connection = object: ServiceConnection {
override fun onServiceDisconnected(name: ComponentName?) {
@@ -40,9 +40,9 @@ class TunService : VpnService() {
return
when ( status.status ) {
ClashProcessStatus.STATUS_STOPPED ->
ClashProcessStatus.STATUS_STOPPED_INT ->
stopSelf()
ClashProcessStatus.STATUS_STARTED ->
ClashProcessStatus.STATUS_STARTED_INT ->
clash.startTunDevice(fileDescriptor, VPN_MTU)
}
}
@@ -57,7 +57,6 @@ class TunService : VpnService() {
super.onCreate()
if ( prepare(this) != null ) {
startActivity(Intent(this, VpnRequestActivity::class.java))
stopSelf()
return
}
@@ -84,7 +83,7 @@ class TunService : VpnService() {
}
override fun onDestroy() {
fileDescriptor.close()
fileDescriptor?.close()
clash?.stopTunDevice()
clash?.stop()
@@ -1,32 +0,0 @@
package com.github.kr328.clash.service
import android.app.Activity
import android.content.Intent
import android.net.VpnService
import android.os.Bundle
class VpnRequestActivity : Activity() {
companion object {
const val REQUEST_CODE = 124
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val intent = VpnService.prepare(this)
if ( intent == null )
finish()
else
startActivityForResult(intent, REQUEST_CODE)
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if ( requestCode == REQUEST_CODE && resultCode == RESULT_OK ) {
startService(Intent(this, TunService::class.java))
}
else {
super.onActivityResult(requestCode, resultCode, data)
}
}
}
@@ -6,9 +6,9 @@ import androidx.room.Query
@Dao
interface ClashProfileDao {
@Query("UPDATE profiles SET `default` = CASE WHEN url = :url THEN 1 ELSE 0 END")
fun setDefaultProfile(url: String)
@Query("UPDATE profiles SET selected = CASE WHEN url = :url THEN 1 ELSE 0 END")
fun setSelectedProfile(url: String)
@Query("SELECT url FROM profiles WHERE `default` = 1 LIMIT 1")
@Query("SELECT url FROM profiles WHERE selected = 1 LIMIT 1")
fun observeDefaultProfileUrl(): LiveData<String?>
}
@@ -1,10 +1,42 @@
package com.github.kr328.clash.service.data
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.*
@Entity(tableName = "profiles", primaryKeys = ["url"])
data class ClashProfileEntity(@ColumnInfo(name = "url") val url: String,
@ColumnInfo(name = "default") val defaultProfile: Boolean,
@ColumnInfo(name = "writable") val writable: Boolean,
@ColumnInfo(name = "last_update") val lastUpdate: Long)
@Entity(tableName = "profiles")
@TypeConverters(ClashProfileEntity.Converters::class)
data class ClashProfileEntity(
@ColumnInfo(name = "name") val name: String,
@ColumnInfo(name = "url") val url: String,
@ColumnInfo(name = "selected") val selected: Boolean,
@ColumnInfo(name = "type") val writable: Type,
@ColumnInfo(name = "last_update") val lastUpdate: Long,
@ColumnInfo(name = "id") @PrimaryKey(autoGenerate = true) val id: Int = 0) {
enum class Type {
UNKNOWN, URL, FILE
}
class Converters {
@TypeConverter
fun typeToInt(type: Type?): Int? {
return type?.run {
when ( this ) {
Type.URL -> 1
Type.FILE -> 2
Type.UNKNOWN -> 0
}
}
}
@TypeConverter
fun intToType(i: Int?): Type? {
return i?.run {
when (this) {
1 -> Type.URL
2 -> Type.FILE
else -> Type.UNKNOWN
}
}
}
}
}