new profile activity
This commit is contained in:
24 files changed
+533
-104
No files matched your search
@@ -21,6 +21,11 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity android:name=".ProfilesActivity" android:exported="false" />
|
||||
<activity android:name=".ProfilesActivity"
|
||||
android:label="@string/clash_profiles"
|
||||
android:exported="false" />
|
||||
<activity android:name=".CreateProfileActivity"
|
||||
android:label="@string/clash_new_profile"
|
||||
android:exported="false" />
|
||||
</application>
|
||||
</manifest>
|
||||
</manifest>
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.github.kr328.clash
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.BaseAdapter
|
||||
import android.widget.ImageView
|
||||
import android.widget.ListView
|
||||
import android.widget.TextView
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.fragment.app.commit
|
||||
import kotlinx.android.synthetic.main.activity_new_profile.*
|
||||
|
||||
class CreateProfileActivity : AppCompatActivity() {
|
||||
companion object {
|
||||
val NEW_PROFILE_SOURCE = listOf(
|
||||
AdapterData(R.drawable.ic_new_profile_file,
|
||||
R.string.clash_new_profile_file_title,
|
||||
R.string.clash_new_profile_file_summary),
|
||||
AdapterData(R.drawable.ic_new_profile_url,
|
||||
R.string.clash_new_profile_url_title,
|
||||
R.string.clash_new_profile_url_summary)
|
||||
)
|
||||
}
|
||||
|
||||
class Adapter(private val context: Context) : BaseAdapter() {
|
||||
override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View {
|
||||
return (convertView ?: LayoutInflater.from(context).inflate(R.layout.adapter_new_profile, parent, false)).apply {
|
||||
val current = NEW_PROFILE_SOURCE[position]
|
||||
|
||||
findViewById<ImageView>(R.id.adapter_new_profile_icon).setImageResource(current.icon)
|
||||
findViewById<TextView>(R.id.adapter_new_profile_title).text = context.getString(current.title)
|
||||
findViewById<TextView>(R.id.adapter_new_profile_summary).text = context.getString(current.summary)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItem(position: Int): Any {
|
||||
return NEW_PROFILE_SOURCE
|
||||
}
|
||||
|
||||
override fun getItemId(position: Int): Long {
|
||||
return position.toLong()
|
||||
}
|
||||
|
||||
override fun getCount(): Int {
|
||||
return NEW_PROFILE_SOURCE.size
|
||||
}
|
||||
}
|
||||
|
||||
data class AdapterData(val icon: Int, val title: Int, val summary: Int)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_new_profile)
|
||||
|
||||
setSupportActionBar(activity_new_profile_toolbar)
|
||||
|
||||
activity_new_profile_list.adapter = Adapter(this)
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,10 @@ class MainActivity : AppCompatActivity() {
|
||||
setLogo(R.mipmap.ic_launcher_foreground)
|
||||
})
|
||||
|
||||
activity_main_clash_profiles.setOnClickListener {
|
||||
startActivity(Intent(this, ProfilesActivity::class.java))
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -100,7 +104,7 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
ClashDatabase.getInstance(this)
|
||||
.openClashProfileDao()
|
||||
.observeDefaultProfileUrl()
|
||||
.observeDefaultProfileName()
|
||||
.observe(this) {
|
||||
activity_main_clash_profiles_summary.text =
|
||||
if ( it == null )
|
||||
|
||||
@@ -1,14 +1,57 @@
|
||||
package com.github.kr328.clash
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.observe
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.github.kr328.clash.adapter.ProfileAdapter
|
||||
import com.github.kr328.clash.service.data.ClashDatabase
|
||||
import com.github.kr328.clash.service.data.ClashProfileEntity
|
||||
import kotlinx.android.synthetic.main.activity_profiles.*
|
||||
|
||||
class ProfilesActivity : AppCompatActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_main_profiles)
|
||||
setContentView(R.layout.activity_profiles)
|
||||
|
||||
setSupportActionBar(activity_profiles_toolbar)
|
||||
activity_profiles_toolbar.elevation = 4.0f
|
||||
|
||||
activity_profiles_new_profile.setOnClickListener {
|
||||
startActivity(Intent(this, CreateProfileActivity::class.java))
|
||||
}
|
||||
|
||||
activity_profiles_main_list.layoutManager = object: LinearLayoutManager(this) {
|
||||
override fun canScrollHorizontally(): Boolean = false
|
||||
override fun canScrollVertically(): Boolean = false
|
||||
}
|
||||
activity_profiles_main_list.adapter = ProfileAdapter(this)
|
||||
|
||||
ClashDatabase.getInstance(this)
|
||||
.openClashProfileDao()
|
||||
.observeProfiles()
|
||||
.observe(this, this::refreshList)
|
||||
}
|
||||
|
||||
private fun refreshList(newData: List<ClashProfileEntity>) {
|
||||
val adapter = activity_profiles_main_list.adapter as ProfileAdapter
|
||||
val oldData = adapter.profiles
|
||||
|
||||
val result = DiffUtil.calculateDiff(object : DiffUtil.Callback() {
|
||||
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean =
|
||||
oldData[oldItemPosition].id == newData[newItemPosition].id
|
||||
|
||||
override fun getOldListSize(): Int = oldData.size
|
||||
|
||||
override fun getNewListSize(): Int = newData.size
|
||||
|
||||
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean =
|
||||
oldData[oldItemPosition] == newData[newItemPosition]
|
||||
})
|
||||
|
||||
adapter.profiles = newData
|
||||
result.dispatchUpdatesTo(adapter)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.github.kr328.clash.adapter
|
||||
|
||||
import android.content.Context
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.github.kr328.clash.R
|
||||
import com.github.kr328.clash.fragment.FormFragment
|
||||
import com.google.android.material.textfield.TextInputEditText
|
||||
|
||||
class FormAdapter(private val context: Context, private val elements: List<FormFragment.FormElement>) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
class TextHolder(view: View) : RecyclerView.ViewHolder(view) {
|
||||
val icon: ImageView = view.findViewById(R.id.adapter_from_text_icon)
|
||||
val edit: TextInputEditText = view.findViewById(R.id.adapter_from_text_text)
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||
return when ( viewType ) {
|
||||
FormFragment.FormType.STRING.ordinal, FormFragment.FormType.FILE.ordinal, FormFragment.FormType.URL.ordinal ->
|
||||
TextHolder(LayoutInflater.from(context).inflate(R.layout.adapter_from_text, parent, false))
|
||||
else -> throw IllegalArgumentException()
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int {
|
||||
return elements.size
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
val current = elements[position]
|
||||
|
||||
when ( holder ) {
|
||||
is TextHolder -> {
|
||||
holder.icon.setImageResource(current.icon)
|
||||
holder.edit.hint = context.getString(current.hint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemViewType(position: Int): Int {
|
||||
return elements[position].type.ordinal
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.github.kr328.clash.adapter
|
||||
|
||||
import android.content.Context
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.github.kr328.clash.R
|
||||
import com.github.kr328.clash.service.data.ClashProfileEntity
|
||||
|
||||
class ProfileAdapter(private val context: Context) :
|
||||
RecyclerView.Adapter<ProfileAdapter.ProfileViewHolder>() {
|
||||
var profiles: List<ClashProfileEntity> = emptyList()
|
||||
|
||||
class ProfileViewHolder(view: View) : RecyclerView.ViewHolder(view) {
|
||||
val checkable: View = view.findViewById(R.id.adapter_profiles_checked)
|
||||
val title: TextView = view.findViewById(R.id.adapter_profiles_title)
|
||||
val summary: TextView = view.findViewById(R.id.adapter_profiles_summary)
|
||||
val operation: ImageView = view.findViewById(R.id.adapter_profiles_operation)
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ProfileViewHolder {
|
||||
return ProfileViewHolder(
|
||||
LayoutInflater.from(context)
|
||||
.inflate(R.layout.adapter_profile, parent, false)
|
||||
)
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int {
|
||||
return profiles.size
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ProfileViewHolder, position: Int) {
|
||||
val current = profiles[position]
|
||||
|
||||
holder.title.text = current.name
|
||||
holder.summary.text = context.getString(
|
||||
R.string.clash_profile_item_summary,
|
||||
current.proxies, current.proxyGroups, current.rules
|
||||
)
|
||||
|
||||
with(current.token) {
|
||||
when {
|
||||
startsWith("http") || startsWith("content") -> {
|
||||
holder.operation.setImageResource(R.drawable.ic_profile_refresh)
|
||||
}
|
||||
startsWith("file") -> {
|
||||
holder.operation.setImageResource(R.drawable.ic_profile_edit)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.github.kr328.clash.fragment
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.github.kr328.clash.adapter.FormAdapter
|
||||
import java.lang.Exception
|
||||
|
||||
class FormFragment(private val elements: List<FormElement>) : Fragment() {
|
||||
enum class FormType {
|
||||
STRING, URL, FILE
|
||||
}
|
||||
|
||||
interface ResultDumper {
|
||||
class DumpException(message: String) : Exception(message)
|
||||
|
||||
fun dump(element: FormElement, data: Any)
|
||||
}
|
||||
data class FormElement(val type: FormType, val icon: Int, val title: Int, val hint: Int, val result: ResultDumper)
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
return RecyclerView(requireActivity()).apply {
|
||||
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
|
||||
layoutManager = LinearLayoutManager(requireActivity())
|
||||
adapter = FormAdapter(requireActivity(), elements)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,5 +7,5 @@ import kotlinx.serialization.Serializable
|
||||
data class ClashProfile(
|
||||
@SerialName("Proxy") val proxies: List<ClashProxy>,
|
||||
@SerialName("Proxy Group") val proxyGroups: List<ClashProxyGroup>,
|
||||
@SerialName("Rule") val rules: List<String>
|
||||
@SerialName("Rule") val rules: List<ClashRule>
|
||||
)
|
||||
@@ -0,0 +1,82 @@
|
||||
package com.github.kr328.clash.model
|
||||
|
||||
import com.charleskorn.kaml.YamlException
|
||||
import kotlinx.serialization.*
|
||||
import kotlinx.serialization.internal.StringDescriptor
|
||||
|
||||
@Serializable(ClashRule.Serializer::class)
|
||||
data class ClashRule(val matcher: Matcher, val pattern: String, val target: String) {
|
||||
enum class Matcher {
|
||||
DOMAIN_SUFFIX,
|
||||
DOMAIN_KEYWORD,
|
||||
DOMAIN,
|
||||
IP_CIDR,
|
||||
SRC_IP_CIDR,
|
||||
GEOIP,
|
||||
DST_PORT,
|
||||
SRC_PORT,
|
||||
MATCH;
|
||||
|
||||
override fun toString(): String {
|
||||
return when (this) {
|
||||
DOMAIN_SUFFIX -> "DOMAIN-SUFFIX"
|
||||
DOMAIN_KEYWORD -> "DOMAIN-KEYWORD"
|
||||
DOMAIN -> "DOMAIN"
|
||||
IP_CIDR -> "IP-CIDR"
|
||||
SRC_IP_CIDR -> "SRC-IP-CIDR"
|
||||
GEOIP -> "GEOIP"
|
||||
DST_PORT -> "DST-PORT"
|
||||
SRC_PORT -> "SRC-PORT"
|
||||
MATCH -> "MATCH"
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromString(s: String): Matcher {
|
||||
return when (s) {
|
||||
"DOMAIN-SUFFIX" -> DOMAIN_SUFFIX
|
||||
"DOMAIN-KEYWORD" -> DOMAIN_KEYWORD
|
||||
"DOMAIN" -> DOMAIN
|
||||
"IP-CIDR" -> IP_CIDR
|
||||
"SRC-IP-CIDR" -> SRC_IP_CIDR
|
||||
"GEOIP" -> GEOIP
|
||||
"DST-PORT" -> DST_PORT
|
||||
"SRC-PORT" -> SRC_PORT
|
||||
"MATCH" -> MATCH
|
||||
else -> throw YamlException("Invalid matcher $s", 0, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Serializer : KSerializer<ClashRule> {
|
||||
override val descriptor: SerialDescriptor
|
||||
get() = StringDescriptor.withName("rule")
|
||||
|
||||
override fun deserialize(decoder: Decoder): ClashRule {
|
||||
val rule = decoder.decodeString().split(",")
|
||||
|
||||
return when (rule.size) {
|
||||
3 -> {
|
||||
ClashRule(Matcher.fromString(rule[0]), rule[1], rule[2])
|
||||
}
|
||||
2 -> {
|
||||
if ( Matcher.fromString(rule[0]) != Matcher.MATCH )
|
||||
throw YamlException("Invalid rule $rule", 0, 0)
|
||||
|
||||
ClashRule(Matcher.MATCH, "", rule[1])
|
||||
}
|
||||
else -> throw YamlException("Invalid rule $rule", 0, 0)
|
||||
}
|
||||
}
|
||||
|
||||
override fun serialize(encoder: Encoder, obj: ClashRule) {
|
||||
if ( obj.matcher == Matcher.MATCH ) {
|
||||
encoder.encodeString("${obj.matcher},${obj.target}")
|
||||
}
|
||||
else {
|
||||
encoder.encodeString("${obj.matcher},${obj.pattern},${obj.target}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M6,2c-1.1,0 -1.99,0.9 -1.99,2L4,20c0,1.1 0.89,2 1.99,2L18,22c1.1,0 2,-0.9 2,-2L20,8l-6,-6L6,2zM13,9L13,3.5L18.5,9L13,9z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M19.35,10.04C18.67,6.59 15.64,4 12,4 9.11,4 6.6,5.64 5.35,8.04 2.34,8.36 0,10.91 0,14c0,3.31 2.69,6 6,6h13c2.76,0 5,-2.24 5,-5 0,-2.64 -2.05,-4.78 -4.65,-4.96zM17,13l-5,5 -5,-5h3V9h4v4h3z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M3,17.25V21h3.75L17.81,9.94l-3.75,-3.75L3,17.25zM20.71,7.04c0.39,-0.39 0.39,-1.02 0,-1.41l-2.34,-2.34c-0.39,-0.39 -1.02,-0.39 -1.41,0l-1.83,1.83 3.75,3.75 1.83,-1.83z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -7.99,8s3.57,8 7.99,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:orientation="vertical"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<com.google.android.material.appbar.AppBarLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
<androidx.appcompat.widget.Toolbar
|
||||
android:id="@+id/activity_new_profile_toolbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="60dp"/>
|
||||
</com.google.android.material.appbar.AppBarLayout>
|
||||
|
||||
<ListView
|
||||
android:id="@+id/activity_new_profile_list"
|
||||
android:dividerHeight="0dp"
|
||||
android:divider="@null"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
|
||||
</LinearLayout>
|
||||
@@ -1,13 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:orientation="vertical"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<androidx.appcompat.widget.Toolbar
|
||||
android:id="@+id/activity_profiles_toolbar"
|
||||
<com.google.android.material.appbar.AppBarLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="75dp"/>
|
||||
android:layout_height="wrap_content">
|
||||
<androidx.appcompat.widget.Toolbar
|
||||
android:id="@+id/activity_profiles_toolbar"
|
||||
android:elevation="4dp"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="60dp" />
|
||||
</com.google.android.material.appbar.AppBarLayout>
|
||||
|
||||
<ScrollView
|
||||
android:layout_width="match_parent"
|
||||
@@ -33,11 +39,10 @@
|
||||
android:contentDescription="@string/app_name"
|
||||
android:layout_marginStart="20dp"
|
||||
android:layout_marginEnd="20dp"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="60dp"/>
|
||||
android:layout_width="20dp"
|
||||
android:layout_height="40dp"/>
|
||||
<TextView
|
||||
android:text="@string/clash_new_profile"
|
||||
android:textAppearance="@style/TextAppearance.AppCompat.Large"
|
||||
android:gravity="center|start"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:padding="15dp"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/adapter_from_text_icon"
|
||||
android:src="@drawable/ic_about"
|
||||
android:contentDescription="@string/app_name"
|
||||
android:layout_width="30dp"
|
||||
android:layout_height="30dp"/>
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/adapter_from_text_text"
|
||||
android:drawableEnd="@drawable/ic_about"
|
||||
android:layout_marginStart="10dp"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:focusable="true"
|
||||
android:clickable="true"
|
||||
android:foreground="?android:attr/selectableItemBackground"
|
||||
android:inputType="none"/>
|
||||
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,44 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:id="@+id/activity_main_clash_profiles"
|
||||
android:layout_marginTop="10dp"
|
||||
android:layout_marginStart="30dp"
|
||||
android:layout_marginEnd="30dp"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingStart="18dp"
|
||||
android:paddingEnd="18dp"
|
||||
android:paddingTop="15dp"
|
||||
android:paddingBottom="15dp"
|
||||
app:cardCornerRadius="8dp"
|
||||
app:cardBackgroundColor="@android:color/white">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/adapter_new_profile_icon"
|
||||
android:layout_width="28dp"
|
||||
android:layout_height="28dp"
|
||||
android:layout_centerVertical="true"
|
||||
android:contentDescription="@string/app_name"
|
||||
android:src="@drawable/ic_profiles" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/adapter_new_profile_title"
|
||||
android:layout_toEndOf="@id/adapter_new_profile_icon"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="20dp"
|
||||
android:textAppearance="@style/TextAppearance.AppCompat.Medium"
|
||||
android:text="@string/clash_profiles" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/adapter_new_profile_summary"
|
||||
android:text="@string/clash_profiles_summary_unselected"
|
||||
android:textColor="@android:color/darker_gray"
|
||||
android:textAppearance="@style/TextAppearance.AppCompat.Small"
|
||||
android:layout_marginTop="2dp"
|
||||
android:layout_alignStart="@id/adapter_new_profile_title"
|
||||
android:layout_below="@id/adapter_new_profile_title"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content" />
|
||||
</RelativeLayout>
|
||||
@@ -22,11 +22,13 @@
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent">
|
||||
<TextView
|
||||
android:id="@+id/adapter_profiles_title"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="@style/TextAppearance.AppCompat.Medium"
|
||||
android:text="@string/app_name"/>
|
||||
<TextView
|
||||
android:id="@+id/adapter_profiles_summary"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="@style/TextAppearance.AppCompat.Small"
|
||||
@@ -47,6 +49,7 @@
|
||||
android:background="@color/light_gray"/>
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/adapter_profiles_operation"
|
||||
android:contentDescription="@string/app_name"
|
||||
android:layout_width="80dp"
|
||||
android:layout_height="80dp"
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<resources>
|
||||
<string name="app_name">Clash</string>
|
||||
|
||||
<string name="clash_status_stopped">Clash Stopped</string>
|
||||
<string name="clash_status_click_to_start">Click to start</string>
|
||||
<string name="clash_status_started">Clash Started</string>
|
||||
<string name="clash_status_stopped">Stopped</string>
|
||||
<string name="clash_status_click_to_start">Click here to start</string>
|
||||
<string name="clash_status_started">Running</string>
|
||||
<string name="clash_status_forwarded_traffic">%s Traffic Forwarded</string>
|
||||
|
||||
<string name="clash_proxy_manage">Proxy Manage</string>
|
||||
@@ -19,4 +19,11 @@
|
||||
<string name="clash_about">About</string>
|
||||
|
||||
<string name="clash_new_profile">New Profile</string>
|
||||
<string name="clash_profile_item_summary">%d Proxies, %d ProxyGroups, %d Rules</string>
|
||||
|
||||
<string name="clash_new_profile_file_title">File</string>
|
||||
<string name="clash_new_profile_file_summary">From Local Storage</string>
|
||||
|
||||
<string name="clash_new_profile_url_title">URL</string>
|
||||
<string name="clash_new_profile_url_summary">From Internet</string>
|
||||
</resources>
|
||||
@@ -3,13 +3,12 @@ package com.github.kr328.clash.core
|
||||
import android.content.Context
|
||||
import android.net.LocalSocket
|
||||
import android.net.LocalSocketAddress
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import org.json.JSONObject
|
||||
import java.io.*
|
||||
|
||||
class Clash(
|
||||
private val context: Context,
|
||||
context: Context,
|
||||
clashDir: File,
|
||||
private val controllerPath: File,
|
||||
listener: (ClashProcessStatus) -> Unit
|
||||
@@ -24,15 +23,12 @@ class Clash(
|
||||
const val PING_REPLY = 233
|
||||
}
|
||||
|
||||
private val cacheFile = context.cacheDir.resolve("config.yaml")
|
||||
|
||||
fun ping(): Boolean {
|
||||
try {
|
||||
return runControl(COMMAND_PING) {_, input, _ ->
|
||||
return runControl(COMMAND_PING) { _, input, _ ->
|
||||
input.readInt() == PING_REPLY
|
||||
}
|
||||
}
|
||||
catch (e: IOException) {
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Clash ping failure", e)
|
||||
}
|
||||
return false
|
||||
@@ -40,24 +36,15 @@ class Clash(
|
||||
|
||||
@Throws(IOException::class)
|
||||
@Synchronized
|
||||
fun loadProfile(url: Uri?) {
|
||||
if ( url == null || url.scheme != "content" )
|
||||
return
|
||||
fun loadProfile(file: File) {
|
||||
runControl(COMMAND_PROFILE_RELOAD) { _, input, output ->
|
||||
output.writeString(JSONObject().apply {
|
||||
put("path", file.absolutePath)
|
||||
}.toString())
|
||||
|
||||
synchronized(cacheFile) {
|
||||
context.contentResolver.openInputStream(url)?.use {
|
||||
it.copyTo(FileOutputStream(cacheFile))
|
||||
}
|
||||
|
||||
runControl(COMMAND_PROFILE_RELOAD) {_, input, output ->
|
||||
output.writeString(JSONObject().apply {
|
||||
put("path", cacheFile.absolutePath)
|
||||
}.toString())
|
||||
|
||||
val result = input.readString()
|
||||
if (result.isNotEmpty()) {
|
||||
throw IOException(JSONObject(result).getString("error"))
|
||||
}
|
||||
val result = input.readString()
|
||||
if (result.isNotEmpty()) {
|
||||
throw IOException(JSONObject(result).getString("error"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -73,10 +60,18 @@ class Clash(
|
||||
}
|
||||
}
|
||||
|
||||
private fun <R>runControl(command: Int, block: (LocalSocket, DataInputStream, DataOutputStream) -> R): R {
|
||||
private fun <R> runControl(
|
||||
command: Int,
|
||||
block: (LocalSocket, DataInputStream, DataOutputStream) -> R
|
||||
): R {
|
||||
val socket = LocalSocket()
|
||||
|
||||
socket.connect(LocalSocketAddress(controllerPath.absolutePath, LocalSocketAddress.Namespace.FILESYSTEM))
|
||||
socket.connect(
|
||||
LocalSocketAddress(
|
||||
controllerPath.absolutePath,
|
||||
LocalSocketAddress.Namespace.FILESYSTEM
|
||||
)
|
||||
)
|
||||
|
||||
val input = DataInputStream(socket.inputStream)
|
||||
val output = DataOutputStream(socket.outputStream)
|
||||
@@ -90,8 +85,7 @@ class Clash(
|
||||
output.close()
|
||||
|
||||
socket.close()
|
||||
}
|
||||
catch (ignore: IOException) {
|
||||
} catch (ignore: IOException) {
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
package com.github.kr328.clash.core
|
||||
|
||||
import android.os.Parcel
|
||||
import android.os.Parcelable
|
||||
|
||||
data class ClashStatus(val status: Int) : Parcelable {
|
||||
companion object {
|
||||
const val STOPPED = 0
|
||||
const val IDLE = 1
|
||||
const val LOADING_PROFILE = 2
|
||||
|
||||
@JvmField
|
||||
val CREATOR = object: Parcelable.Creator<ClashStatus> {
|
||||
override fun createFromParcel(parcel: Parcel): ClashStatus {
|
||||
return ClashStatus(parcel)
|
||||
}
|
||||
|
||||
override fun newArray(size: Int): Array<ClashStatus?> {
|
||||
return arrayOfNulls(size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
constructor(parcel: Parcel) : this(parcel.readInt())
|
||||
|
||||
override fun writeToParcel(dest: Parcel?, flags: Int) {
|
||||
dest?.writeInt(status)
|
||||
}
|
||||
|
||||
override fun describeContents(): Int {
|
||||
return 0
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -2,7 +2,6 @@ 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
|
||||
@@ -12,6 +11,7 @@ import androidx.lifecycle.Observer
|
||||
import com.github.kr328.clash.core.Clash
|
||||
import com.github.kr328.clash.core.ClashProcessStatus
|
||||
import com.github.kr328.clash.service.data.ClashDatabase
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
@@ -23,7 +23,6 @@ class ClashService : Service() {
|
||||
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_INT)
|
||||
set(value) {
|
||||
@@ -38,29 +37,17 @@ class ClashService : Service() {
|
||||
private val observers = mutableMapOf<String, IClashObserver>()
|
||||
|
||||
private val defaultProfileObserver = object: Observer<String?> {
|
||||
override fun onChanged(url: String?) {
|
||||
override fun onChanged(file: String?) {
|
||||
executor.submit {
|
||||
Log.i(TAG, "Loading profile $url")
|
||||
Log.i(TAG, "Loading profile $file")
|
||||
|
||||
if (url == null)
|
||||
clash.stop()
|
||||
if (url == lastProfile)
|
||||
if (file == null)
|
||||
return@submit
|
||||
|
||||
try {
|
||||
clash.loadProfile(Uri.parse(url))
|
||||
lastProfile = url
|
||||
clash.loadProfile(File(file))
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Load profile failure")
|
||||
|
||||
if (lastProfile == null) {
|
||||
clash.stop()
|
||||
} else {
|
||||
val p = lastProfile
|
||||
lastProfile = null
|
||||
ClashDatabase.getInstance(this@ClashService)
|
||||
.openClashProfileDao()
|
||||
.setSelectedProfile(p ?: "")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -140,12 +127,12 @@ class ClashService : Service() {
|
||||
ClashProcessStatus.STATUS_STARTED_INT ->
|
||||
ClashDatabase.getInstance(this)
|
||||
.openClashProfileDao()
|
||||
.observeDefaultProfileUrl()
|
||||
.observeDefaultProfileCache()
|
||||
.observeForever(defaultProfileObserver)
|
||||
ClashProcessStatus.STATUS_STOPPED_INT ->
|
||||
ClashDatabase.getInstance(this)
|
||||
.openClashProfileDao()
|
||||
.observeDefaultProfileUrl()
|
||||
.observeDefaultProfileCache()
|
||||
.removeObserver(defaultProfileObserver)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,15 @@ import androidx.room.Query
|
||||
|
||||
@Dao
|
||||
interface ClashProfileDao {
|
||||
@Query("UPDATE profiles SET selected = CASE WHEN url = :url THEN 1 ELSE 0 END")
|
||||
fun setSelectedProfile(url: String)
|
||||
@Query("UPDATE profiles SET selected = CASE WHEN id = :id THEN 1 ELSE 0 END")
|
||||
fun setSelectedProfile(id: Long)
|
||||
|
||||
@Query("SELECT url FROM profiles WHERE selected = 1 LIMIT 1")
|
||||
fun observeDefaultProfileUrl(): LiveData<String?>
|
||||
@Query("SELECT cache FROM profiles WHERE selected = 1 LIMIT 1")
|
||||
fun observeDefaultProfileCache(): LiveData<String?>
|
||||
|
||||
@Query("SELECT name FROM profiles WHERE selected = 1 LIMIT 1")
|
||||
fun observeDefaultProfileName(): LiveData<String?>
|
||||
|
||||
@Query("SELECT * FROM profiles ORDER BY name")
|
||||
fun observeProfiles(): LiveData<List<ClashProfileEntity>>
|
||||
}
|
||||
@@ -6,9 +6,9 @@ import androidx.room.*
|
||||
@TypeConverters(ClashProfileEntity.Converters::class)
|
||||
data class ClashProfileEntity(
|
||||
@ColumnInfo(name = "name") val name: String,
|
||||
@ColumnInfo(name = "url") val url: String,
|
||||
@ColumnInfo(name = "token") val token: String,
|
||||
@ColumnInfo(name = "cache") val cache: String,
|
||||
@ColumnInfo(name = "selected") val selected: Boolean,
|
||||
@ColumnInfo(name = "type") val writable: Type,
|
||||
@ColumnInfo(name = "proxies") val proxies: Long,
|
||||
@ColumnInfo(name = "proxy_groups") val proxyGroups: Long,
|
||||
@ColumnInfo(name = "rules") val rules: Long,
|
||||
|
||||
Reference in new issue
Block a user