Add CI/CD configuration and API documentation

This commit is contained in:
2026-07-01 21:40:53 +08:00
commit c590135d68
4168 changed files with 740252 additions and 0 deletions
@@ -0,0 +1 @@
<manifest package="com.github.kr328.clash.design" />
@@ -0,0 +1,134 @@
package com.github.kr328.clash.design
import android.content.Context
import android.view.View
import androidx.core.widget.addTextChangedListener
import com.github.kr328.clash.design.adapter.AppAdapter
import com.github.kr328.clash.design.component.AccessControlMenu
import com.github.kr328.clash.design.databinding.DesignAccessControlBinding
import com.github.kr328.clash.design.databinding.DialogSearchBinding
import com.github.kr328.clash.design.dialog.FullScreenDialog
import com.github.kr328.clash.design.model.AppInfo
import com.github.kr328.clash.design.store.UiStore
import com.github.kr328.clash.design.util.*
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
class AccessControlDesign(
context: Context,
uiStore: UiStore,
private val selected: MutableSet<String>,
) : Design<AccessControlDesign.Request>(context) {
enum class Request {
ReloadApps,
SelectAll,
SelectNone,
SelectInvert,
Import,
Export,
}
private val binding = DesignAccessControlBinding
.inflate(context.layoutInflater, context.root, false)
private val adapter = AppAdapter(context, selected)
private val menu: AccessControlMenu by lazy {
AccessControlMenu(context, binding.menuView, uiStore, requests)
}
val apps: List<AppInfo>
get() = adapter.apps
override val root: View
get() = binding.root
suspend fun patchApps(apps: List<AppInfo>) {
adapter.swapDataSet(adapter::apps, apps, false)
}
suspend fun rebindAll() {
withContext(Dispatchers.Main) {
adapter.rebindAll()
}
}
init {
binding.self = this
binding.activityBarLayout.applyFrom(context)
binding.mainList.recyclerList.also {
it.bindAppBarElevation(binding.activityBarLayout)
it.applyLinearAdapter(context, adapter)
}
binding.menuView.setOnClickListener {
menu.show()
}
binding.searchView.setOnClickListener {
launch {
try {
requestSearch()
} finally {
withContext(NonCancellable) {
rebindAll()
}
}
}
}
}
private suspend fun requestSearch() {
coroutineScope {
val binding = DialogSearchBinding
.inflate(context.layoutInflater, context.root, false)
val adapter = AppAdapter(context, selected)
val dialog = FullScreenDialog(context)
val filter = Channel<Unit>(Channel.CONFLATED)
dialog.setContentView(binding.root)
binding.surface = dialog.surface
binding.mainList.applyLinearAdapter(context, adapter)
binding.keywordView.addTextChangedListener {
filter.trySend(Unit)
}
binding.closeView.setOnClickListener {
dialog.dismiss()
}
dialog.setOnDismissListener {
cancel()
}
dialog.setOnShowListener {
binding.keywordView.requestTextInput()
}
dialog.show()
while (isActive) {
filter.receive()
val keyword = binding.keywordView.text?.toString() ?: ""
val apps: List<AppInfo> = if (keyword.isEmpty()) {
emptyList()
} else {
withContext(Dispatchers.Default) {
apps.filter {
it.label.contains(keyword, ignoreCase = true) ||
it.packageName.contains(keyword, ignoreCase = true)
}
}
}
adapter.patchDataSet(adapter::apps, apps, false, AppInfo::packageName)
delay(200)
}
}
}
}
@@ -0,0 +1,48 @@
package com.github.kr328.clash.design
import android.content.Context
import android.view.View
import com.github.kr328.clash.design.databinding.DesignSettingsCommonBinding
import com.github.kr328.clash.design.preference.category
import com.github.kr328.clash.design.preference.clickable
import com.github.kr328.clash.design.preference.preferenceScreen
import com.github.kr328.clash.design.preference.tips
import com.github.kr328.clash.design.util.applyFrom
import com.github.kr328.clash.design.util.bindAppBarElevation
import com.github.kr328.clash.design.util.layoutInflater
import com.github.kr328.clash.design.util.root
class ApkBrokenDesign(context: Context) : Design<ApkBrokenDesign.Request>(context) {
data class Request(val url: String)
private val binding = DesignSettingsCommonBinding
.inflate(context.layoutInflater, context.root, false)
override val root: View
get() = binding.root
init {
binding.surface = surface
binding.activityBarLayout.applyFrom(context)
binding.scrollRoot.bindAppBarElevation(binding.activityBarLayout)
val screen = preferenceScreen(context) {
tips(R.string.application_broken_tips)
category(R.string.reinstall)
clickable(
title = R.string.github_releases,
summary = R.string.meta_github_url
) {
clicked {
requests.trySend(Request(context.getString(R.string.meta_github_url)))
}
}
}
binding.content.addView(screen.root)
}
}
@@ -0,0 +1,29 @@
package com.github.kr328.clash.design
import android.content.Context
import android.view.View
import com.github.kr328.clash.design.databinding.DesignAppCrashedBinding
import com.github.kr328.clash.design.util.applyFrom
import com.github.kr328.clash.design.util.bindAppBarElevation
import com.github.kr328.clash.design.util.layoutInflater
import com.github.kr328.clash.design.util.root
class AppCrashedDesign(context: Context) : Design<Unit>(context) {
private val binding = DesignAppCrashedBinding
.inflate(context.layoutInflater, context.root, false)
override val root: View
get() = binding.root
fun setAppLogs(logs: String) {
binding.logsView.text = logs
}
init {
binding.self = this
binding.activityBarLayout.applyFrom(context)
binding.scrollRoot.bindAppBarElevation(binding.activityBarLayout)
}
}
@@ -0,0 +1,82 @@
package com.github.kr328.clash.design
import android.content.Context
import android.view.View
import com.github.kr328.clash.design.databinding.DesignSettingsCommonBinding
import com.github.kr328.clash.design.model.Behavior
import com.github.kr328.clash.design.model.DarkMode
import com.github.kr328.clash.design.preference.*
import com.github.kr328.clash.design.store.UiStore
import com.github.kr328.clash.design.util.applyFrom
import com.github.kr328.clash.design.util.bindAppBarElevation
import com.github.kr328.clash.design.util.layoutInflater
import com.github.kr328.clash.design.util.root
import com.github.kr328.clash.service.store.ServiceStore
class AppSettingsDesign(
context: Context,
uiStore: UiStore,
srvStore: ServiceStore,
behavior: Behavior,
running: Boolean,
) : Design<AppSettingsDesign.Request>(context) {
enum class Request {
ReCreateAllActivities
}
private val binding = DesignSettingsCommonBinding
.inflate(context.layoutInflater, context.root, false)
override val root: View
get() = binding.root
init {
binding.surface = surface
binding.activityBarLayout.applyFrom(context)
binding.scrollRoot.bindAppBarElevation(binding.activityBarLayout)
val screen = preferenceScreen(context) {
category(R.string.behavior)
switch(
value = behavior::autoRestart,
icon = R.drawable.ic_baseline_restore,
title = R.string.auto_restart,
summary = R.string.allow_clash_auto_restart,
)
category(R.string.interface_)
selectableList(
value = uiStore::darkMode,
values = DarkMode.values(),
valuesText = arrayOf(
R.string.follow_system_android_10,
R.string.always_light,
R.string.always_dark
),
icon = R.drawable.ic_baseline_brightness_4,
title = R.string.dark_mode
) {
listener = OnChangedListener {
requests.trySend(Request.ReCreateAllActivities)
}
}
category(R.string.service)
switch(
value = srvStore::dynamicNotification,
icon = R.drawable.ic_baseline_domain,
title = R.string.show_traffic,
summary = R.string.show_traffic_summary
) {
enabled = !running
}
}
binding.content.addView(screen.root)
}
}
@@ -0,0 +1,277 @@
package com.github.kr328.clash.design
import android.annotation.SuppressLint
import android.content.res.Configuration
import android.os.Build
import android.os.Bundle
import android.view.View
// import android.view.WindowManager
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import com.github.kr328.clash.common.compat.isAllowForceDarkCompat
import com.github.kr328.clash.common.compat.isLightNavigationBarCompat
import com.github.kr328.clash.common.compat.isLightStatusBarsCompat
import com.github.kr328.clash.common.compat.isSystemBarsTranslucentCompat
import com.github.kr328.clash.design.adapter.OrdersDataAdapter
import com.github.kr328.clash.design.adapter.PlanDataAdapter
import com.github.kr328.clash.design.adapter.TicketDetailAdapter
import com.github.kr328.clash.design.adapter.TicketsDataAdapter
import com.github.kr328.clash.design.databinding.ActivityBaseListBinding
// import com.github.kr328.clash.design.databinding.DesignSettingsCommonBinding
import com.github.kr328.clash.design.ui.DayNight
import com.github.kr328.clash.design.ui.Surface
import com.github.kr328.clash.design.util.applyFrom
// import com.github.kr328.clash.design.util.layoutInflater
import com.github.kr328.clash.design.util.resolveThemedBoolean
import com.github.kr328.clash.design.util.resolveThemedColor
import com.github.kr328.clash.design.util.root
import com.github.kr328.clash.design.util.setOnInsertsChangedListener
// import com.github.kr328.clash.design.view.ActivityBarLayout
import com.github.kr328.clash.network.OrderData
import com.github.kr328.clash.network.PlanData
import com.github.kr328.clash.network.TicketMessage
import com.github.kr328.clash.network.TicketsData
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
// 这是一个通用的 Activity 用于加载和刷新列表数据
abstract class BaseListActivity<T> : AppCompatActivity() {
private var dayNight: DayNight = DayNight.Day
val surface = Surface()
private lateinit var recyclerView: RecyclerView
lateinit var swipeRefreshLayout: SwipeRefreshLayout
private lateinit var adapter: RecyclerView.Adapter<*>
private var currentPage = 1
private var isLoading = false
// 子类需要实现这些方法
abstract fun createAdapter(): RecyclerView.Adapter<*>
abstract suspend fun loadData(page: Int, callback: (List<T>?, Throwable?) -> Unit)
abstract fun onCreate()
var mainBinding: ActivityBaseListBinding? = null
private var onRightClick: (() -> Unit)? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
applyDayNight()
val binding = ActivityBaseListBinding
.inflate(this.layoutInflater, this.root, false)
binding.activityBarLayout.applyFrom(this)
setContentView(binding.root)
binding.surface = surface
mainBinding = binding
recyclerView = binding.baseRecyclerView
swipeRefreshLayout =binding.baseSwipeRefreshLayout
recyclerView.layoutManager = LinearLayoutManager(this)
adapter = createAdapter()
recyclerView.adapter = adapter
onCreate()
this.window.decorView.setOnInsertsChangedListener {
if (surface.insets != it) {
surface.insets = it
}
}
swipeRefreshLayout.setOnRefreshListener {
CoroutineScope(Dispatchers.IO).launch {
withContext(Dispatchers.Main) {
swipeRefreshLayout.isRefreshing = true
}
loadData(1) { data, error ->
swipeRefreshLayout.isRefreshing = false
if (error != null) {
showError(error)
} else {
swipeRefreshLayout.isRefreshing = false
initList(data)
}
}
}
}
refereshData()
}
fun requestRightClick(){
// 使用安全调用,确保在 onRightClick 不为 null 时执行
println("requestRightClick...")
onRightClick?.invoke()
println("requestRightClick invoke()...")
}
fun setRightButtonVisible(onRightClickEvent: (() -> Unit) ){
println("setRightButtonVisible...")
mainBinding?.rightButton?.visibility = View.VISIBLE
onRightClick = onRightClickEvent
mainBinding?.rightButton?.setOnClickListener {
requestRightClick()
}
}
fun refereshData(){
CoroutineScope(Dispatchers.IO).launch {
// 加载数据
withContext(Dispatchers.Main) {
swipeRefreshLayout.isRefreshing = true
}
loadData(currentPage) { data, error ->
if (error != null) {
swipeRefreshLayout.isRefreshing = false
showError(error)
} else {
swipeRefreshLayout.isRefreshing = false
initList(data)
}
}
}
}
fun referTitle(tit: String){
title = tit
mainBinding?.activityBarLayout?.applyFrom(this)
}
private fun applyDayNight(config: Configuration = resources.configuration) {
// val dayNight = theme.applyStyle(R.style.AppThemeLight, true) //默认白天模式
/*
val dayNight = queryDayNight(config)
when (dayNight) {
DayNight.Night -> theme.applyStyle(R.style.AppThemeDark, true)
DayNight.Day -> theme.applyStyle(R.style.AppThemeLight, true)
}*/
window.isAllowForceDarkCompat = false
window.isSystemBarsTranslucentCompat = true
window.statusBarColor = resolveThemedColor(android.R.attr.statusBarColor)
window.navigationBarColor = resolveThemedColor(android.R.attr.navigationBarColor)
if (Build.VERSION.SDK_INT >= 23) {
window.isLightStatusBarsCompat = resolveThemedBoolean(android.R.attr.windowLightStatusBar)
}
if (Build.VERSION.SDK_INT >= 27) {
window.isLightNavigationBarCompat = resolveThemedBoolean(android.R.attr.windowLightNavigationBar)
}
this.dayNight = DayNight.Night //dayNight
}
/**
* 初始化列表数据。
* 如果数据为空,显示提示信息;否则,在UI线程上更新适配器数据。
*
* @param data 要显示的数据列表,可能为null
*/
@SuppressLint("NotifyDataSetChanged")
private fun initList(data: List<T>?) {
if (data.isNullOrEmpty()) {
showEmptyDataToast()
} else {
updateAdapterDataOnUiThread(data)
}
}
/**
* 在UI线程上显示"没有更多数据"的Toast提示。
*/
private fun showEmptyDataToast() {
runOnUiThread {
Toast.makeText(this, "没有更多数据", Toast.LENGTH_SHORT).show()
}
}
/**
* 在UI线程上更新适配器数据。
* 这个方法作为一个中间层,避免在runOnUiThread的lambda中直接捕获外部变量。
*
* @param data 要更新到适配器的数据列表
*/
private fun updateAdapterDataOnUiThread(data: List<T>) {
runOnUiThread {
doUpdateAdapterData(data)
}
}
/**
* 执行实际的适配器数据更新操作。
* 根据适配器类型,将数据设置到相应的适配器中,并通知数据集变化。
*
* @param data 要更新到适配器的数据列表
*/
@SuppressLint("NotifyDataSetChanged")
@Suppress("UNCHECKED_CAST")
private fun doUpdateAdapterData(data: List<T>) {
when (adapter) {
is PlanDataAdapter -> (adapter as PlanDataAdapter).setData(data as List<PlanData>)
is OrdersDataAdapter -> (adapter as OrdersDataAdapter).setData(data as List<OrderData>)
is TicketsDataAdapter -> (adapter as TicketsDataAdapter).setData(data as List<TicketsData>)
is TicketDetailAdapter -> (adapter as TicketDetailAdapter).setData(data as List<TicketMessage>)
else -> println("Unknown adapter type.")
}
adapter.notifyDataSetChanged()
}
@SuppressLint("NotifyDataSetChanged")
private fun updateList(data: List<T>?) {
if (data.isNullOrEmpty()) {
runOnUiThread {
Toast.makeText(this, "没有更多数据", Toast.LENGTH_SHORT).show()
}
} else {
// 更新列表
runOnUiThread {
// 更新适配器的数据
// adapter.addData(data) // 假设你实现了一个 setData 方法来更新适配器的数据
adapter.notifyDataSetChanged()
}
}
}
private fun showError(error: Throwable) {
runOnUiThread {
Toast.makeText(this, "加载失败: ${error.message}", Toast.LENGTH_SHORT).show()
}
}
// 调用此方法来处理列表项点击
// fun onListItemClicked(item: T) {
// onItemClicked(item)
// }
}
@@ -0,0 +1,225 @@
package com.github.kr328.clash.design
import android.content.Context
import android.view.View
import androidx.core.content.ContextCompat
import com.github.kr328.clash.design.ProfileDesign.Request
import com.github.kr328.clash.design.databinding.ActivityConfigorderBinding
import com.github.kr328.clash.design.databinding.ActivityPlanItemBinding
import com.github.kr328.clash.design.databinding.ActivityProfileBinding
import com.github.kr328.clash.design.ui.ToastDuration
import com.github.kr328.clash.design.util.applyFrom
import com.github.kr328.clash.design.util.layoutInflater
import com.github.kr328.clash.design.util.root
import com.github.kr328.clash.network.ApiClient
import com.github.kr328.clash.network.ApiService
import com.github.kr328.clash.network.ConfigResponse
import com.github.kr328.clash.network.PlanData
import com.github.kr328.clash.network.SaveOrderRequest
import com.github.kr328.clash.network.SubmitOrderResponse
import com.github.kr328.clash.network.safeApiRequestCall
import com.github.kr328.clash.utity.LoadingDialog
import com.google.gson.Gson
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class ConfigOrderDesign (context: Context) : Design<ConfigOrderDesign.Request>(context) {
private var currentChoose: Map<String,String>? = null
public var trade_no:String = ""
enum class Request {
SubmitOrder,
}
private val binding = ActivityConfigorderBinding
.inflate(context.layoutInflater, context.root, false)
override val root: View
get() = binding.root
init {
binding.self = this
binding.activityBarLayout.applyFrom(context)
}
fun request(request: Request) {
if (request == Request.SubmitOrder){
//提交订单
LoadingDialog.show(context, "正在下单中...")
CoroutineScope(Dispatchers.IO).launch {
val apiService = ApiClient.retrofit.create(ApiService::class.java)
safeApiRequestCall { apiService.saveOrder(PreferenceManager.loginauthData, SaveOrderRequest("${currentChoose?.get("period")}",(currentChoose?.get("plan_id") ?: "0").toInt(),""))}.let {
withContext(Dispatchers.Main) {
LoadingDialog.hide()
}
if(it != null && it.isSuccessful){
if (it.body()?.data != null){
//tradeNO 订单号 成功
trade_no = it.body()?.data ?: ""
requests.trySend(request)
}
}else{
val errorinfo = (it?.errorBody()?.string())
if (errorinfo != null){
//解析错错误信息
val gson = Gson()
val submitResponse = gson.fromJson(errorinfo, SubmitOrderResponse::class.java)
if (submitResponse?.message != null){
withContext(Dispatchers.Main) {
showToast("下单失败:${submitResponse.message}", ToastDuration.Long)
}
}
}else{
withContext(Dispatchers.Main) {
showToast("下单失败:请求数据失败", ToastDuration.Long)
}
}
}
}
}
}
}
fun fillData(plan: PlanData?){
binding.configorderPlanname = "商品名称:${plan?.name}"
if (plan?.onetime_price != null && plan.onetime_price > 0) {
binding.configorderPlantype = "类型/周期:一次性"
binding.configorderPlantype2 = "一次性"
}else{
binding.configorderPlantype = "类型/周期:按月"
binding.configorderPlantype2 = "按月"
}
binding.configorderPlanname2 = "${plan?.name}"
binding.configorderPlantransfer = "商品流量:${plan?.transfer_enable} GB"
if (plan?.onetime_price !=null) {
binding.configorderPlanamount = "¥ ${plan?.onetime_price.toFloat()/100}0"
}else{
if (plan?.month_price !=null) {
binding.configorderPlanamount = "¥ ${plan?.month_price.toFloat() / 100}0"
}
}
var selectedIndex = 0
if (plan?.onetime_price != null && plan.onetime_price > 0) {
//一次性
val map = HashMap<String,String>()
map.put("type","一次性");//存储key和value
map.put("amount","${plan.onetime_price}");
map.put("period","onetime_price");
map.put("plan_id","${plan.id}");
val frameLayout = ActivityPlanItemBinding.inflate(context.layoutInflater, context.root, false)
frameLayout.typeTextView.text = "一次性"
frameLayout.amountTextView.text = "¥ ${ (map.get("amount")?.toDouble() ?: 0.0)/100}0"
binding.container.addView(frameLayout.root)
val child = binding.container.getChildAt(0)
child.background = ContextCompat.getDrawable(
context, R.drawable.card_border_selected
)
currentChoose = map
}else{
val map = HashMap<String,String>()
map.put("type","按月");//存储key和value
map.put("amount","${plan?.month_price ?: 0}");
map.put("period","month_price");
map.put("plan_id","${plan?.id}");
currentChoose = map
val map2 = HashMap<String,String>()
map2.put("type","按季");//存储key和value
map2.put("amount","${plan?.quarter_price ?: 0}");
map2.put("period","quarter_price");
map2.put("plan_id","${plan?.id}");
val map3 = HashMap<String,String>()
map3.put("type","半年");//存储key和value
map3.put("amount","${plan?.half_year_price ?: 0}");
map3.put("period","half_year_price");
map3.put("plan_id","${plan?.id}");
val map4 = HashMap<String,String>()
map4.put("type","一年");//存储key和value
map4.put("amount","${plan?.year_price ?: 0}");
map4.put("period","year_price");
map4.put("plan_id","${plan?.id}");
val list = ArrayList<Map<String,String>>()
list.add(map)
if((plan?.quarter_price ?: 0) > 0){
list.add(map2)
}
if((plan?.half_year_price ?: 0) > 0){
list.add(map3)
}
if((plan?.year_price ?: 0) > 0){
list.add(map4)
}
for (i in list.indices) {
// 设置点击事件和单选逻辑
val frameLayout = ActivityPlanItemBinding.inflate(context.layoutInflater, context.root, false)
val item = list[i]
frameLayout.typeTextView.text = item.get("type")
frameLayout.amountTextView.text ="¥ ${ (item.get("amount")?.toDouble() ?: 0.0)/100}0"
frameLayout.planitemFrameLayout.setOnClickListener {
selectedIndex = i
currentChoose = item
binding.configorderPlantype2 = item.get("type")
binding.configorderPlanamount = "¥ ${ (item.get("amount")?.toDouble() ?: 0.0)/100}0"
for (j in 0 until binding.container.childCount) {
val child = binding.container.getChildAt(j)
println("selectedIndex: ${selectedIndex} ${ child.background} ")
child.background = ContextCompat.getDrawable(
context,
if (j == selectedIndex) R.drawable.card_border_selected else R.drawable.card_border
)
}
}
binding.container.addView(frameLayout.root)
if (i == 0){
//默认选中第一个
frameLayout.planitemFrameLayout.background = ContextCompat.getDrawable(
context, R.drawable.card_border_selected
)
}
}
}
}
}
@@ -0,0 +1,59 @@
package com.github.kr328.clash.design
import android.content.Context
import android.view.View
import androidx.appcompat.app.AppCompatActivity
import com.github.kr328.clash.design.ui.Surface
import com.github.kr328.clash.design.ui.ToastDuration
import com.github.kr328.clash.design.util.setOnInsertsChangedListener
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.withContext
abstract class Design<R>(val context: Context) :
CoroutineScope by CoroutineScope(Dispatchers.Unconfined) {
abstract val root: View
val surface = Surface()
val requests: Channel<R> = Channel(Channel.UNLIMITED)
suspend fun showToast(
resId: Int,
duration: ToastDuration,
configure: Snackbar.() -> Unit = {}
) {
return showToast(context.getString(resId), duration, configure)
}
suspend fun showToast(
message: CharSequence,
duration: ToastDuration,
configure: Snackbar.() -> Unit = {}
) {
withContext(Dispatchers.Main) {
Snackbar.make(
root,
message,
when (duration) {
ToastDuration.Short -> Snackbar.LENGTH_SHORT
ToastDuration.Long -> Snackbar.LENGTH_LONG
ToastDuration.Indefinite -> Snackbar.LENGTH_INDEFINITE
}
).apply(configure).show()
}
}
init {
when (context) {
is AppCompatActivity -> {
context.window.decorView.setOnInsertsChangedListener {
if (surface.insets != it) {
surface.insets = it
}
}
}
}
}
}
@@ -0,0 +1,123 @@
package com.github.kr328.clash.design
import android.app.Dialog
import android.content.Context
import android.view.View
import com.github.kr328.clash.design.adapter.FileAdapter
import com.github.kr328.clash.design.databinding.DesignFilesBinding
import com.github.kr328.clash.design.databinding.DialogFilesMenuBinding
import com.github.kr328.clash.design.dialog.AppBottomSheetDialog
import com.github.kr328.clash.design.dialog.requestModelTextInput
import com.github.kr328.clash.design.model.File
import com.github.kr328.clash.design.util.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class FilesDesign(context: Context) : Design<FilesDesign.Request>(context) {
sealed class Request {
data class OpenFile(val file: File) : Request()
data class OpenDirectory(val file: File) : Request()
data class RenameFile(val file: File) : Request()
data class DeleteFile(val file: File) : Request()
data class ImportFile(val file: File?) : Request()
data class ExportFile(val file: File) : Request()
object PopStack : Request()
}
private val binding = DesignFilesBinding
.inflate(context.layoutInflater, context.root, false)
private val adapter: FileAdapter = FileAdapter(context, this::requestOpen, this::requestMore)
override val root: View
get() = binding.root
var configurationEditable: Boolean
get() = binding.configurationEditable
set(value) {
binding.configurationEditable = value
}
suspend fun swapFiles(files: List<File>, currentInBaseDir: Boolean) {
withContext(Dispatchers.Main) {
adapter.swapDataSet(adapter::files, files)
binding.currentInBaseDir = currentInBaseDir
}
}
fun updateElapsed() {
adapter.updateElapsed()
}
suspend fun requestFileName(name: String): String {
return context.requestModelTextInput(
initial = name,
title = context.getText(R.string.file_name),
hint = context.getText(R.string.file_name),
error = context.getText(R.string.invalid_file_name),
validator = ValidatorFileName,
)
}
init {
binding.self = this
binding.activityBarLayout.applyFrom(context)
binding.mainList.recyclerList.also {
it.applyLinearAdapter(context, adapter)
it.bindAppBarElevation(binding.activityBarLayout)
}
}
private fun requestOpen(file: File) {
if (file.isDirectory) {
requests.trySend(Request.OpenDirectory(file))
} else {
requests.trySend(Request.OpenFile(file))
}
}
fun requestRename(dialog: Dialog, file: File) {
requests.trySend(Request.RenameFile(file))
dialog.dismiss()
}
fun requestImport(dialog: Dialog, file: File) {
requests.trySend(Request.ImportFile(file))
dialog.dismiss()
}
fun requestExport(dialog: Dialog, file: File) {
requests.trySend(Request.ExportFile(file))
dialog.dismiss()
}
fun requestDelete(dialog: Dialog, file: File) {
requests.trySend(Request.DeleteFile(file))
dialog.dismiss()
}
fun requestNew() {
requests.trySend(Request.ImportFile(null))
}
private fun requestMore(file: File) {
val dialog = AppBottomSheetDialog(context)
val binding = DialogFilesMenuBinding.inflate(context.layoutInflater)
binding.master = this
binding.self = dialog
binding.file = file
binding.currentInBase = this.binding.currentInBaseDir
binding.configurationEditable = this.binding.configurationEditable
dialog.setContentView(binding.root)
dialog.show()
}
}
@@ -0,0 +1,91 @@
package com.github.kr328.clash.design
import android.content.Context
import android.net.Uri
import android.os.Build
import android.view.View
import android.webkit.WebChromeClient
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import com.github.kr328.clash.design.ProxyDesign.Request
import com.github.kr328.clash.design.databinding.ActivityH5webviewBinding
import com.github.kr328.clash.design.databinding.DesignSettingsCommonBinding
import com.github.kr328.clash.design.util.applyFrom
import com.github.kr328.clash.design.util.layoutInflater
import com.github.kr328.clash.design.util.root
class H5WebViewDesign(
context: Context,
openLink: (Uri) -> Unit,
) : Design<H5WebViewDesign.Request>(context) {
private val binding = ActivityH5webviewBinding
.inflate(context.layoutInflater, context.root, false)
enum class Request {
OpenURL,ReferWebView
}
override val root: View
get() = binding.root
init {
binding.surface = surface
binding.activityBarLayout.applyFrom(context)
binding.webview.webViewClient= WebViewClient()//目标的网页仍然在当前WebView中显
binding.webview.settings.apply {
javaScriptEnabled = true // 启用 JavaScript
domStorageEnabled = true // 启用 DOM 存储
loadWithOverviewMode = true
useWideViewPort = true
allowContentAccess = true
allowFileAccess = true
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
binding.webview.settings.mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
}
binding.webview.canGoBack()
// 设置 WebChromeClient 以更新加载进度
binding.webview.webChromeClient = object : WebChromeClient() {
override fun onProgressChanged(view: WebView?, newProgress: Int) {
super.onProgressChanged(view, newProgress)
// 显示进度条
if (newProgress < 100) {
binding.progressBar.visibility = View.VISIBLE
binding.progressBar.progress = newProgress
} else {
// 隐藏进度条
binding.progressBar.visibility = View.GONE
}
}
}
}
fun requestRefereshTesting() {
// urlTesting = true
binding.webview.reload()
}
fun breaseURL(url: String){
binding.webview.loadUrl(url)//.将网址传入
}
fun refereshApplyFrom(){
binding.activityBarLayout.applyFrom(context)
}
}
@@ -0,0 +1,251 @@
package com.github.kr328.clash.design
import android.content.Context
import android.net.Uri
import android.view.View
import android.view.inputmethod.InputMethodManager
import android.widget.Toast
import androidx.core.content.ContextCompat.getSystemService
import com.github.kr328.clash.design.MainDesign.Request
import com.github.kr328.clash.design.databinding.DesignSettingsCommonBinding
import com.github.kr328.clash.design.preference.NullableTextAdapter
import com.github.kr328.clash.design.preference.TextAdapter
import com.github.kr328.clash.design.preference.category
import com.github.kr328.clash.design.preference.clickable
import com.github.kr328.clash.design.preference.editableText
import com.github.kr328.clash.design.preference.editableTextMap
import com.github.kr328.clash.design.preference.preferenceScreen
import com.github.kr328.clash.design.preference.tips
import com.github.kr328.clash.design.ui.ToastDuration
import com.github.kr328.clash.design.util.applyFrom
import com.github.kr328.clash.design.util.bindAppBarElevation
import com.github.kr328.clash.design.util.layoutInflater
import com.github.kr328.clash.design.util.root
import com.github.kr328.clash.network.ApiClient
import com.github.kr328.clash.network.ApiService
import com.github.kr328.clash.network.LoginRequest
import com.github.kr328.clash.network.PublicResponse
import com.github.kr328.clash.network.safeApiRequestCall
import com.github.kr328.clash.utity.LoadingDialog
import com.google.gson.Gson
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.net.URLEncoder
class HelpDesign(
context: Context,
openLink: (Uri) -> Unit,
) : Design<HelpDesign.Request>(context) {
private val binding = DesignSettingsCommonBinding
.inflate(context.layoutInflater, context.root, false)
enum class Request {
CommitSuccess,
}
fun request(request: HelpDesign.Request) {
requests.trySend(request)
}
override val root: View
get() = binding.root
init {
binding.surface = surface
binding.activityBarLayout.applyFrom(context)
/*
binding.scrollRoot.bindAppBarElevation(binding.activityBarLayout)
data class ConfigurationOverride(
var title: String? = null,
var content: String? = null
)
val configuration = ConfigurationOverride()
val screen = preferenceScreen(context) {
editableText(
value = configuration::title,
adapter = NullableTextAdapter.String,
title = R.string.name,
placeholder = R.string.name,
empty = R.string.default_
)
editableText(
value = configuration::content,
adapter = NullableTextAdapter.String,
title = R.string.more,
placeholder = R.string.more,
empty = R.string.default_
)
tips(R.string.tips_help)
clickable(
title = R.string.github_issues,
summary = R.string.github_issues_zeus
) {
clicked {
openLink(Uri.parse("https://github.com/nicolastinkl"))
}
}
category(R.string.document)
clickable(
title = R.string.clash_wiki,
summary = R.string.clash_wiki_url
) {
clicked {
openLink(Uri.parse(context.getString(R.string.clash_wiki_url)))
}
}
clickable(
title = R.string.clash_meta_wiki,
summary = R.string.clash_meta_wiki_url
) {
clicked {
openLink(Uri.parse(context.getString(R.string.clash_meta_wiki_url)))
}
}
category(R.string.sources)
clickable(
title = R.string.clash_meta_core,
summary = R.string.clash_meta_core_url
) {
clicked {
openLink(Uri.parse(context.getString(R.string.clash_meta_core_url)))
}
}
clickable(
title = R.string.clash_meta_for_android,
summary = R.string.meta_github_url
) {
clicked {
openLink(Uri.parse(context.getString(R.string.meta_github_url)))
}
}
}
binding.content.addView(screen.root) */
binding.gongdanButton.setOnClickListener {
val gondanSubText = binding.gondanSubText.text.toString()
val gondanSubContent = binding.gondanSubContent.text.toString()
if (gondanSubText.isEmpty() || gondanSubContent.isEmpty()) {
Toast.makeText(context, "请输入工单标题和内容", Toast.LENGTH_SHORT).show()
} else {
// Hide the keyboard
val inputMethodManager = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
val currentFocusView = binding.root
if (currentFocusView != null) {
inputMethodManager.hideSoftInputFromWindow(currentFocusView.windowToken, 0)
}
performLogin(gondanSubText, gondanSubContent)
}
}
}
fun encodeString(input: String): String {
val encoded = StringBuilder()
input.forEach { char ->
when {
char.isWhitespace() -> {
// 编码空格为 %20
encoded.append("")
}
char.isLetterOrDigit() || char.toInt() in 0x4E00..0x9FFF -> {
// 保留字母、数字和中文字符
encoded.append(char)
}
else -> {
// 其他字符使用 URLEncoder 编码
encoded.append(URLEncoder.encode(char.toString(), "UTF-8"))
}
}
}
return encoded.toString()
}
private fun performLogin(email: String, password: String) {
// Perform login logic here, possibly calling an API
// Show the loading indicator with a custom message
LoadingDialog.show(context, "正在提交...")
val apiServiceApp = ApiClient.retrofit.create(ApiService::class.java)
//获取 Config 数据
CoroutineScope(Dispatchers.IO).launch {
val fieldMap = mutableMapOf<String, String>()
val email1 = encodeString(email)
val password1 =encodeString(password)
fieldMap.put("subject",""+email1)
fieldMap.put("level","1")
fieldMap.put("message",""+password1)
safeApiRequestCall {
apiServiceApp.saveTicket(PreferenceManager.loginauthData, request = fieldMap)}.let {
withContext(Dispatchers.Main) {
LoadingDialog.hide()
}
if (it != null && it.isSuccessful) {
showToast("提交成功",ToastDuration.Long)
requests.trySend(Request.CommitSuccess)
}else{
val errorinfo = it?.errorBody()?.string()
if (errorinfo != null){
//解析错错误信息
val gson = Gson()
val submitResponse = gson.fromJson(errorinfo, PublicResponse::class.java)
if (submitResponse?.message != null){
withContext(Dispatchers.Main) {
Toast.makeText(context, "提交失败: ${submitResponse.message}", Toast.LENGTH_SHORT).show()
}
}
}else{
withContext(Dispatchers.Main) {
Toast.makeText(context, "提交失败", Toast.LENGTH_SHORT).show()
}
}
}
}
}
}
}
@@ -0,0 +1,245 @@
package com.github.kr328.clash.design
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.view.View
import android.widget.Toast
import com.github.kr328.clash.common.util.intent
import com.github.kr328.clash.design.databinding.ActivityInvitationBinding
import com.github.kr328.clash.design.databinding.ActivityInviteItemBinding
import com.github.kr328.clash.design.databinding.ActivityPlanItemBinding
import com.github.kr328.clash.design.databinding.ActivitySubmitorderBinding
import com.github.kr328.clash.design.network.APIGlobalObject
import com.github.kr328.clash.design.ui.ToastDuration
import com.github.kr328.clash.design.util.applyFrom
import com.github.kr328.clash.design.util.layoutInflater
import com.github.kr328.clash.design.util.root
import com.github.kr328.clash.design.util.showCustomDialog
import com.github.kr328.clash.network.ApiClient
import com.github.kr328.clash.network.ApiService
import com.github.kr328.clash.network.PaymentData
import com.github.kr328.clash.network.PublicResponse
import com.github.kr328.clash.network.safeApiRequestCall
import com.github.kr328.clash.utity.LoadingDialog
import com.google.gson.Gson
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
class InvitationDesign (context: Context) : Design<InvitationDesign.Request>(context) {
enum class Request {
SubmitGenNewInviteCpde,
}
private var firstLink:String = ""
private val binding = ActivityInvitationBinding
.inflate(context.layoutInflater, context.root, false)
override val root: View
get() = binding.root
init {
binding.self = this
binding.activityBarLayout.applyFrom(context)
binding.CreatenewInvitecodeButton.setOnClickListener {
LoadingDialog.show(context, "正在生成中...")
CoroutineScope(Dispatchers.IO).launch {
val apiService = ApiClient.retrofit.create(ApiService::class.java)
safeApiRequestCall { apiService.saveinvite(PreferenceManager.loginauthData)}.let {
withContext(Dispatchers.Main) {
LoadingDialog.hide()
}
if (it != null && it.isSuccessful) {
withContext(Dispatchers.Main) {
requestdataDisible()
}
}else{
val errorinfo = it?.errorBody()?.string()
if (errorinfo != null){
//解析错错误信息
val gson = Gson()
val submitResponse = gson.fromJson(errorinfo, PublicResponse::class.java)
if (submitResponse?.message != null){
withContext(Dispatchers.Main) {
Toast.makeText(context, "生成失败: ${submitResponse.message}", Toast.LENGTH_SHORT).show()
}
}
}else{
withContext(Dispatchers.Main) {
Toast.makeText(context, "生成失败", Toast.LENGTH_SHORT).show()
}
}
}
}
}
}
binding.shareLinkButton.setOnClickListener {
showDiag(firstLink)
}
}
fun showDiag(code:String){
val message = "目前为止我用过最好的梯子,播放Youtube、Netflix高清视频从未如此轻松。\n\n" +
"下载链接(推荐使用Chrome浏览器访问):${PreferenceManager.getConfigFromPreferences(context)?.websiteURL}\n\n" +
"安装后打开填写我的邀请码:${code} 你能多得3天会员!\n"
context.showCustomDialog(
title = "推荐文案",
message = message ,
positiveButtonText = "立即分享",
negativeButtonText = "复制推荐文案",
onPositiveClick = {
launch {
// 创建分享意图
val shareIntent = Intent().apply {
action = Intent.ACTION_SEND
putExtra(Intent.EXTRA_TEXT, message) // 分享的文字内容
type = "text/plain"
}
// 启动分享选择器
context.startActivity(Intent.createChooser(shareIntent, "分享给朋友"))
}
},
onNegativeClick = {
// 清理缓存,清除节点信息,切换界面
launch {
copytoSysString(message)
}
}
)
}
fun request(request: Request) {
}
fun formatTimestamp(timestamp: Long): String {
val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) // 定义格式
val date = Date(timestamp) // 将时间戳转为 Date 对象
return dateFormat.format(date) // 格式化为字符串
}
private suspend fun copytoSys(code: String ){
withContext(Dispatchers.Main) {
showDiag(code)
}
// var cm: ClipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
// var str:ClipData= ClipData.newPlainText("Label",PreferenceManager.getConfigFromPreferences(context)?.mainregisterURL+code)
//
// cm.setPrimaryClip(str)
// showToast("复制成功到剪切板",ToastDuration.Long)
}
private suspend fun copytoSysString(code: String ){
var cm: ClipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
var str:ClipData= ClipData.newPlainText("Label",code)
cm.setPrimaryClip(str)
showToast("复制成功到剪切板",ToastDuration.Long)
}
fun requestdataDisible(){
LoadingDialog.show(context, "正在获取中...")
CoroutineScope(Dispatchers.IO).launch {
val apiService = ApiClient.retrofit.create(ApiService::class.java)
safeApiRequestCall { apiService.getinviteList(PreferenceManager.loginauthData)}.let {
withContext(Dispatchers.Main) {
LoadingDialog.hide()
}
if (it != null && it.isSuccessful){
if (it.body()?.data?.stat != null){
val listdata = it.body()?.data?.stat
var index = 0
listdata?.forEach { item ->
withContext(Dispatchers.Main) {
if (index == 0) {
binding.inviteZhucenum = "${item}"
} else if (index == 1) {
binding.inviteQuerenzhong = "¥${item}.00"
} else if (index == 2) {
binding.inviteZhucenumleiji = "¥${item}.00"
} else if (index == 3) {
binding.inviteBili = "${item}%"
}
}
index ++
}
}
val codes = it.body()?.data?.codes
withContext(Dispatchers.Main) {
binding.container.removeAllViews()
var index = 0
codes?.forEach { item ->
if (index == 0){
firstLink = item.code ?: ""
}
val frameLayout = ActivityInviteItemBinding.inflate(
context.layoutInflater,
context.root,
false
)
frameLayout.nameTextView.text = item.code
// frameLayout.timeTextView.text = "${formatTimestamp((item.created_at ?: 0) * 1000L)}"
frameLayout.planitemFrameLayout.setOnClickListener {
println("click ${item}")
GlobalScope.launch {
this@InvitationDesign.copytoSys(item.code ?: "")
}
}
frameLayout.copyInvitecodeButton.setOnClickListener {
GlobalScope.launch {
this@InvitationDesign.copytoSys(item.code ?: "")
}
}
binding.container.addView(frameLayout.root)
index ++
}
}
}
}
}
}
}
@@ -0,0 +1,70 @@
package com.github.kr328.clash.design
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.view.View
import androidx.core.content.getSystemService
import androidx.recyclerview.widget.LinearLayoutManager
import com.github.kr328.clash.core.model.LogMessage
import com.github.kr328.clash.design.adapter.LogMessageAdapter
import com.github.kr328.clash.design.databinding.DesignLogcatBinding
import com.github.kr328.clash.design.ui.ToastDuration
import com.github.kr328.clash.design.util.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class LogcatDesign(
context: Context,
private val streaming: Boolean,
) : Design<LogcatDesign.Request>(context) {
enum class Request {
Close, Delete, Export
}
private val binding = DesignLogcatBinding
.inflate(context.layoutInflater, context.root, false)
private val adapter = LogMessageAdapter(context) {
launch {
val data = ClipData.newPlainText("log_message", it.message)
context.getSystemService<ClipboardManager>()?.setPrimaryClip(data)
showToast(R.string.copied, ToastDuration.Short)
}
}
suspend fun patchMessages(messages: List<LogMessage>, removed: Int, appended: Int) {
withContext(Dispatchers.Main) {
adapter.messages = messages
adapter.notifyItemRangeInserted(adapter.messages.size, appended)
adapter.notifyItemRangeRemoved(0, removed)
if (streaming && binding.recyclerList.isTop) {
binding.recyclerList.scrollToPosition(messages.size - 1)
}
}
}
override val root: View
get() = binding.root
init {
binding.self = this
binding.streaming = streaming
binding.activityBarLayout.applyFrom(context)
binding.recyclerList.bindAppBarElevation(binding.activityBarLayout)
binding.recyclerList.layoutManager = LinearLayoutManager(context).apply {
if (streaming) {
reverseLayout = true
stackFromEnd = true
}
}
binding.recyclerList.adapter = adapter
}
}
@@ -0,0 +1,57 @@
package com.github.kr328.clash.design
import android.content.Context
import android.view.View
import com.github.kr328.clash.design.adapter.LogFileAdapter
import com.github.kr328.clash.design.databinding.DesignLogsBinding
import com.github.kr328.clash.design.model.LogFile
import com.github.kr328.clash.design.util.*
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import kotlin.coroutines.resume
class LogsDesign(context: Context) : Design<LogsDesign.Request>(context) {
sealed class Request {
object StartLogcat : Request()
object DeleteAll : Request()
data class OpenFile(val file: LogFile) : Request()
}
private val binding = DesignLogsBinding
.inflate(context.layoutInflater, context.root, false)
private val adapter = LogFileAdapter(context) {
requests.trySend(Request.OpenFile(it))
}
override val root: View
get() = binding.root
suspend fun patchLogs(logs: List<LogFile>) {
adapter.patchDataSet(adapter::logs, logs, false, LogFile::fileName)
}
suspend fun requestDeleteAll(): Boolean {
return withContext(Dispatchers.Main) {
suspendCancellableCoroutine { ctx ->
MaterialAlertDialogBuilder(context)
.setTitle(R.string.delete_all_logs)
.setMessage(R.string.delete_all_logs_warn)
.setPositiveButton(R.string.ok) { _, _ -> ctx.resume(true) }
.setNegativeButton(R.string.cancel) { _, _ -> }
.show()
.setOnDismissListener { if (!ctx.isCompleted) ctx.resume(false) }
}
}
}
init {
binding.self = this
binding.activityBarLayout.applyFrom(context)
binding.recyclerList.applyLinearAdapter(context, adapter)
}
}
@@ -0,0 +1,382 @@
package com.github.kr328.clash.design
import android.content.Context
import android.content.res.ColorStateList
import android.graphics.Color
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View
import android.widget.TextView
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.widget.PopupMenu
import androidx.core.content.ContextCompat
import com.airbnb.lottie.LottieAnimationView
import com.github.kr328.clash.common.util.ticker
import com.github.kr328.clash.core.Clash
import com.github.kr328.clash.core.model.TunnelState
import com.github.kr328.clash.core.util.trafficDownload
import com.github.kr328.clash.core.util.trafficTotal
import com.github.kr328.clash.core.util.trafficUpload
import com.github.kr328.clash.design.adapter.ImageSliderImagesAdapter
import com.github.kr328.clash.design.component.ProxyMenu
import com.github.kr328.clash.design.component.ProxyViewConfig
import com.github.kr328.clash.design.databinding.DesignAboutBinding
import com.github.kr328.clash.design.databinding.DesignMainBinding
import com.github.kr328.clash.design.preference.selectableList
import com.github.kr328.clash.design.util.layoutInflater
import com.github.kr328.clash.design.util.resolveThemedColor
import com.github.kr328.clash.design.util.root
import com.github.kr328.clash.service.model.AccessControlMode
import com.google.android.material.button.MaterialButton
import com.google.android.material.tabs.TabLayoutMediator
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import java.util.concurrent.TimeUnit
import com.github.kr328.clash.design.preference.*
import com.github.kr328.clash.design.ui.ToastDuration
import com.github.kr328.clash.service.store.ServiceStore
import com.github.kr328.clash.utity.LoadingDialog
import com.google.android.material.bottomsheet.BottomSheetDialog
import kotlinx.coroutines.launch
class MainDesign(context: Context) : Design<MainDesign.Request>(context) ,PopupMenu.OnMenuItemClickListener {
enum class Request {
ToggleStatus,
OpenProxy,
OpenProfiles,
OpenProviders,
OpenLogs,
OpenSettings,
OpenHelp,
OpenAbout,
OpenSettingsDIY,
OpenSettingsKEFU,
OpenModeDirect,
OpenChangeMode,
OpenModeGlobal,
OpenModeRule,
OpenModeMenu,
OpenModeSheet
}
private var menu:PopupMenu? = null
private val binding = DesignMainBinding
.inflate(context.layoutInflater, context.root, false)
override val root: View
get() = binding.root
suspend fun setProfileName(name: String?) {
withContext(Dispatchers.Main) {
binding.profileName = name
}
}
suspend fun setSelectNodeName(name: String?) {
withContext(Dispatchers.Main) {
binding.selectnodeName = name
}
}
suspend fun setClashRunning(running: Boolean) {
withContext(Dispatchers.Main) {
binding.clashRunning = running
if (running){
binding.connectionButton.setAnimation("51a05581.json")
binding.serverSelection.visibility = View.VISIBLE
// withContext(Dispatchers.Main){
// LoadingDialog.hide()
// }
binding.trafficStats.visibility = View.VISIBLE
// binding.modeImg1.visibility = View.VISIBLE
// binding.modeImg2.visibility = View.VISIBLE
// binding.menuView.visibility = View.VISIBLE
binding.connectionButtonText.setText("点击断开")
binding.connectionButton.loop(false)
binding.connectionButtonText.setTextColor(ContextCompat.getColor(context, R.color.white))
}else{
binding.trafficStats.visibility = View.GONE
binding.connectionButton.setAnimation("1d2a0fe5.json")
binding.connectionButton.loop(true)
binding.serverSelection.visibility = View.GONE
binding.connectionButtonText.setText("点击连接")
binding.connectionButtonText.setTextColor(ContextCompat.getColor(context,R.color.black))
// binding.modeImg1.visibility = View.GONE
// binding.modeImg2.visibility = View.GONE
// binding.menuView.visibility = View.GONE
}
}
}
suspend fun setUploadedSeep(value: Long){
withContext(Dispatchers.Main) {
binding.uploadedseed = value.trafficUpload()
}
}
suspend fun setDownloadedSeep(value: Long){
withContext(Dispatchers.Main) {
binding.downloadseed = value.trafficDownload()
}
}
suspend fun setForwarded(value: Long) {
withContext(Dispatchers.Main) {
binding.forwarded = value.trafficTotal()
}
}
suspend fun setMode(mode: TunnelState.Mode) {
withContext(Dispatchers.Main) {
binding.mode = when (mode) {
TunnelState.Mode.Direct -> context.getString(R.string.direct_mode)
TunnelState.Mode.Global -> context.getString(R.string.global_mode)
TunnelState.Mode.Rule -> context.getString(R.string.rule_mode)
else -> context.getString(R.string.rule_mode)
}
when (mode) {
// TunnelState.Mode.Direct -> {
// binding.modeSelectionbutton1.backgroundTintList = ColorStateList.valueOf(Color.parseColor("#2b9a45"))
// }
// TunnelState.Mode.Global -> binding.modeSelectionbutton2.backgroundTintList = ColorStateList.valueOf(Color.parseColor("#2b9a45"))
// TunnelState.Mode.Rule -> binding.modeSelectionbutton3.backgroundTintList = ColorStateList.valueOf(Color.parseColor("#2b9a45"))
// else -> binding.modeSelectionbutton3.backgroundTintList = ColorStateList.valueOf(Color.parseColor("#2b9a45"))
TunnelState.Mode.Direct -> binding.selectnodeName = context.getString(R.string.direct_mode)
TunnelState.Mode.Global -> binding.selectnodeName = context.getString(R.string.global_mode)
TunnelState.Mode.Rule -> binding.selectnodeName = context.getString(R.string.rule_mode)
else -> binding.selectnodeName = context.getString(R.string.direct_mode)
}
}
}
suspend fun setHasProviders(has: Boolean) {
withContext(Dispatchers.Main) {
binding.hasProviders = has
}
}
suspend fun showAbout(versionName: String) {
withContext(Dispatchers.Main) {
val binding = DesignAboutBinding.inflate(context.layoutInflater).apply {
this.versionName = versionName
}
AlertDialog.Builder(context)
.setView(binding.root)
.show()
}
}
init {
binding.self = this
binding.colorClashStarted = context.resolveThemedColor(R.attr.colorPrimary)
binding.colorClashStopped = context.resolveThemedColor(R.attr.colorClashStopped)
// menu = PopupMenu(context, binding.menuView)
// menu?.menuInflater?.inflate(R.menu.activity_menu_mode, menu?.menu)
// menu?.setOnMenuItemClickListener(this)
}
override fun onMenuItemClick(item: MenuItem): Boolean {
item.isChecked = !item.isChecked
when (item.itemId) {
R.id.global_mode -> {
requests.trySend(Request.OpenModeGlobal)
}
R.id.rule_mode -> {
requests.trySend(Request.OpenModeRule)
}
else -> return false
}
return true
}
fun request(request: Request) {
requests.trySend(request)
}
suspend fun startAutoScroll(viewPager2: androidx.viewpager2.widget.ViewPager2,nextItem: Int){
withContext(Dispatchers.Main) {
viewPager2.setCurrentItem(nextItem, true)
}
}
suspend fun OpenModeSheet(){
// 创建 BottomSheetDialog
val bottomSheetDialog = BottomSheetDialog(context)
// 加载自定义的底部布局
val view = LayoutInflater.from(context).inflate(R.layout.bottom_sheet_layout, null)
bottomSheetDialog.setContentView(view)
view.findViewById<TextView>(R.id.mode_rule).setOnClickListener {
// 处理点击事件
bottomSheetDialog.dismiss()
requests.trySend(Request.OpenModeRule)
}
view.findViewById<TextView>(R.id.mode_global).setOnClickListener {
// 处理点击事件
bottomSheetDialog.dismiss()
requests.trySend(Request.OpenModeGlobal)
}
// 显示 BottomSheetDialog
bottomSheetDialog.show()
}
suspend fun OpenModeMenu(){
menu?.show()
}
suspend fun startBannsers() {
withContext(Dispatchers.Main) {
//sart ad bannsers
val viewPager2 = binding.adBanner
val tabLayout = binding.tabLayout
// 创建图片资源列表
/* val imageList = listOf(
R.drawable.ad_banner,
R.drawable.ad_banner2,
R.drawable.ad_banner3
)*/
val imageList = PreferenceManager.getConfigFromPreferences(context)?.banners
//binding.menuView.text = PreferenceManager.modeName
// 设置适配器
//val adapter = ImageSliderAdapter(imageList)
if (imageList != null){
viewPager2.adapter = ImageSliderImagesAdapter(imageList){ position ->
// 点击图片时的处理逻辑
}
// 设置 TabLayoutMediator 来同步 ViewPager2 和 TabLayout
TabLayoutMediator(tabLayout, viewPager2) { tab, position ->
// Tab 的自定义逻辑可以放在这里
}.attach()
}
val uncheckedColor = ColorStateList(
arrayOf(intArrayOf(-android.R.attr.state_checked)), // 未选中状态
intArrayOf(Color.parseColor("#40383838")) // 未选中颜色
)
val checkedColor = ColorStateList(
arrayOf(intArrayOf(android.R.attr.state_checked)), // 选中状态
intArrayOf(Color.parseColor("#2b9a45")) // 选中颜色
)
/*
val screen = preferenceScreen(context) {
val vpnDependencies: MutableList<Preference> = mutableListOf()
val srvStore = ServiceStore(context)
val selectMode = selectableList(
value = srvStore::accessControlMode,
values = AccessControlMode.values(),
valuesText = arrayOf(
R.string.direct_mode,
R.string.rule_mode,
R.string.global_mode
),
title = R.string.rule_mode,
configure = vpnDependencies::add,
)
}
binding.linearLayoutMode.addView(screen.root)
*/
binding.modeSelection.addOnButtonCheckedListener { group, checkedId, isChecked ->
//
// if(button.id == checkedId){
// button.backgroundTintList = checkedColor // 选中状态颜色
// }else{
// button.backgroundTintList = uncheckedColor // 未选中状态颜色
// }
/*
for (buttonId in listOf(binding.modeSelectionbutton1,binding.modeSelectionbutton2,binding.modeSelectionbutton3)) {
if (buttonId.id != checkedId) {
// binding.modeSelection.uncheck(buttonId.id)
buttonId.backgroundTintList = uncheckedColor
}
}
val button = group.findViewById<MaterialButton>(checkedId)
button.backgroundTintList = checkedColor // 选中状态颜色
*/
// binding.modeSelectionbutton1.invalidate()
// binding.modeSelectionbutton2.invalidate()
// binding.modeSelectionbutton3.invalidate()
// Reset all buttons to default background tint
// binding.modeSelectionbutton1.backgroundTintList = ColorStateList.valueOf(Color.parseColor("#40383838"))
// binding.modeSelectionbutton2.backgroundTintList = ColorStateList.valueOf(Color.parseColor("#40383838"))
// binding.modeSelectionbutton3.backgroundTintList = ColorStateList.valueOf(Color.parseColor("#40383838"))
//
when (checkedId) {
R.id.modeSelectionbutton1 -> {
request(Request.OpenModeDirect)
binding.modeSelectionbutton1.backgroundTintList = checkedColor // 选中状态颜色
// binding.modeSelectionbutton1.backgroundTintList = ColorStateList.valueOf(Color.parseColor("#2b9a45"))
}
R.id.modeSelectionbutton2 -> {
request(Request.OpenModeGlobal)
binding.modeSelectionbutton2.backgroundTintList = checkedColor // 选中状态颜色
//binding.modeSelectionbutton2.backgroundTintList = ColorStateList.valueOf(Color.parseColor("#2b9a45"))
}
R.id.modeSelectionbutton3 -> {
request(Request.OpenModeRule)
binding.modeSelectionbutton3.backgroundTintList = checkedColor // 选中状态颜色
// binding.modeSelectionbutton3.backgroundTintList = ColorStateList.valueOf(Color.parseColor("#2b9a45"))
}
}
}
}
}
}
@@ -0,0 +1,275 @@
package com.github.kr328.clash.design
import android.content.Context
import android.view.View
import com.github.kr328.clash.core.model.ConfigurationOverride
import com.github.kr328.clash.design.databinding.DesignSettingsMetaFeatureBinding
import com.github.kr328.clash.design.preference.*
import com.github.kr328.clash.design.util.*
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
class MetaFeatureSettingsDesign(
context: Context,
configuration: ConfigurationOverride
) : Design<MetaFeatureSettingsDesign.Request>(context) {
enum class Request {
ResetOverride, ImportGeoIp, ImportGeoSite, ImportCountry, ImportASN
}
private val binding = DesignSettingsMetaFeatureBinding
.inflate(context.layoutInflater, context.root, false)
override val root: View
get() = binding.root
suspend fun requestResetConfirm(): Boolean {
return suspendCancellableCoroutine { ctx ->
val dialog = MaterialAlertDialogBuilder(context)
.setTitle(R.string.reset_override_settings)
.setMessage(R.string.reset_override_settings_message)
.setPositiveButton(R.string.ok) { _, _ -> ctx.resume(true) }
.setNegativeButton(R.string.cancel) { _, _ -> }
.show()
dialog.setOnDismissListener {
if (!ctx.isCompleted)
ctx.resume(false)
}
ctx.invokeOnCancellation {
dialog.dismiss()
}
}
}
init {
binding.self = this
binding.activityBarLayout.applyFrom(context)
binding.scrollRoot.bindAppBarElevation(binding.activityBarLayout)
val booleanValues: Array<Boolean?> = arrayOf(
null,
true,
false
)
val booleanValuesText: Array<Int> = arrayOf(
R.string.dont_modify,
R.string.enabled,
R.string.disabled
)
val screen = preferenceScreen(context) {
category(R.string.settings)
selectableList(
value = configuration::unifiedDelay,
values = booleanValues,
valuesText = booleanValuesText,
title = R.string.unified_delay,
)
selectableList(
value = configuration::geodataMode,
values = booleanValues,
valuesText = booleanValuesText,
title = R.string.geodata_mode,
)
selectableList(
value = configuration::tcpConcurrent,
values = booleanValues,
valuesText = booleanValuesText,
title = R.string.tcp_concurrent,
)
selectableList(
value = configuration::findProcessMode,
values = arrayOf(
null,
ConfigurationOverride.FindProcessMode.Off,
ConfigurationOverride.FindProcessMode.Strict,
ConfigurationOverride.FindProcessMode.Always
),
valuesText = arrayOf(
R.string.dont_modify,
R.string.off,
R.string.strict,
R.string.always,
),
title = R.string.find_process_mode,
) {
}
category(R.string.sniffer_setting)
val snifferDependencies: MutableList<Preference> = mutableListOf()
val sniffer = selectableList(
value = configuration.sniffer::enable,
values = arrayOf(
null,
true,
false
),
valuesText = arrayOf(
R.string.dont_modify,
R.string.enabled,
R.string.disabled
),
title = R.string.strategy
) {
listener = OnChangedListener {
if (configuration.sniffer.enable == false) {
snifferDependencies.forEach {
it.enabled = false
}
} else {
snifferDependencies.forEach {
it.enabled = true
}
}
}
}
editableTextList(
value = configuration.sniffer::sniffing,
adapter = TextAdapter.String,
title = R.string.sniffing,
placeholder = R.string.dont_modify,
configure = snifferDependencies::add,
)
selectableList(
value = configuration.sniffer::forceDnsMapping,
values = booleanValues,
valuesText = booleanValuesText,
title = R.string.force_dns_mapping,
configure = snifferDependencies::add,
)
selectableList(
value = configuration.sniffer::parsePureIp,
values = booleanValues,
valuesText = booleanValuesText,
title = R.string.parse_pure_ip,
configure = snifferDependencies::add,
)
selectableList(
value = configuration.sniffer::overrideDestination,
values = booleanValues,
valuesText = booleanValuesText,
title = R.string.override_destination,
configure = snifferDependencies::add,
)
editableTextList(
value = configuration.sniffer::forceDomain,
adapter = TextAdapter.String,
title = R.string.force_domain,
placeholder = R.string.dont_modify,
configure = snifferDependencies::add,
)
editableTextList(
value = configuration.sniffer::skipDomain,
adapter = TextAdapter.String,
title = R.string.skip_domain,
placeholder = R.string.dont_modify,
configure = snifferDependencies::add,
)
editableTextList(
value = configuration.sniffer::portWhitelist,
adapter = TextAdapter.String,
title = R.string.port_whitelist,
placeholder = R.string.dont_modify,
configure = snifferDependencies::add,
)
sniffer.listener?.onChanged()
/*
category(R.string.geox_url_setting)
val geoxUrlDependencies: MutableList<Preference> = mutableListOf()
editableText(
value = configuration.geoxurl::geoip,
adapter = NullableTextAdapter.String,
title = R.string.geox_geoip,
placeholder = R.string.dont_modify,
empty = R.string.geoip_url,
configure = geoxUrlDependencies::add,
)
editableText(
value = configuration.geoxurl::mmdb,
adapter = NullableTextAdapter.String,
title = R.string.geox_mmdb,
placeholder = R.string.dont_modify,
empty = R.string.mmdb_url,
configure = geoxUrlDependencies::add,
)
editableText(
value = configuration.geoxurl::geosite,
adapter = NullableTextAdapter.String,
title = R.string.geox_geosite,
placeholder = R.string.dont_modify,
empty = R.string.geosite_url,
configure = geoxUrlDependencies::add,
)
*/
category(R.string.geox_files)
clickable (
title = R.string.import_geoip_file,
summary = R.string.press_to_import,
){
clicked {
requests.trySend(Request.ImportGeoIp)
}
}
clickable (
title = R.string.import_geosite_file,
summary = R.string.press_to_import,
){
clicked {
requests.trySend(Request.ImportGeoSite)
}
}
clickable (
title = R.string.import_country_file,
summary = R.string.press_to_import,
){
clicked {
requests.trySend(Request.ImportCountry)
}
}
clickable (
title = R.string.import_asn_file,
summary = R.string.press_to_import,
){
clicked {
requests.trySend(Request.ImportASN)
}
}
}
binding.content.addView(screen.root)
}
fun requestClear() {
requests.trySend(Request.ResetOverride)
}
}
@@ -0,0 +1,213 @@
package com.github.kr328.clash.design
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.view.View
import android.widget.LinearLayout
import com.github.kr328.clash.common.util.componentName
import com.github.kr328.clash.design.databinding.DesignSettingsCommonBinding
import com.github.kr328.clash.design.model.Behavior
import com.github.kr328.clash.design.preference.*
import com.github.kr328.clash.design.store.UiStore
import com.github.kr328.clash.design.ui.ToastDuration
import com.github.kr328.clash.design.util.applyFrom
import com.github.kr328.clash.design.util.bindAppBarElevation
import com.github.kr328.clash.design.util.layoutInflater
import com.github.kr328.clash.design.util.root
import com.github.kr328.clash.design.view.drividerline
import com.github.kr328.clash.service.model.AccessControlMode
import com.github.kr328.clash.service.store.ServiceStore
import kotlinx.coroutines.launch
class NetworkSettingsDesign(
context: Context,
uiStore: UiStore,
behavior: Behavior,
srvStore: ServiceStore,
running: Boolean,
) : Design<NetworkSettingsDesign.Request>(context) {
enum class Request {
StartAccessControlList
}
private val binding = DesignSettingsCommonBinding
.inflate(context.layoutInflater, context.root, false)
override val root: View
get() = binding.root
init {
binding.surface = surface
binding.activityBarLayout.applyFrom(context)
binding.scrollRoot.bindAppBarElevation(binding.activityBarLayout)
val screen = preferenceScreen(context) {
val vpnDependencies: MutableList<Preference> = mutableListOf()
category(R.string.default_)
// switch(
// value = behavior::autoRestart,
// icon = R.drawable.ic_baseline_restore,
// title = R.string.auto_restart,
// summary = R.string.allow_clash_auto_restart,
// )
//
//
// drividerline()
//
switch(
value = srvStore::dynamicNotification,
icon = R.drawable.ic_baseline_domain,
title = R.string.show_traffic,
summary = R.string.show_traffic_summary
) {
// enabled = !running
}
drividerline()
selectableList(
value = srvStore::accessControlMode,
values = AccessControlMode.values(),
valuesText = arrayOf(
R.string.allow_all_apps,
R.string.allow_selected_apps,
R.string.deny_selected_apps
),
title = R.string.access_control_mode,
configure = vpnDependencies::add,
)
drividerline()
clickable(
title = R.string.access_control_packages,
summary = R.string.access_control_packages_summary,
) {
clicked {
requests.trySend(Request.StartAccessControlList)
}
vpnDependencies.add(this)
}
drividerline()
category(R.string.vpn_service_options)
val vpn = switch(
value = uiStore::enableVpn,
icon = R.drawable.ic_baseline_vpn_lock,
title = R.string.route_system_traffic,
summary = R.string.routing_via_vpn_service
) {
listener = OnChangedListener {
vpnDependencies.forEach {
it.enabled = uiStore.enableVpn
}
}
}
drividerline()
switch(
value = srvStore::bypassPrivateNetwork,
title = R.string.bypass_private_network,
summary = R.string.bypass_private_network_summary,
configure = vpnDependencies::add,
)
drividerline()
switch(
value = srvStore::dnsHijacking,
title = R.string.dns_hijacking,
summary = R.string.dns_hijacking_summary,
configure = vpnDependencies::add,
)
drividerline()
switch(
value = srvStore::allowBypass,
title = R.string.allow_bypass,
summary = R.string.allow_bypass_summary,
configure = vpnDependencies::add,
)
drividerline()
switch(
value = srvStore::allowIpv6,
title = R.string.allow_ipv6,
summary = R.string.allow_ipv6_summary,
configure = vpnDependencies::add,
)
drividerline()
if (Build.VERSION.SDK_INT >= 29) {
switch(
value = srvStore::systemProxy,
title = R.string.system_proxy,
summary = R.string.system_proxy_summary,
configure = vpnDependencies::add,
)
}
drividerline()
selectableList(
value = srvStore::tunStackMode,
values = arrayOf(
"system",
"gvisor",
"mixed"
),
valuesText = arrayOf(
R.string.tun_stack_system,
R.string.tun_stack_gvisor,
R.string.tun_stack_mixed
),
title = R.string.tun_stack_mode,
configure = vpnDependencies::add,
)
drividerline()
/*
if (running) {
vpn.enabled = false
vpnDependencies.forEach {
it.enabled = false
}
} else {
vpn.listener?.onChanged()
}*/
}
binding.content.addView(screen.root)
// if (running) {
// launch {
// showToast(R.string.options_unavailable, ToastDuration.Indefinite)
// }
// }
}
}
@@ -0,0 +1,51 @@
package com.github.kr328.clash.design
import android.content.Context
import android.view.View
import com.github.kr328.clash.design.adapter.ProfileProviderAdapter
import com.github.kr328.clash.design.databinding.DesignNewProfileBinding
import com.github.kr328.clash.design.model.ProfileProvider
import com.github.kr328.clash.design.util.*
class NewProfileDesign(context: Context) : Design<NewProfileDesign.Request>(context) {
sealed class Request {
data class Create(val provider: ProfileProvider) : Request()
data class OpenDetail(val provider: ProfileProvider.External) : Request()
}
private val binding = DesignNewProfileBinding
.inflate(context.layoutInflater, context.root, false)
private val adapter = ProfileProviderAdapter(context, this::requestCreate, this::requestDetail)
override val root: View
get() = binding.root
suspend fun patchProviders(providers: List<ProfileProvider>) {
adapter.apply {
patchDataSet(this::providers, providers)
}
}
init {
binding.self = this
binding.activityBarLayout.applyFrom(context)
binding.mainList.recyclerList.also {
it.bindAppBarElevation(binding.activityBarLayout)
it.applyLinearAdapter(context, adapter)
}
}
private fun requestCreate(provider: ProfileProvider) {
requests.trySend(Request.Create(provider))
}
private fun requestDetail(provider: ProfileProvider): Boolean {
if (provider !is ProfileProvider.External) return false
requests.trySend(Request.OpenDetail(provider))
return true
}
}
@@ -0,0 +1,417 @@
package com.github.kr328.clash.design
import android.content.Context
import android.view.View
import com.github.kr328.clash.core.model.ConfigurationOverride
import com.github.kr328.clash.core.model.LogMessage
import com.github.kr328.clash.core.model.TunnelState
import com.github.kr328.clash.design.databinding.DesignSettingsOverideBinding
import com.github.kr328.clash.design.databinding.DialogPreferenceListBinding
import com.github.kr328.clash.design.dialog.FullScreenDialog
import com.github.kr328.clash.design.model.AppInfo
import com.github.kr328.clash.design.preference.*
import com.github.kr328.clash.design.util.*
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import kotlin.coroutines.resume
class OverrideSettingsDesign(
context: Context,
configuration: ConfigurationOverride
) : Design<OverrideSettingsDesign.Request>(context) {
enum class Request {
ResetOverride
}
private val binding = DesignSettingsOverideBinding
.inflate(context.layoutInflater, context.root, false)
override val root: View
get() = binding.root
suspend fun requestResetConfirm(): Boolean {
return suspendCancellableCoroutine { ctx ->
val dialog = MaterialAlertDialogBuilder(context)
.setTitle(R.string.reset_override_settings)
.setMessage(R.string.reset_override_settings_message)
.setPositiveButton(R.string.ok) { _, _ -> ctx.resume(true) }
.setNegativeButton(R.string.cancel) { _, _ -> }
.show()
dialog.setOnDismissListener {
if (!ctx.isCompleted)
ctx.resume(false)
}
ctx.invokeOnCancellation {
dialog.dismiss()
}
}
}
init {
binding.self = this
binding.activityBarLayout.applyFrom(context)
binding.scrollRoot.bindAppBarElevation(binding.activityBarLayout)
val booleanValues: Array<Boolean?> = arrayOf(
null,
true,
false
)
val booleanValuesText: Array<Int> = arrayOf(
R.string.dont_modify,
R.string.enabled,
R.string.disabled
)
val screen = preferenceScreen(context) {
category(R.string.general)
editableText(
value = configuration::httpPort,
adapter = NullableTextAdapter.Port,
title = R.string.http_port,
placeholder = R.string.dont_modify,
empty = R.string.disabled,
)
editableText(
value = configuration::socksPort,
adapter = NullableTextAdapter.Port,
title = R.string.socks_port,
placeholder = R.string.dont_modify,
empty = R.string.disabled,
)
editableText(
value = configuration::redirectPort,
adapter = NullableTextAdapter.Port,
title = R.string.redirect_port,
placeholder = R.string.dont_modify,
empty = R.string.disabled,
)
editableText(
value = configuration::tproxyPort,
adapter = NullableTextAdapter.Port,
title = R.string.tproxy_port,
placeholder = R.string.dont_modify,
empty = R.string.disabled,
)
editableText(
value = configuration::mixedPort,
adapter = NullableTextAdapter.Port,
title = R.string.mixed_port,
placeholder = R.string.dont_modify,
empty = R.string.disabled,
)
editableTextList(
value = configuration::authentication,
adapter = TextAdapter.String,
title = R.string.authentication,
placeholder = R.string.dont_modify,
)
selectableList(
value = configuration::allowLan,
values = booleanValues,
valuesText = booleanValuesText,
title = R.string.allow_lan,
)
selectableList(
value = configuration::ipv6,
values = booleanValues,
valuesText = booleanValuesText,
title = R.string.ipv6,
)
editableText(
value = configuration::bindAddress,
adapter = NullableTextAdapter.String,
title = R.string.bind_address,
placeholder = R.string.dont_modify,
empty = R.string.default_
)
editableText(
value = configuration::externalController,
adapter = NullableTextAdapter.String,
title = R.string.external_controller,
placeholder = R.string.dont_modify,
empty = R.string.default_
)
editableText(
value = configuration::externalControllerTLS,
adapter = NullableTextAdapter.String,
title = R.string.external_controller_tls,
placeholder = R.string.dont_modify,
empty = R.string.default_
)
editableTextList(
value = configuration.externalControllerCors::allowOrigins,
adapter = TextAdapter.String,
title = R.string.allow_origins,
placeholder = R.string.dont_modify,
)
selectableList(
value = configuration.externalControllerCors::allowPrivateNetwork,
values = booleanValues,
valuesText = booleanValuesText,
title = R.string.allow_private_network,
)
editableText(
value = configuration::secret,
adapter = NullableTextAdapter.String,
title = R.string.secret,
placeholder = R.string.dont_modify,
empty = R.string.default_
)
selectableList(
value = configuration::mode,
values = arrayOf(
null,
TunnelState.Mode.Direct,
TunnelState.Mode.Global,
TunnelState.Mode.Rule
),
valuesText = arrayOf(
R.string.dont_modify,
R.string.direct_mode,
R.string.global_mode,
R.string.rule_mode
),
title = R.string.mode
)
selectableList(
value = configuration::logLevel,
values = arrayOf(
null,
LogMessage.Level.Info,
LogMessage.Level.Warning,
LogMessage.Level.Error,
LogMessage.Level.Debug,
LogMessage.Level.Silent,
),
valuesText = arrayOf(
R.string.dont_modify,
R.string.info,
R.string.warning,
R.string.error,
R.string.debug,
R.string.silent,
),
title = R.string.log_level,
)
editableTextMap(
value = configuration::hosts,
keyAdapter = TextAdapter.String,
valueAdapter = TextAdapter.String,
title = R.string.hosts,
placeholder = R.string.dont_modify,
)
category(R.string.dns)
val dnsDependencies: MutableList<Preference> = mutableListOf()
val dns = selectableList(
value = configuration.dns::enable,
values = arrayOf(
null,
true,
false
),
valuesText = arrayOf(
R.string.dont_modify,
R.string.force_enable,
R.string.use_built_in,
),
title = R.string.strategy
) {
listener = OnChangedListener {
if (configuration.dns.enable == false) {
dnsDependencies.forEach {
it.enabled = false
}
} else {
dnsDependencies.forEach {
it.enabled = true
}
}
}
}
selectableList(
value = configuration.dns::preferH3,
values = booleanValues,
valuesText = booleanValuesText,
title = R.string.prefer_h3,
configure = dnsDependencies::add,
)
editableText(
value = configuration.dns::listen,
adapter = NullableTextAdapter.String,
title = R.string.listen,
placeholder = R.string.dont_modify,
empty = R.string.disabled,
configure = dnsDependencies::add,
)
selectableList(
value = configuration.app::appendSystemDns,
values = booleanValues,
valuesText = booleanValuesText,
title = R.string.append_system_dns,
configure = dnsDependencies::add,
)
selectableList(
value = configuration.dns::ipv6,
values = booleanValues,
valuesText = booleanValuesText,
title = R.string.ipv6,
configure = dnsDependencies::add,
)
selectableList(
value = configuration.dns::useHosts,
values = booleanValues,
valuesText = booleanValuesText,
title = R.string.use_hosts,
configure = dnsDependencies::add,
)
selectableList(
value = configuration.dns::enhancedMode,
values = arrayOf(
null,
ConfigurationOverride.DnsEnhancedMode.None,
ConfigurationOverride.DnsEnhancedMode.FakeIp,
ConfigurationOverride.DnsEnhancedMode.Mapping
),
valuesText = arrayOf(
R.string.dont_modify,
R.string.disabled,
R.string.fakeip,
R.string.mapping
),
title = R.string.enhanced_mode,
configure = dnsDependencies::add,
)
editableTextList(
value = configuration.dns::nameServer,
adapter = TextAdapter.String,
title = R.string.name_server,
placeholder = R.string.dont_modify,
configure = dnsDependencies::add,
)
editableTextList(
value = configuration.dns::fallback,
adapter = TextAdapter.String,
title = R.string.fallback,
placeholder = R.string.dont_modify,
configure = dnsDependencies::add,
)
editableTextList(
value = configuration.dns::defaultServer,
adapter = TextAdapter.String,
title = R.string.default_name_server,
placeholder = R.string.dont_modify,
configure = dnsDependencies::add,
)
editableTextList(
value = configuration.dns::fakeIpFilter,
adapter = TextAdapter.String,
title = R.string.fakeip_filter,
placeholder = R.string.dont_modify,
configure = dnsDependencies::add,
)
selectableList(
value = configuration.dns::fakeIPFilterMode,
values = arrayOf(
null,
ConfigurationOverride.FilterMode.BlackList,
ConfigurationOverride.FilterMode.WhiteList
),
valuesText = arrayOf(
R.string.dont_modify,
R.string.blacklist,
R.string.whitelist
),
title = R.string.fakeip_filter_mode,
configure = dnsDependencies::add,
)
selectableList(
value = configuration.dns.fallbackFilter::geoIp,
values = booleanValues,
valuesText = booleanValuesText,
title = R.string.geoip_fallback,
configure = dnsDependencies::add,
)
editableText(
value = configuration.dns.fallbackFilter::geoIpCode,
adapter = NullableTextAdapter.String,
title = R.string.geoip_fallback_code,
placeholder = R.string.dont_modify,
empty = R.string.raw_cn,
configure = dnsDependencies::add,
)
editableTextList(
value = configuration.dns.fallbackFilter::domain,
adapter = TextAdapter.String,
title = R.string.domain_fallback,
placeholder = R.string.dont_modify,
configure = dnsDependencies::add,
)
editableTextList(
value = configuration.dns.fallbackFilter::ipcidr,
adapter = TextAdapter.String,
title = R.string.ipcidr_fallback,
placeholder = R.string.dont_modify,
configure = dnsDependencies::add,
)
editableTextMap(
value = configuration.dns::nameserverPolicy,
keyAdapter = TextAdapter.String,
valueAdapter = TextAdapter.String,
title = R.string.name_server_policy,
placeholder = R.string.dont_modify,
configure = dnsDependencies::add,
)
dns.listener?.onChanged()
}
binding.content.addView(screen.root)
}
fun requestClear() {
requests.trySend(Request.ResetOverride)
}
}
@@ -0,0 +1,160 @@
package com.github.kr328.clash.design
import android.content.Context
import android.content.SharedPreferences
import androidx.lifecycle.LiveData
import com.github.kr328.clash.network.ConfigResponse
object PreferenceManager {
private const val PREF_NAME = "app_preferences"
private lateinit var prefs: SharedPreferences
fun init(context: Context) {
prefs = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE or Context.MODE_MULTI_PROCESS)
}
fun clearData(){
prefs.edit().clear().apply()
}
var cached_userSubscritedata: String
get() = prefs.getString("cached_userSubscritedata", "") ?: ""
set(value) {
prefs.edit().putString("cached_userSubscritedata", value).apply()
}
var cache_timestamp: Long
get() = prefs.getLong("cache_timestamp", 0) ?: 0
set(value) {
prefs.edit().putLong("cache_timestamp", value).apply()
}
var cached_data: String
get() = prefs.getString("cached_data", "") ?: ""
set(value) {
prefs.edit().putString("cached_data", value).apply()
}
var selectnodeName: String
get() = prefs.getString("selectnodeName", "自动选择") ?: "自动选择"
set(value) {
prefs.edit().putString("selectnodeName", value).apply()
}
var modeName: String
get() = prefs.getString("modeName", "智能模式") ?: "智能模式"
set(value) {
prefs.edit().putString("modeName", value).apply()
}
var loginemail: String
get() = prefs.getString("loginemail", "") ?: ""
set(value) {
prefs.edit().putString("loginemail", value).apply()
}
var loginToken: String
get() = prefs.getString("loginToken", "") ?: ""
set(value) {
prefs.edit().putString("loginToken", value).apply()
}
var loginauthData: String
get() = prefs.getString("loginauthData", "") ?: ""
set(value) {
prefs.edit().putString("loginauthData", value).apply()
}
var isLoginin: Boolean
get() = prefs.getBoolean("isLoginin", false) ?: false
set(value) {
prefs.edit().putBoolean("isLoginin", value).apply()
}
var baseURL: String
get() = prefs.getString("baseURL", "") ?: ""
set(value) {
prefs.edit().putString("baseURL", value).apply()
}
// Save data to prefserences
fun saveConfigToPreferences( configResponse: ConfigResponse) {
val editor = prefs.edit()
// Store each field individually
editor.putString("baseURL", configResponse.baseURL)
editor.putString("baseDYURL", configResponse.baseDYURL)
editor.putString("mainregisterURL", configResponse.mainregisterURL)
editor.putString("paymentURL", configResponse.paymentURL)
editor.putString("telegramurl", configResponse.telegramurl)
editor.putString("kefuurl", configResponse.kefuurl)
editor.putString("websiteURL", configResponse.websiteURL)
editor.putString("crisptoken", configResponse.crisptoken)
editor.putString("banners", configResponse.banners.joinToString(","))
// Apply changes
editor.apply()
}
// Retrieve data from SharedPreferences
fun getConfigFromPreferences(context: Context): ConfigResponse? {
// Retrieve each field individually
val baseURL = prefs.getString("baseURL", null) ?: ""
val baseDYURL = prefs.getString("baseDYURL", null) ?: ""
val mainregisterURL = prefs.getString("mainregisterURL", null) ?: ""
val paymentURL = prefs.getString("paymentURL", null) ?: ""
val telegramurl = prefs.getString("telegramurl", null) ?: ""
val kefuurl = prefs.getString("kefuurl", null) ?: ""
val websiteURL = prefs.getString("websiteURL", null) ?: ""
val crisptoken = prefs.getString("crisptoken", null) ?: ""
val bannersString = prefs.getString("banners", null) ?: ""
// If any field is null, return null (you can add your own null-check handling logic)
// Convert the banners string back into a List
val banners = bannersString.split(",").map { it.trim() } ?: emptyList()
return ConfigResponse(
baseURL = baseURL,
baseDYURL = baseDYURL,
mainregisterURL = mainregisterURL,
paymentURL = paymentURL,
telegramurl = telegramurl,
kefuurl = kefuurl,
websiteURL = websiteURL,
crisptoken = crisptoken,
banners = banners, message = "", code = 1
)
}
}
//自动通知监听
class PreferenceLiveData(private val context: Context,) : LiveData<String>() {
private var pref: SharedPreferences? = null
private val key: String = "selectnodeName"
private val defValue: String = "自动选择"
private var listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
if (key == this.key) {
value = pref?.getString(key, defValue)
}
}
override fun onActive() {
super.onActive()
pref = context.getSharedPreferences("app_preferences", Context.MODE_PRIVATE)
pref?.registerOnSharedPreferenceChangeListener(listener)
value = pref?.getString(key, defValue)
}
override fun onInactive() {
super.onInactive()
pref?.unregisterOnSharedPreferenceChangeListener(listener)
}
}
@@ -0,0 +1,130 @@
package com.github.kr328.clash.design
import android.view.View
import android.content.Context
import android.content.res.ColorStateList
import android.graphics.Color
import android.graphics.RenderEffect
import android.graphics.Shader
import android.os.Build
import com.github.kr328.clash.core.bridge.Bridge
import com.github.kr328.clash.design.MainDesign.Request
import com.github.kr328.clash.design.databinding.ActivityProfileBinding
import com.github.kr328.clash.design.databinding.DesignProfilesBinding
import com.github.kr328.clash.design.network.APIGlobalObject
import com.github.kr328.clash.design.util.applyFrom
import com.github.kr328.clash.design.util.bindAppBarElevation
import com.github.kr328.clash.design.util.layoutInflater
import com.github.kr328.clash.design.util.resolveThemedColor
import com.github.kr328.clash.design.util.root
import com.github.kr328.clash.network.ConfigResponse
import com.github.kr328.clash.network.SubscribeData
import com.github.kr328.clash.network.SubscribeResponse
import com.github.kr328.clash.service.model.Profile
import com.google.gson.Gson
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
class ProfileDesign (context: Context) : Design<ProfileDesign.Request>(context) {
enum class Request {
OpenBuyPlan,
OpenMyOrders,
OpenMyBlance,
OpenMyInvites,
OpenCustomView,
OpenMyGongdan,
OpenLogout,
OpenGSettings,
OpenCustomViewIPtest,
OpenCustomViewSpeed
}
private var subData: SubscribeData? = null
private val binding = ActivityProfileBinding
.inflate(context.layoutInflater, context.root, false)
override val root: View
get() = binding.root
init {
binding.self = this
binding.activityBarLayout.applyFrom(context)
// binding.viewAccountinfo.setBackgroundColor(Color.parseColor("#FFFFFF")) // Semi-transparent white color
// binding.viewAccountinfo2.setBackgroundColor(Color.parseColor("#FFFFFF")) // Semi-transparent white color
// binding.viewAccountinfo3.setBackgroundColor(Color.parseColor("#FFFFFF")) // Semi-transparent white color
// binding.viewAccountinfo4.setBackgroundColor(Color.parseColor("#FFFFFF")) // Semi-transparent white color
}
fun request(request: Request) {
requests.trySend(request)
}
fun formatTimestamp(timestamp: Long): String {
val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) // 定义格式
val date = Date(timestamp) // 将时间戳转为 Date 对象
return dateFormat.format(date) // 格式化为字符串
}
fun queryVersion(version: String){
binding.textVersion.text = version
}
suspend fun requestdataDisible() {
if ( APIGlobalObject.subData == null){
val gson = Gson()
val cached_userSubscritedata = gson.fromJson(PreferenceManager.cached_userSubscritedata, SubscribeResponse::class.java)
APIGlobalObject.subData = cached_userSubscritedata.data
}
binding.accountEmail = PreferenceManager.loginemail
if ((APIGlobalObject.subData?.plan?.id ?:0) > 0){
binding.accountPlanname = APIGlobalObject.subData?.plan?.name ?: ""
if ( APIGlobalObject.subData?.expired_at == null){
binding.accountPlantime = "该订阅长期有效"
}else{
binding.accountPlantime = "到期时间:${ formatTimestamp((APIGlobalObject.subData?.expired_at ?: 0) * 1000L)}"
}
val total = APIGlobalObject.subData?.plan?.transfer_enable ?: 0
val upmb = (APIGlobalObject.subData?.d ?: 0.00).toDouble().div(1024).div(1024).div(1024)
val downmb = (APIGlobalObject.subData?.u ?: 0.00).toDouble().div(1024).div(1024).div(1024)
val usd = upmb + downmb
binding.accountPlanuseinfo = "已用 ${String.format("%.2f", usd)}GB/总计 ${total}GB"
if (total>0 && usd>0) {
binding.accountProgressValue = (usd /total.toDouble() * 100).toInt()
if(usd > total){
binding.useedprogressBar.progressTintList = ColorStateList.valueOf(
// context.resolveThemedColor(R.attr.colorOnPrimary)
Color.parseColor("#ce665e")
)
}else{
}
}else{
binding.accountProgressValue = 0
}
binding.accountBlance = "${ APIGlobalObject.subData?.transfer_enable }"
}else{
binding.accountPlanname = "未订阅任何套餐"
binding.accountPlantime = "已过期"
binding.accountPlanuseinfo = "已用 0GB/总计 0GB"
}
}
}
@@ -0,0 +1,145 @@
package com.github.kr328.clash.design
import android.app.Dialog
import android.content.Context
import android.view.View
import android.view.ViewGroup
import android.view.animation.Animation
import android.view.animation.AnimationUtils
import com.github.kr328.clash.design.adapter.ProfileAdapter
import com.github.kr328.clash.design.databinding.DesignProfilesBinding
import com.github.kr328.clash.design.databinding.DialogProfilesMenuBinding
import com.github.kr328.clash.design.dialog.AppBottomSheetDialog
import com.github.kr328.clash.design.ui.ToastDuration
import com.github.kr328.clash.design.util.*
import com.github.kr328.clash.service.model.Profile
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class ProfilesDesign(context: Context) : Design<ProfilesDesign.Request>(context) {
sealed class Request {
object UpdateAll : Request()
object Create : Request()
data class Active(val profile: Profile) : Request()
data class Update(val profile: Profile) : Request()
data class Edit(val profile: Profile) : Request()
data class Duplicate(val profile: Profile) : Request()
data class Delete(val profile: Profile) : Request()
}
private val binding = DesignProfilesBinding
.inflate(context.layoutInflater, context.root, false)
private val adapter = ProfileAdapter(context, this::requestActive, this::showMenu)
private var allUpdating: Boolean
get() = adapter.states.allUpdating;
set(value) {
adapter.states.allUpdating = value
}
private val rotateAnimation : Animation = AnimationUtils.loadAnimation(context, R.anim.rotate_infinite)
override val root: View
get() = binding.root
suspend fun patchProfiles(profiles: List<Profile>) {
adapter.apply {
patchDataSet(this::profiles, profiles, id = { it.uuid })
}
val updatable = withContext(Dispatchers.Default) {
profiles.any { it.imported && it.type != Profile.Type.File }
}
withContext(Dispatchers.Main) {
binding.updateView.visibility = if (updatable) View.VISIBLE else View.GONE
}
}
suspend fun requestSave(profile: Profile) {
showToast(R.string.active_unsaved_tips, ToastDuration.Long) {
setAction(R.string.edit) {
requests.trySend(Request.Edit(profile))
}
}
}
fun updateElapsed() {
adapter.updateElapsed()
}
init {
binding.self = this
binding.activityBarLayout.applyFrom(context)
binding.mainList.recyclerList.also {
it.bindAppBarElevation(binding.activityBarLayout)
it.applyLinearAdapter(context, adapter)
}
}
private fun showMenu(profile: Profile) {
val dialog = AppBottomSheetDialog(context)
val binding = DialogProfilesMenuBinding
.inflate(context.layoutInflater, dialog.window?.decorView as ViewGroup?, false)
binding.master = this
binding.self = dialog
binding.profile = profile
dialog.setContentView(binding.root)
dialog.show()
}
fun requestUpdateAll() {
allUpdating = true;
changeUpdateAllButtonStatus()
requests.trySend(Request.UpdateAll)
}
fun finishUpdateAll() {
allUpdating = false;
changeUpdateAllButtonStatus()
}
fun requestCreate() {
requests.trySend(Request.Create)
}
private fun requestActive(profile: Profile) {
requests.trySend(Request.Active(profile))
}
fun requestUpdate(dialog: Dialog, profile: Profile) {
requests.trySend(Request.Update(profile))
dialog.dismiss()
}
fun requestEdit(dialog: Dialog, profile: Profile) {
requests.trySend(Request.Edit(profile))
dialog.dismiss()
}
fun requestDuplicate(dialog: Dialog, profile: Profile) {
requests.trySend(Request.Duplicate(profile))
dialog.dismiss()
}
fun requestDelete(dialog: Dialog, profile: Profile) {
requests.trySend(Request.Delete(profile))
dialog.dismiss()
}
private fun changeUpdateAllButtonStatus() {
if (allUpdating) {
binding.updateView.startAnimation(rotateAnimation)
} else {
binding.updateView.clearAnimation()
}
}
}
@@ -0,0 +1,172 @@
package com.github.kr328.clash.design
import android.content.Context
import android.view.View
import com.github.kr328.clash.core.model.FetchStatus
import com.github.kr328.clash.design.databinding.DesignPropertiesBinding
import com.github.kr328.clash.design.dialog.ModelProgressBarConfigure
import com.github.kr328.clash.design.dialog.requestModelTextInput
import com.github.kr328.clash.design.dialog.withModelProgressBar
import com.github.kr328.clash.design.util.*
import com.github.kr328.clash.service.model.Profile
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import java.util.concurrent.TimeUnit
import kotlin.coroutines.resume
class PropertiesDesign(context: Context) : Design<PropertiesDesign.Request>(context) {
sealed class Request {
object Commit : Request()
object BrowseFiles : Request()
}
private val binding = DesignPropertiesBinding
.inflate(context.layoutInflater, context.root, false)
override val root: View
get() = binding.root
var profile: Profile
get() = binding.profile!!
set(value) {
binding.profile = value
}
val progressing: Boolean
get() = binding.processing
suspend fun withProcessing(executeTask: suspend (suspend (FetchStatus) -> Unit) -> Unit) {
try {
binding.processing = true
context.withModelProgressBar {
configure {
isIndeterminate = true
text = context.getString(R.string.initializing)
}
executeTask {
configure {
applyFrom(it)
}
}
}
} finally {
binding.processing = false
}
}
suspend fun requestExitWithoutSaving(): Boolean {
return withContext(Dispatchers.Main) {
suspendCancellableCoroutine { ctx ->
val dialog = MaterialAlertDialogBuilder(context)
.setTitle(R.string.exit_without_save)
.setMessage(R.string.exit_without_save_warning)
.setCancelable(true)
.setPositiveButton(R.string.ok) { _, _ -> ctx.resume(true) }
.setNegativeButton(R.string.cancel) { _, _ -> }
.setOnDismissListener { if (!ctx.isCompleted) ctx.resume(false) }
.show()
ctx.invokeOnCancellation { dialog.dismiss() }
}
}
}
init {
binding.self = this
binding.activityBarLayout.applyFrom(context)
binding.tips.text = context.getHtml(R.string.tips_properties)
binding.scrollRoot.bindAppBarElevation(binding.activityBarLayout)
}
fun inputName() {
launch {
val name = context.requestModelTextInput(
initial = profile.name,
title = context.getText(R.string.name),
hint = context.getText(R.string.properties),
error = context.getText(R.string.should_not_be_blank),
validator = ValidatorNotBlank
)
if (name != profile.name) {
profile = profile.copy(name = name)
}
}
}
fun inputUrl() {
if (profile.type == Profile.Type.External)
return
launch {
val url = context.requestModelTextInput(
initial = profile.source,
title = context.getText(R.string.url),
hint = context.getText(R.string.profile_url),
error = context.getText(R.string.accept_http_content),
validator = ValidatorHttpUrl
)
if (url != profile.source) {
profile = profile.copy(source = url)
}
}
}
fun inputInterval() {
launch {
var minutes = TimeUnit.MILLISECONDS.toMinutes(profile.interval)
minutes = context.requestModelTextInput(
initial = if (minutes == 0L) "" else minutes.toString(),
title = context.getText(R.string.auto_update),
hint = context.getText(R.string.auto_update_minutes),
error = context.getText(R.string.at_least_15_minutes),
validator = ValidatorAutoUpdateInterval
).toLongOrNull() ?: 0
val interval = TimeUnit.MINUTES.toMillis(minutes)
if (interval != profile.interval) {
profile = profile.copy(interval = interval)
}
}
}
fun requestCommit() {
requests.trySend(Request.Commit)
}
fun requestBrowseFiles() {
requests.trySend(Request.BrowseFiles)
}
private fun ModelProgressBarConfigure.applyFrom(status: FetchStatus) {
when (status.action) {
FetchStatus.Action.FetchConfiguration -> {
text = context.getString(R.string.format_fetching_configuration, status.args[0])
isIndeterminate = true
}
FetchStatus.Action.FetchProviders -> {
text = context.getString(R.string.format_fetching_provider, status.args[0])
isIndeterminate = false
max = status.max
progress = status.progress
}
FetchStatus.Action.Verifying -> {
text = context.getString(R.string.verifying)
isIndeterminate = false
max = status.max
progress = status.progress
}
}
}
}
@@ -0,0 +1,62 @@
package com.github.kr328.clash.design
import android.content.Context
import android.view.View
import com.github.kr328.clash.core.model.Provider
import com.github.kr328.clash.design.adapter.ProviderAdapter
import com.github.kr328.clash.design.databinding.DesignProvidersBinding
import com.github.kr328.clash.design.util.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class ProvidersDesign(
context: Context,
providers: List<Provider>,
) : Design<ProvidersDesign.Request>(context) {
sealed class Request {
data class Update(val index: Int, val provider: Provider) : Request()
}
private val binding = DesignProvidersBinding
.inflate(context.layoutInflater, context.root, false)
override val root: View
get() = binding.root
private val adapter = ProviderAdapter(context, providers) { index, provider ->
requests.trySend(Request.Update(index, provider))
}
fun updateElapsed() {
adapter.updateElapsed()
}
suspend fun notifyUpdated(index: Int) {
withContext(Dispatchers.Main) {
adapter.notifyUpdated(index)
}
}
suspend fun notifyChanged(index: Int) {
withContext(Dispatchers.Main) {
adapter.notifyChanged(index)
}
}
init {
binding.self = this
binding.activityBarLayout.applyFrom(context)
binding.mainList.recyclerList.bindAppBarElevation(binding.activityBarLayout)
binding.mainList.recyclerList.applyLinearAdapter(context, adapter)
}
fun requestUpdateAll() {
adapter.states.filter { !it.updating }.forEachIndexed { index, state ->
state.updating = true
requests.trySend(Request.Update(index, state.provider))
}
}
}
@@ -0,0 +1,281 @@
package com.github.kr328.clash.design
import android.content.Context
import android.content.res.ColorStateList
import android.graphics.Color
import android.view.View
import android.widget.Toast
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.widget.ViewPager2
import com.github.kr328.clash.core.model.Proxy
import com.github.kr328.clash.core.model.TunnelState
import com.github.kr328.clash.design.adapter.ProxyAdapter
import com.github.kr328.clash.design.adapter.ProxyPageAdapter
import com.github.kr328.clash.design.adapter.ServerListAdapter
import com.github.kr328.clash.design.component.ProxyMenu
import com.github.kr328.clash.design.component.ProxyViewConfig
import com.github.kr328.clash.design.databinding.DesignProxyBinding
import com.github.kr328.clash.design.model.ProxyState
import com.github.kr328.clash.design.store.UiStore
import com.github.kr328.clash.design.util.applyFrom
import com.github.kr328.clash.design.util.layoutInflater
import com.github.kr328.clash.design.util.resolveThemedColor
import com.github.kr328.clash.design.util.root
import com.github.kr328.clash.design.view.CustomDividerItemDecoration
import com.google.android.material.tabs.TabLayoutMediator
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class ProxyDesign(
context: Context,
overrideMode: TunnelState.Mode?,
groupNames: List<String>,
uiStore: UiStore,
) : Design<ProxyDesign.Request>(context) {
sealed class Request {
object ReloadAll : Request()
object ReLaunch : Request()
data class PatchMode(val mode: TunnelState.Mode?) : Request()
data class Reload(val index: Int) : Request()
data class Select(val index: Int, val name: String) : Request()
data class UrlTest(val index: Int) : Request()
}
private val binding = DesignProxyBinding
.inflate(context.layoutInflater, context.root, false)
private var config = ProxyViewConfig(context, uiStore.proxyLine)
private val menu: ProxyMenu by lazy {
ProxyMenu(context, binding.menuView, overrideMode, uiStore, requests) {
config.proxyLine = uiStore.proxyLine
}
}
override val root: View = binding.root
private lateinit var recyclerView: RecyclerView
private lateinit var adapter: ServerListAdapter
var urlTesting: Boolean = false
/*
private val adapter: ProxyPageAdapter
get() = binding.pagesView.adapter!! as ProxyPageAdapter
private var horizontalScrolling = false
private val verticalBottomScrolled: Boolean
get() = adapter.states[binding.pagesView.currentItem].bottom
private var urlTesting: Boolean
get() = adapter.states[binding.pagesView.currentItem].urlTesting
set(value) {
adapter.states[binding.pagesView.currentItem].urlTesting = value
}
suspend fun updateGroup(
position: Int,
proxies: List<Proxy>,
selectable: Boolean,
parent: ProxyState,
links: Map<String, ProxyState>
) {
adapter.updateAdapter(position, proxies, selectable, parent, links)
adapter.states[position].urlTesting = false
updateUrlTestButtonStatus()
}
suspend fun requestRedrawVisible() {
withContext(Dispatchers.Main) {
adapter.requestRedrawVisible()
}
}
*/
suspend fun updateGroup(
position: Int,
proxies: List<Proxy>,
selectable: Boolean,
parent: ProxyState,
links: Map<String, ProxyState>
) {
adapter.updateAdapter(position, proxies, selectable, parent, links)
adapter.urlTesting = false
updateUrlTestButtonStatus()
}
suspend fun requestRedrawVisible() {
withContext(Dispatchers.Main) {
adapter.requestRedrawVisible()
}
}
init {
binding.self = this
binding.activityBarLayout.applyFrom(context)
binding.menuView.setOnClickListener {
menu.show()
}
if (groupNames.isEmpty() ) {
binding.emptyView.visibility = View.VISIBLE
binding.urlTestView.visibility = View.GONE
//binding.pagesView.visibility = View.GONE
binding.urlTestFloatView.visibility = View.GONE
}else{
binding.urlTestView.visibility = View.VISIBLE
binding.urlTestProgressView.visibility = View.GONE
binding.urlTestFloatView.supportImageTintList = ColorStateList.valueOf(
context.resolveThemedColor(R.attr.colorOnPrimary)
)
// 初始化 RecyclerView
recyclerView =binding.recyclerView
recyclerView.layoutManager = LinearLayoutManager(context)
adapter = ServerListAdapter( surface,
config){ name ->
requests.trySend(Request.Select(0, name))
}
recyclerView.adapter = adapter
binding.baseSwipeRefreshLayout.setOnRefreshListener {
CoroutineScope(Dispatchers.IO).launch {
withContext(Dispatchers.Main) {
binding.baseSwipeRefreshLayout.isRefreshing = true
}
requests.trySend(Request.Reload(0))
}
}
// 设置自定义分割线
val customDivider = CustomDividerItemDecoration(1, Color.LTGRAY) // 4px高的灰色分割线
recyclerView.addItemDecoration(customDivider)
// val firstObj = groupNames.first()
// val newgroupNames = listOf(firstObj)
// println(groupNames)
}
/*
if (groupNames.isEmpty() ) {
binding.emptyView.visibility = View.VISIBLE
binding.urlTestView.visibility = View.GONE
/// binding.tabLayoutView.visibility = View.GONE
// binding.elevationView.visibility = View.GONE
binding.pagesView.visibility = View.GONE
binding.urlTestFloatView.visibility = View.GONE
} else {
binding.urlTestFloatView.supportImageTintList = ColorStateList.valueOf(
context.resolveThemedColor(R.attr.colorOnPrimary)
)
val firstObj = groupNames.first()
val newgroupNames = listOf(firstObj)
binding.pagesView.apply {
adapter = ProxyPageAdapter(
surface,
config,
List(groupNames.size) { index ->
ProxyAdapter(config) { name ->
requests.trySend(Request.Select(index, name))
}
}
) {
if (it == currentItem)
updateUrlTestButtonStatus()
}
registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
override fun onPageScrollStateChanged(state: Int) {
horizontalScrolling = state != ViewPager2.SCROLL_STATE_IDLE
updateUrlTestButtonStatus()
}
override fun onPageSelected(position: Int) {
uiStore.proxyLastGroup = groupNames[position]
}
})
}
TabLayoutMediator(binding.tabLayoutView, binding.pagesView) { tab, index ->
tab.text = groupNames[index]
}.attach()
val initialPosition = groupNames.indexOf(uiStore.proxyLastGroup)
binding.pagesView.post {
if (initialPosition > 0)
binding.pagesView.setCurrentItem(initialPosition, false)
}
} */
}
/*
fun requestUrlTesting() {
urlTesting = true
requests.trySend(Request.UrlTest(binding.pagesView.currentItem))
updateUrlTestButtonStatus()
}
private fun updateUrlTestButtonStatus() {
if (verticalBottomScrolled || horizontalScrolling || urlTesting) {
binding.urlTestFloatView.hide()
} else {
binding.urlTestFloatView.show()
}
if (urlTesting) {
binding.urlTestView.visibility = View.GONE
binding.urlTestProgressView.visibility = View.VISIBLE
} else {
binding.urlTestView.visibility = View.VISIBLE
binding.urlTestProgressView.visibility = View.GONE
}
} */
fun requestUrlTesting() {
urlTesting = true
requests.trySend(Request.UrlTest(0))
updateUrlTestButtonStatus()
}
private fun updateUrlTestButtonStatus() {
if (urlTesting) {
binding.urlTestView.visibility = View.GONE
binding.urlTestProgressView.visibility = View.VISIBLE
} else {
binding.urlTestView.visibility = View.VISIBLE
binding.urlTestProgressView.visibility = View.GONE
}
}
fun finishreferesh() {
binding.baseSwipeRefreshLayout.isRefreshing = false
}
}
@@ -0,0 +1,33 @@
package com.github.kr328.clash.design
import android.content.Context
import android.view.View
import com.github.kr328.clash.design.databinding.DesignSettingsBinding
import com.github.kr328.clash.design.util.applyFrom
import com.github.kr328.clash.design.util.bindAppBarElevation
import com.github.kr328.clash.design.util.layoutInflater
import com.github.kr328.clash.design.util.root
class SettingsDesign(context: Context) : Design<SettingsDesign.Request>(context) {
enum class Request {
StartApp, StartNetwork, StartOverride, StartMetaFeature,
}
private val binding = DesignSettingsBinding
.inflate(context.layoutInflater, context.root, false)
override val root: View
get() = binding.root
init {
binding.self = this
binding.activityBarLayout.applyFrom(context)
binding.scrollRoot.bindAppBarElevation(binding.activityBarLayout)
}
fun request(request: Request) {
requests.trySend(request)
}
}
@@ -0,0 +1,286 @@
package com.github.kr328.clash.design
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.view.View
import androidx.core.content.ContextCompat
import androidx.transition.Visibility
import com.github.kr328.clash.common.util.intent
import com.github.kr328.clash.design.ConfigOrderDesign.Request
import com.github.kr328.clash.design.databinding.ActivityPaymentItemBinding
import com.github.kr328.clash.design.databinding.ActivityPlanItemBinding
import com.github.kr328.clash.design.databinding.ActivitySubmitorderBinding
import com.github.kr328.clash.design.network.APIGlobalObject
import com.github.kr328.clash.design.ui.ToastDuration
import com.github.kr328.clash.design.util.applyFrom
import com.github.kr328.clash.design.util.layoutInflater
import com.github.kr328.clash.design.util.root
import com.github.kr328.clash.design.util.showCustomDialog
import com.github.kr328.clash.network.ApiClient
import com.github.kr328.clash.network.ApiService
import com.github.kr328.clash.network.CheckoutrderRequest
import com.github.kr328.clash.network.PaymentData
import com.github.kr328.clash.network.PublicResponse
import com.github.kr328.clash.network.QueryOrderData
import com.github.kr328.clash.network.QueryOrderRequest
import com.github.kr328.clash.network.SaveOrderRequest
import com.github.kr328.clash.network.SubmitOrderResponse
import com.github.kr328.clash.network.safeApiRequestCall
import com.github.kr328.clash.utity.LoadingDialog
import com.google.gson.Gson
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
class SubmitOrderViewDesign (context: Context) : Design<SubmitOrderViewDesign.Request>(context) {
enum class Request {
SubmitPay,
}
private var currentChoose: PaymentData? = null
private var tradeNo: String = ""
private val binding = ActivitySubmitorderBinding
.inflate(context.layoutInflater, context.root, false)
override val root: View
get() = binding.root
fun request(request: Request) {
if (request == Request.SubmitPay) {
//提交订单
LoadingDialog.show(context, "正在结账中...")
CoroutineScope(Dispatchers.IO).launch {
val apiService = ApiClient.retrofit.create(ApiService::class.java)
safeApiRequestCall { apiService.checkoutOrder(PreferenceManager.loginauthData, CheckoutrderRequest(tradeNo,currentChoose?.id ?: 0 ))}.let {
withContext(Dispatchers.Main) {
LoadingDialog.hide()
}
if (it != null && it.isSuccessful){
if (it.body()?.data != null){
val url = it.body()?.data ?: ""
if (url.length > 3){
try {
withContext(Dispatchers.Main) {
val intent = Intent(Intent.ACTION_VIEW)
intent.addCategory(Intent.CATEGORY_BROWSABLE);
intent.setData(Uri.parse(url))
context.startActivity(intent)
//弹出支付框
context.showCustomDialog(
title = "温馨提示",
message = "完成支付后若未到账,请重启一下客户端即可同步订阅套餐时长。",
positiveButtonText = "确定",
negativeButtonText = "取消",
onPositiveClick = {
fetchTradeInfo(tradeNo)
launch {
withContext(Dispatchers.Main) {
}
}
},
onNegativeClick = {
// 执行取消操作
}
)
}
} catch (e: Exception) {
println("当前手机未安装浏览器")
}
}
}
println(it.body())
}else{
val errorinfo = (it?.errorBody()?.string())
if (errorinfo != null){
//解析错错误信息
val gson = Gson()
val submitResponse = gson.fromJson(errorinfo, PublicResponse::class.java)
if (submitResponse?.message != null){
withContext(Dispatchers.Main) {
showToast("结算请求失败:${submitResponse.message}", ToastDuration.Long)
}
}
}else{
withContext(Dispatchers.Main) {
showToast("结算失败:请求数据失败", ToastDuration.Long)
}
}
}
}
}
}
}
init {
binding.self = this
binding.activityBarLayout.applyFrom(context)
}
fun fetchTradeInfo(trade_no: String?){
tradeNo = trade_no ?: ""
LoadingDialog.show(context, "请求订单数据...")
CoroutineScope(Dispatchers.IO).launch {
val apiService = ApiClient.retrofit.create(ApiService::class.java)
safeApiRequestCall { apiService.getOrderdetail(PreferenceManager.loginauthData,trade_no?: "")}.let {
if(it != null && it.isSuccessful){
if (it.body()?.data != null){
withContext(Dispatchers.Main) {
fillData(it.body()?.data)
LoadingDialog.hide()
// 清空 binding.container 的所有子视图
binding.container.removeAllViews()
}
var selectedIndex = 0
//继续获取支付方式
safeApiRequestCall { apiService.getPaymentList(PreferenceManager.loginauthData)}.let {
if(it != null && it.isSuccessful){
println("${it.body()}")
withContext(Dispatchers.Main) {
it.body().let {
it?.data.let {
if (it != null) {
for (i in it.indices) {
// 设置点击事件和单选逻辑
val frameLayout =
ActivityPaymentItemBinding.inflate(
context.layoutInflater,
context.root,
false
)
val item = it[i]
frameLayout.typeTextView.text = item.name
frameLayout.amountTextView.text = "手续费:${item.handling_fee_percent ?: "0.00"}%"
frameLayout.planitemFrameLayout.setOnClickListener {
selectedIndex = i
currentChoose = item
for (j in 0 until binding.container.childCount) {
val child =
binding.container.getChildAt(j)
println("selectedIndex: ${selectedIndex} ${child.background} ")
child.background =
ContextCompat.getDrawable(
context,
if (j == selectedIndex) R.drawable.card_border_selected else R.drawable.card_border
)
}
}
binding.container.addView(frameLayout.root)
if (i == 0){
//默认选中第一个
currentChoose = it[0]
//设置手续费显示:
//binding.configorderPlanFeeAmount = currentChoose.handling_fee_percent
frameLayout.planitemFrameLayout.background = ContextCompat.getDrawable(
context, R.drawable.card_border_selected
)
}
}
}
}
}
}
}
}
}
}else{
val errorinfo = (it?.errorBody()?.string())
if (errorinfo != null){
//解析错错误信息
val gson = Gson()
val submitResponse = gson.fromJson(errorinfo, PublicResponse::class.java)
if (submitResponse?.message != null){
withContext(Dispatchers.Main) {
showToast("订单请求失败:${submitResponse.message}", ToastDuration.Long)
}
}
}else{
withContext(Dispatchers.Main) {
showToast("下单失败:请求数据失败", ToastDuration.Long)
}
}
}
withContext(Dispatchers.Main) {
LoadingDialog.hide()
}
}
}
}
fun formatTimestamp(timestamp: Long): String {
val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) // 定义格式
val date = Date(timestamp) // 将时间戳转为 Date 对象
return dateFormat.format(date) // 格式化为字符串
}
private fun fillData(data: QueryOrderData?) {
data.let {
binding.configorderPlanname = "商品名称:${data?.plan?.name}"
binding.configorderTradeno = "订单号:${data?.trade_no}"
binding.configorderPlanname2 = data?.plan?.name
val amount = (data?.total_amount?.toDouble() ?: 0.0)/100
binding.configorderPlanamount = "¥ ${amount}0"
binding.configorderPlantype = "类型/周期:${data?.periodZh}"
binding.configorderPlantransfer = "商品流量:${data?.plan?.transfer_enable} GB"
binding.configorderTimer = "创建时间:${ formatTimestamp((data?.created_at ?: 0) * 1000L)}"
binding.orderDetailsStatus.text = data?.statusZh
if (data?.status == 0){
binding.confirmPaymentButton.visibility = View.VISIBLE
}else{
binding.confirmPaymentButton.visibility = View.GONE
}
}
}
}
@@ -0,0 +1,49 @@
package com.github.kr328.clash.design.adapter
import android.content.Context
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.github.kr328.clash.design.databinding.AdapterAppBinding
import com.github.kr328.clash.design.model.AppInfo
import com.github.kr328.clash.design.util.layoutInflater
import com.github.kr328.clash.design.util.root
class AppAdapter(
private val context: Context,
private val selected: MutableSet<String>,
) : RecyclerView.Adapter<AppAdapter.Holder>() {
class Holder(val binding: AdapterAppBinding) : RecyclerView.ViewHolder(binding.root)
var apps: List<AppInfo> = emptyList()
fun rebindAll() {
notifyItemRangeChanged(0, itemCount)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
return Holder(
AdapterAppBinding
.inflate(context.layoutInflater, context.root, false)
)
}
override fun onBindViewHolder(holder: Holder, position: Int) {
val current = apps[position]
holder.binding.app = current
holder.binding.selected = current.packageName in selected
holder.binding.root.setOnClickListener {
if (holder.binding.selected) {
selected.remove(current.packageName)
holder.binding.selected = false
} else {
selected.add(current.packageName)
holder.binding.selected = true
}
}
}
override fun getItemCount(): Int {
return apps.size
}
}
@@ -0,0 +1,49 @@
package com.github.kr328.clash.design.adapter
import android.content.Context
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.github.kr328.clash.design.databinding.AdapterEditableTextListBinding
import com.github.kr328.clash.design.preference.TextAdapter
import com.github.kr328.clash.design.util.layoutInflater
class EditableTextListAdapter<T>(
private val context: Context,
val values: MutableList<T>,
private val adapter: TextAdapter<T>,
) : RecyclerView.Adapter<EditableTextListAdapter.Holder>() {
class Holder(val binding: AdapterEditableTextListBinding) :
RecyclerView.ViewHolder(binding.root)
fun addElement(text: String) {
val value = adapter.to(text)
notifyItemInserted(values.size)
values.add(value)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
return Holder(
AdapterEditableTextListBinding
.inflate(context.layoutInflater, parent, false)
)
}
override fun onBindViewHolder(holder: Holder, position: Int) {
val current = values[position]
holder.binding.textView.text = adapter.from(current)
holder.binding.deleteView.setOnClickListener {
val index = values.indexOf(current)
if (index >= 0) {
values.removeAt(index)
notifyItemRemoved(index)
}
}
}
override fun getItemCount(): Int {
return values.size
}
}
@@ -0,0 +1,51 @@
package com.github.kr328.clash.design.adapter
import android.content.Context
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.github.kr328.clash.design.databinding.AdapterEditableTextMapBinding
import com.github.kr328.clash.design.preference.TextAdapter
import com.github.kr328.clash.design.util.layoutInflater
class EditableTextMapAdapter<K, V>(
private val context: Context,
val values: MutableList<Pair<K, V>>,
private val keyAdapter: TextAdapter<K>,
private val valueAdapter: TextAdapter<V>,
) : RecyclerView.Adapter<EditableTextMapAdapter.Holder>() {
class Holder(val binding: AdapterEditableTextMapBinding) : RecyclerView.ViewHolder(binding.root)
fun addElement(key: String, value: String) {
val keyValue = keyAdapter.to(key)
val valueValue = valueAdapter.to(value)
notifyItemInserted(values.size)
values.add(keyValue to valueValue)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
return Holder(
AdapterEditableTextMapBinding
.inflate(context.layoutInflater, parent, false)
)
}
override fun onBindViewHolder(holder: Holder, position: Int) {
val current = values[position]
holder.binding.keyView.text = keyAdapter.from(current.first)
holder.binding.valueView.text = valueAdapter.from(current.second)
holder.binding.deleteView.setOnClickListener {
val index = values.indexOf(current)
if (index >= 0) {
values.removeAt(index)
notifyItemRemoved(index)
}
}
}
override fun getItemCount(): Int {
return values.size
}
}
@@ -0,0 +1,53 @@
package com.github.kr328.clash.design.adapter
import android.content.Context
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.github.kr328.clash.design.databinding.AdapterFileBinding
import com.github.kr328.clash.design.model.File
import com.github.kr328.clash.design.ui.ObservableCurrentTime
import com.github.kr328.clash.design.util.layoutInflater
class FileAdapter(
private val context: Context,
private val open: (File) -> Unit,
private val more: (File) -> Unit,
) : RecyclerView.Adapter<FileAdapter.Holder>() {
class Holder(val binding: AdapterFileBinding) : RecyclerView.ViewHolder(binding.root)
private val currentTime = ObservableCurrentTime()
var files: List<File> = emptyList()
fun updateElapsed() {
currentTime.update()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
return Holder(
AdapterFileBinding
.inflate(context.layoutInflater, parent, false)
.also { it.currentTime = currentTime }
)
}
override fun onBindViewHolder(holder: Holder, position: Int) {
val current = files[position]
holder.binding.apply {
file = current
setOpen {
open(current)
}
setMore {
more(current)
}
}
}
override fun getItemCount(): Int {
return files.size
}
}
@@ -0,0 +1,32 @@
package com.github.kr328.clash.design
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import androidx.recyclerview.widget.RecyclerView
class ImageSliderAdapter(private val imageList: List<Int>) : RecyclerView.Adapter<ImageSliderAdapter.ImageViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ImageViewHolder {
val view = LayoutInflater.from(parent.context).inflate(R.layout.image_slider_item, parent, false)
return ImageViewHolder(view)
}
override fun onBindViewHolder(holder: ImageViewHolder, position: Int) {
holder.imageView.setImageResource(imageList[position])
with(holder) {
}
}
override fun getItemCount(): Int {
return imageList.size
}
class ImageViewHolder(view: View) : RecyclerView.ViewHolder(view) {
val imageView: ImageView = view.findViewById(R.id.slider_image)
}
}
@@ -0,0 +1,46 @@
package com.github.kr328.clash.design.adapter
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.bitmap.CircleCrop
import com.bumptech.glide.load.resource.bitmap.RoundedCorners
import com.bumptech.glide.request.RequestOptions
import com.github.kr328.clash.design.R
class ImageSliderImagesAdapter(private val imageUrls: List<String>,
private val onItemClick: (Int) -> Unit // 接收点击事件
) :
RecyclerView.Adapter<ImageSliderImagesAdapter.ImageSliderViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ImageSliderViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_image_slider, parent, false)
return ImageSliderViewHolder(view)
}
override fun onBindViewHolder(holder: ImageSliderViewHolder, position: Int) {
val imageUrl = imageUrls[position]
Glide.with(holder.imageView.context)
.load(imageUrl) // 网络图片的 URL
.placeholder(R.drawable.placeholder) // 占位图
.error(R.drawable.placeholder) // 加载失败时的图片
.into(holder.imageView)
// 设置点击事件
// holder.imageView.apply { RequestOptions().transform(RoundedCorners(30)) }// 圆角处理
holder.imageView.setOnClickListener {
onItemClick(position) // 传递当前图片的 URL 或其他数据
}
}
override fun getItemCount(): Int = imageUrls.size
class ImageSliderViewHolder(view: View) : RecyclerView.ViewHolder(view) {
val imageView: ImageView = view.findViewById(R.id.imageView)
}
}
@@ -0,0 +1,38 @@
package com.github.kr328.clash.design.adapter
import android.content.Context
import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams
import androidx.recyclerview.widget.RecyclerView
import com.github.kr328.clash.design.model.LogFile
import com.github.kr328.clash.design.util.format
import com.github.kr328.clash.design.view.ActionLabel
class LogFileAdapter(
private val context: Context,
private val open: (LogFile) -> Unit,
) : RecyclerView.Adapter<LogFileAdapter.Holder>() {
class Holder(val label: ActionLabel) : RecyclerView.ViewHolder(label)
var logs: List<LogFile> = emptyList()
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
return Holder(ActionLabel(context).apply {
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
})
}
override fun onBindViewHolder(holder: Holder, position: Int) {
val current = logs[position]
holder.label.text = current.fileName
holder.label.subtext = current.date.format(context)
holder.label.setOnClickListener {
open(current)
}
}
override fun getItemCount(): Int {
return logs.size
}
}
@@ -0,0 +1,40 @@
package com.github.kr328.clash.design.adapter
import android.content.Context
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.github.kr328.clash.core.model.LogMessage
import com.github.kr328.clash.design.databinding.AdapterLogMessageBinding
import com.github.kr328.clash.design.util.layoutInflater
class LogMessageAdapter(
private val context: Context,
private val copy: (LogMessage) -> Unit,
) :
RecyclerView.Adapter<LogMessageAdapter.Holder>() {
class Holder(val binding: AdapterLogMessageBinding) : RecyclerView.ViewHolder(binding.root)
var messages: List<LogMessage> = emptyList()
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
return Holder(
AdapterLogMessageBinding
.inflate(context.layoutInflater, parent, false)
)
}
override fun onBindViewHolder(holder: Holder, position: Int) {
val current = messages[position]
holder.binding.message = current
holder.binding.root.setOnLongClickListener {
copy(current)
true
}
}
override fun getItemCount(): Int {
return messages.size
}
}
@@ -0,0 +1,91 @@
package com.github.kr328.clash.design.adapter
import android.annotation.SuppressLint
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.HorizontalScrollView
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import com.github.kr328.clash.design.R
import com.github.kr328.clash.network.OrderData
import com.github.kr328.clash.network.PlanData
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
class OrdersDataAdapter(private val onItemClick: (plan: OrderData) -> Unit ) : RecyclerView.Adapter<OrdersDataAdapterViewHolder>() {
private val subscriptions = mutableListOf<OrderData>()
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): OrdersDataAdapterViewHolder {
val view = LayoutInflater.from(parent.context).inflate(R.layout.activity_item_subscription, parent, false)
return OrdersDataAdapterViewHolder(view)
}
override fun onBindViewHolder(holder: OrdersDataAdapterViewHolder, position: Int) {
val subscription = subscriptions[position]
holder.bind(subscription)
holder.itemView.setOnClickListener {
onItemClick(subscription) // 将点击的 position 传递给回调函数
}
}
override fun getItemCount(): Int {
return subscriptions.size
}
@SuppressLint("NotifyDataSetChanged")
fun setData(newData: List<OrderData>) {
subscriptions.clear()
subscriptions.addAll(newData)
notifyDataSetChanged()
}
@SuppressLint("NotifyDataSetChanged")
fun addData(newData: List<OrderData>) {
subscriptions.addAll(newData)
notifyDataSetChanged()
}
}
class OrdersDataAdapterViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val planName: TextView = itemView.findViewById(R.id.subscriptionName)
private val planDescription: TextView = itemView.findViewById(R.id.subscriptionDescription)
private val plantime: TextView = itemView.findViewById(R.id.subscriptionTime)
private val subscriptionAmount: TextView = itemView.findViewById(R.id.subscriptionAmount)
private val subscriptionStatus: TextView = itemView.findViewById(R.id.subscriptionStatus)
private val groups_Scrollview: HorizontalScrollView = itemView.findViewById(R.id.groups_Scrollview)
fun formatTimestamp(timestamp: Long): String {
val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) // 定义格式
val date = Date(timestamp) // 将时间戳转为 Date 对象
return dateFormat.format(date) // 格式化为字符串
}
fun bind(data: OrderData) {
// 绑定数据
planName.text = data.plan?.name + "(" + data.periodZh + ")"
planDescription.text = "${data.plan?.transfer_enable ?: 0}GB"
plantime.text = formatTimestamp((data.created_at ?: 0 ) * 1000L)
subscriptionStatus.text = data.statusZh
val formattedAmount = data.total_amount?.div(100)
subscriptionAmount.text = " ¥${formattedAmount}"
groups_Scrollview.visibility = View.GONE
//// 如果需要加载图片,可以使用图像加载库,如 Glide 或 Picasso
// Glide.with(itemView.context)
// .load(subscription.planImageUrl)
// .into(planImage)
}
}
@@ -0,0 +1,140 @@
package com.github.kr328.clash.design.adapter
import android.annotation.SuppressLint
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.RelativeLayout
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import com.github.kr328.clash.design.R
import com.github.kr328.clash.network.PlanData
class PlanDataAdapter( private val onItemClick: (plan: PlanData) -> Unit ) : RecyclerView.Adapter<SubscriptionViewHolder>() {
private val subscriptions = mutableListOf<PlanData>()
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SubscriptionViewHolder {
val view = LayoutInflater.from(parent.context).inflate(R.layout.activity_item_subscription, parent, false)
return SubscriptionViewHolder(view)
}
override fun onBindViewHolder(holder: SubscriptionViewHolder, position: Int) {
val subscription = subscriptions[position]
holder.bind(subscription)
holder.itemView.setOnClickListener {
onItemClick(subscription) // 将点击的 position 传递给回调函数
}
}
override fun getItemCount(): Int {
return subscriptions.size
}
@SuppressLint("NotifyDataSetChanged")
fun setData(newData: List<PlanData>) {
subscriptions.clear()
subscriptions.addAll(newData)
notifyDataSetChanged()
}
@SuppressLint("NotifyDataSetChanged")
fun addData(newData: List<PlanData>) {
subscriptions.addAll(newData)
notifyDataSetChanged()
}
}
class SubscriptionViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val planName: TextView = itemView.findViewById(R.id.subscriptionName)
private val planPrice: TextView = itemView.findViewById(R.id.subscriptionTime)
private val planDescription: TextView = itemView.findViewById(R.id.subscriptionDescription)
private val grouponemonth: RelativeLayout = itemView.findViewById(R.id.grouponemohtthtest)
private val groupquitytthtest: RelativeLayout = itemView.findViewById(R.id.groupquitytthtest)
private val grouphelfyear: RelativeLayout = itemView.findViewById(R.id.grouphelfyear)
private val grouponeyear: RelativeLayout = itemView.findViewById(R.id.grouponeyear)
private val onemohtthtest: TextView = itemView.findViewById(R.id.onemohtthtest)
private val onequietythtest: TextView = itemView.findViewById(R.id.onequietythtest)
private val helfyearthtest: TextView = itemView.findViewById(R.id.helfyearthtest)
private val oneyearthtest: TextView = itemView.findViewById(R.id.oneyearthtest)
private val onemonthprice: TextView = itemView.findViewById(R.id.onemonthprice)
private val onequietyprice: TextView = itemView.findViewById(R.id.onequietyprice)
private val helfyearprice: TextView = itemView.findViewById(R.id.helfyearprice)
private val oneyearprice: TextView = itemView.findViewById(R.id.oneyearprice)
private val onemonthpriceday: TextView = itemView.findViewById(R.id.onemonthpriceday)
private val onequietypriceday: TextView = itemView.findViewById(R.id.onequietypriceday)
private val helfyearpriceday: TextView = itemView.findViewById(R.id.helfyearpriceday)
private val oneyearpriceday: TextView = itemView.findViewById(R.id.oneyearpriceday)
fun bind(data: PlanData) {
// 绑定数据
planName.text = data.name
if (data.onetime_price == null) {
planPrice.text = "流量:${data.transfer_enable ?: 0}GB"
planDescription.text = "¥${(data.month_price ?: 0.00).toDouble()/100}"
val month_price = (data.month_price ?: 0.00).toDouble()/100
val quarter_price = (data.quarter_price ?: 0.00).toDouble()/100
val half_year_price = (data.half_year_price ?: 0.00).toDouble()/100
val year_price = (data.year_price ?: 0.00).toDouble()/100
if (month_price > 0.0) {
onemonthprice.text = "${month_price}"
onemonthpriceday.text = "${String.format ("%.2f",month_price/30)}元/天"
}else{
grouponemonth.visibility = View.GONE
}
if (quarter_price > 0.0) {
onequietyprice.text = "${quarter_price}"
onequietypriceday.text = "${String.format ("%.2f",quarter_price/90)}元/天"
}else{
groupquitytthtest.visibility = View.GONE
}
if (half_year_price > 0.0) {
helfyearprice.text = "${half_year_price}"
helfyearpriceday.text = "${String.format ("%.2f",half_year_price/180)}元/天"
}else{
grouphelfyear.visibility = View.GONE
}
if (year_price > 0.0) {
oneyearprice.text = "${year_price}"
oneyearpriceday.text = "${String.format ("%.2f",year_price/360)}元/天"
}else{
grouponeyear.visibility = View.GONE
}
}else{
onemohtthtest.text = "一次性"
planPrice.text = "流量:${data.transfer_enable ?: 0}GB 一次性"
planDescription.text = "¥${(data.onetime_price ?: 0.00).toDouble()/100}"
val month_price = (data.onetime_price ?: 0.00).toDouble()/100
val transform = data.transfer_enable ?: 0
onemonthprice.text = "${month_price}"
onemonthpriceday.text = "${String.format ("%.2f",month_price/transform)}元/GB"
groupquitytthtest.visibility = View.GONE
grouphelfyear.visibility = View.GONE
grouponeyear.visibility = View.GONE
}
//// 如果需要加载图片,可以使用图像加载库,如 Glide 或 Picasso
// Glide.with(itemView.context)
// .load(subscription.planImageUrl)
// .into(planImage)
}
}
@@ -0,0 +1,59 @@
package com.github.kr328.clash.design.adapter
import android.content.Context
import android.graphics.Color
import android.view.View
import android.view.ViewGroup
import android.widget.BaseAdapter
import android.widget.TextView
import com.github.kr328.clash.design.R
import com.github.kr328.clash.design.util.layoutInflater
import com.github.kr328.clash.design.util.resolveThemedColor
class PopupListAdapter(
private val context: Context,
private val texts: List<CharSequence>,
private val selected: Int,
) : BaseAdapter() {
private val colorPrimary = context.resolveThemedColor(R.attr.colorPrimary)
private val colorOnPrimary = context.resolveThemedColor(R.attr.colorOnPrimary)
private val colorControlNormal = context.resolveThemedColor(R.attr.colorControlNormal)
override fun getCount(): Int {
return texts.size
}
override fun getItem(position: Int): Any {
return texts[position]
}
override fun getItemId(position: Int): Long {
return texts[position].hashCode().toLong()
}
override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View {
val view = convertView ?: context.layoutInflater
.inflate(android.R.layout.simple_list_item_1, parent, false)
val text: TextView = view.findViewById(android.R.id.text1)
text.text = texts[position]
if (position == selected) {
text.setBackgroundColor(
Color.argb(
200,
Color.red(colorPrimary),
Color.green(colorPrimary),
Color.blue(colorPrimary)
)
)
text.setTextColor(colorOnPrimary)
} else {
text.setBackgroundColor(Color.TRANSPARENT)
text.setTextColor(colorControlNormal)
}
return view
}
}
@@ -0,0 +1,56 @@
package com.github.kr328.clash.design.adapter
import android.content.Context
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.github.kr328.clash.design.databinding.AdapterProfileBinding
import com.github.kr328.clash.design.model.ProfilePageState
import com.github.kr328.clash.design.model.ProxyPageState
import com.github.kr328.clash.design.ui.ObservableCurrentTime
import com.github.kr328.clash.design.util.layoutInflater
import com.github.kr328.clash.service.model.Profile
class ProfileAdapter(
private val context: Context,
private val onClicked: (Profile) -> Unit,
private val onMenuClicked: (Profile) -> Unit,
) : RecyclerView.Adapter<ProfileAdapter.Holder>() {
class Holder(val binding: AdapterProfileBinding) : RecyclerView.ViewHolder(binding.root)
private val currentTime = ObservableCurrentTime()
var profiles: List<Profile> = emptyList()
val states = ProfilePageState()
fun updateElapsed() {
currentTime.update()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
return Holder(
AdapterProfileBinding
.inflate(context.layoutInflater, parent, false)
.also { it.currentTime = currentTime }
)
}
override fun onBindViewHolder(holder: Holder, position: Int) {
val current = profiles[position]
val binding = holder.binding
if (current === binding.profile)
return
binding.profile = current
binding.setClicked {
onClicked(current)
}
binding.setMenu {
onMenuClicked(current)
}
}
override fun getItemCount(): Int {
return profiles.size
}
}
@@ -0,0 +1,48 @@
package com.github.kr328.clash.design.adapter
import android.content.Context
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.github.kr328.clash.design.databinding.AdapterProfileProviderBinding
import com.github.kr328.clash.design.model.ProfileProvider
import com.github.kr328.clash.design.util.layoutInflater
class ProfileProviderAdapter(
private val context: Context,
private val select: (ProfileProvider) -> Unit,
private val detail: (ProfileProvider) -> Boolean,
) : RecyclerView.Adapter<ProfileProviderAdapter.Holder>() {
class Holder(val binding: AdapterProfileProviderBinding) : RecyclerView.ViewHolder(binding.root)
var providers: List<ProfileProvider> = emptyList()
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
return Holder(
AdapterProfileProviderBinding.inflate(
context.layoutInflater,
parent,
false
)
)
}
override fun onBindViewHolder(holder: Holder, position: Int) {
val current = providers[position]
val binding = holder.binding
binding.provider = current
binding.root.apply {
setOnClickListener {
select(current)
}
setOnLongClickListener {
detail(current)
}
}
}
override fun getItemCount(): Int {
return providers.size
}
}
@@ -0,0 +1,68 @@
package com.github.kr328.clash.design.adapter
import android.content.Context
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.github.kr328.clash.core.model.Provider
import com.github.kr328.clash.design.databinding.AdapterProviderBinding
import com.github.kr328.clash.design.model.ProviderState
import com.github.kr328.clash.design.ui.ObservableCurrentTime
import com.github.kr328.clash.design.util.layoutInflater
class ProviderAdapter(
private val context: Context,
providers: List<Provider>,
private val requestUpdate: (Int, Provider) -> Unit,
) : RecyclerView.Adapter<ProviderAdapter.Holder>() {
class Holder(val binding: AdapterProviderBinding) : RecyclerView.ViewHolder(binding.root)
private val currentTime = ObservableCurrentTime()
val states = providers.map { ProviderState(it, it.updatedAt, false) }
fun updateElapsed() {
currentTime.update()
}
fun notifyUpdated(index: Int) {
states[index].apply {
updating = false
}
notifyItemChanged(index)
}
fun notifyChanged(index: Int) {
states[index].apply {
updating = false
updatedAt = System.currentTimeMillis()
}
notifyItemChanged(index)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
return Holder(
AdapterProviderBinding
.inflate(context.layoutInflater, parent, false)
.also { it.currentTime = currentTime }
)
}
override fun onBindViewHolder(holder: Holder, position: Int) {
val state = states[position]
holder.binding.provider = state.provider
holder.binding.state = state
holder.binding.update = View.OnClickListener {
state.updating = true
requestUpdate(position, state.provider)
}
}
override fun getItemCount(): Int {
return states.size
}
}
@@ -0,0 +1,204 @@
package com.github.kr328.clash.design.adapter
import android.content.Context
import android.graphics.Color
import android.graphics.drawable.Drawable
import android.util.Log
import com.github.kr328.clash.design.component.ProxyView
import com.github.kr328.clash.design.component.ProxyViewConfig
/*
class ProxyAdapter(
private val config: ProxyViewConfig,
private val clicked: (String) -> Unit,
) : RecyclerView.Adapter<ProxyAdapter.Holder>() {
class Holder(val view: ProxyView) : RecyclerView.ViewHolder(view)
var selectable: Boolean = false
var states: List<ProxyViewState> = emptyList()
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
return Holder(ProxyView(config.context, config))
}
override fun onBindViewHolder(holder: Holder, position: Int) {
val current = states[position]
holder.view.apply {
state = current
setOnClickListener {
clicked(current.proxy.name)
}
val isSelector = selectable
isFocusable = isSelector
isClickable = isSelector
current.update(true)
}
}
override fun getItemCount(): Int {
return states.size
}
}
*/
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.DrawableCompat
import androidx.recyclerview.widget.RecyclerView
import com.github.kr328.clash.design.R
import com.github.kr328.clash.design.component.ProxyViewState
import com.github.kr328.clash.design.databinding.DesignProxyBinding
class ProxyAdapter(
private val config: ProxyViewConfig,
private val clicked: (String) -> Unit,
) : RecyclerView.Adapter<ProxyAdapter.Holder>() {
// class Holder(val binding: DesignProxyBinding) : RecyclerView.ViewHolder(binding.root)
class Holder(view: View) : RecyclerView.ViewHolder(view) {
val vpnnodemainid:View = view.findViewById(R.id.vpnnodemainid)
val proxyName: TextView = view.findViewById(R.id.proxy_name)
val proxyLatency: TextView = view.findViewById(R.id.proxy_latency)
val proxySubName:TextView = view.findViewById(R.id.proxy_subtitle)
val selectnodeselectImage:ImageView = view.findViewById(R.id.nodeselectImage)
val viewsss:View = view.findViewById(R.id.view_iteminfo)
var drawableStartGreen: Drawable? = null // 用于存储 drawableStart 图标
var drawableStartGray: Drawable? = null // 用于存储 drawableStart 图标
}
var selectable: Boolean = false
var states: List<ProxyViewState> = emptyList()
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ProxyAdapter.Holder {
val drawableStartGreen = ContextCompat.getDrawable(parent.context, R.drawable.check_24px)?.let {
// 包装 drawable 以支持 Tint
val wrappedDrawable = DrawableCompat.wrap(it)
// 设置 Tint 颜色
DrawableCompat.setTint(wrappedDrawable, Color.parseColor("#2a9843"))
wrappedDrawable
}
val drawableStartGray = ContextCompat.getDrawable(parent.context, R.drawable.ic_baseline_info)?.let {
// 包装 drawable 以支持 Tint
val wrappedDrawable = DrawableCompat.wrap(it)
// 设置 Tint 颜色
DrawableCompat.setTint(wrappedDrawable, Color.parseColor("#6b6d6a"))
wrappedDrawable
}
val view = LayoutInflater.from(parent.context).inflate(R.layout.proxy_view_item, parent, false)
return Holder(view).apply {
// 设置 drawable 到 ViewHolder 的属性,供后续使用
this.drawableStartGreen = drawableStartGreen
this.drawableStartGray = drawableStartGray
}
}
var selectableView: Holder? = null
var selectableData: ProxyViewState? = null
var state: ProxyViewState? = null
override fun onBindViewHolder(holder: Holder, position: Int) {
val current = states[position]
holder.apply {
state = current
viewsss.setBackgroundColor(Color.parseColor("#10FFFFFF")) // Semi-transparent white color
proxyName.text = current.proxy.name
//proxySubName.setColorFilter(ContextCompat.getColor(context, R.color.yourColor), PorterDuff.Mode.SRC_IN)
if (current.proxy.delay > 10000){
proxySubName.text = "超时" //current.proxy.subtitle
proxySubName.setTextColor(Color.parseColor("#6b6d6a"))
proxySubName.setCompoundDrawablesWithIntrinsicBounds(drawableStartGray, null, null, null)
proxyLatency.setTextColor(Color.parseColor("#6b6d6a"))
proxyLatency.text = "..."
}else if (current.proxy.delay > 500 && current.proxy.delay < 10000){
proxyLatency.text = "${current.proxy.delay}ms"
proxyLatency.setTextColor(Color.YELLOW)
proxySubName.text = "在线可用" //current.proxy.subtitle
proxySubName.setTextColor(Color.parseColor("#2a9843"))
proxySubName.setCompoundDrawablesWithIntrinsicBounds(drawableStartGreen, null, null, null)
}else if (current.proxy.delay < 500 && current.proxy.delay > 300){
proxyLatency.text = "${current.proxy.delay}ms"
proxyLatency.setTextColor(Color.parseColor("#fab610"))
proxySubName.text = "在线可用" //current.proxy.subtitle
proxySubName.setTextColor(Color.parseColor("#2a9843"))
proxySubName.setCompoundDrawablesWithIntrinsicBounds(drawableStartGreen, null, null, null)
}else{
proxyLatency.text = "${current.proxy.delay}ms"
proxyLatency.setTextColor(Color.parseColor("#2a9843"))
proxySubName.text = "在线可用" //current.proxy.subtitle
proxySubName.setTextColor(Color.parseColor("#2a9843"))
proxySubName.setCompoundDrawablesWithIntrinsicBounds(drawableStartGreen, null, null, null)
}
// 示例:延迟时间
// current.update(true)
vpnnodemainid.setOnClickListener {
if (selectableView != null) {
if(selectableView == holder){
//do nothing
}else{
//之前的设置为未选择
selectableData?.selected = false
selectableView?.selectnodeselectImage?.setImageResource( android.R.color.transparent)
current.selected = true
selectableView = holder
selectableData = current
clicked(current.proxy.name)
current.update(true)
selectnodeselectImage.setImageResource((R.drawable.checkmarkcirclefill))
}
}else
{
selectableView?.selectnodeselectImage?.setImageResource( android.R.color.transparent)
current.selected = true
selectableView = holder
selectableData = current
clicked(current.proxy.name)
current.update(true)
selectnodeselectImage.setImageResource((R.drawable.checkmarkcirclefill))
}
}
}
current.update(true)
if (current.selected){
selectableView = holder
selectableData = current
}
holder.selectnodeselectImage.setImageResource(if (current.selected) R.drawable.checkmarkcirclefill else android.R.color.transparent)
}
override fun getItemCount(): Int {
return states.size
}
}
@@ -0,0 +1,144 @@
package com.github.kr328.clash.design.adapter
import android.util.Log
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.core.model.Proxy
import com.github.kr328.clash.design.R
import com.github.kr328.clash.design.component.ProxyPageFactory
import com.github.kr328.clash.design.component.ProxyViewConfig
import com.github.kr328.clash.design.component.ProxyViewState
import com.github.kr328.clash.design.model.ProxyPageState
import com.github.kr328.clash.design.model.ProxyState
import com.github.kr328.clash.design.ui.Surface
import com.github.kr328.clash.design.util.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class ProxyPageAdapter(
private val surface: Surface,
private val config: ProxyViewConfig,
private val adapters: List<ProxyAdapter>,
private val stateChanged: (Int) -> Unit,
) : RecyclerView.Adapter<ProxyPageFactory.Holder>() { //<ProxyPageAdapter.VPNViewHolder>(){ //
private val factory = ProxyPageFactory(config)
private var parent: RecyclerView? = null
val states = List(adapters.size) { ProxyPageState() }
suspend fun updateAdapter(
position: Int,
proxies: List<Proxy>,
selectable: Boolean,
parent: ProxyState,
links: Map<String, ProxyState>
) {
val states = withContext(Dispatchers.Default) {
proxies.map {
val link = if (it.type.group) links[it.name] else null
ProxyViewState(config, it, parent, link)
}
}
withContext(Dispatchers.Main) {
adapters[position].apply {
this.selectable = selectable
this.swapDataSet(this::states, states, false)
}
requestRedrawVisible()
}
}
fun requestRedrawVisible() {
factory.fromRoot(parent?.firstVisibleView ?: return)
.recyclerView.invalidateChildren()
}
/*
// ViewHolder class to hold and recycle views for RecyclerView items
inner class VPNViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
val flagImageView: ImageView = itemView.findViewById(R.id.flagImageView)
val vpnInfoTextView: TextView = itemView.findViewById(R.id.vpnInfoTextView)
val speedTextView: TextView = itemView.findViewById(R.id.speedTextView)
}
// This function inflates the item layout and returns the ViewHolder
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VPNViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_vpn, parent, false)
return VPNViewHolder(view)
}
override fun onBindViewHolder(holder: VPNViewHolder, position: Int) {
val adapter = adapters[position]
states[position].bottom = false
Log.i("onBindViewHolder"," ${ adapter.states.size}")
// holder.flagImageView.setImageResource(vpn.countryFlag)
holder.vpnInfoTextView.text = "xxx"
holder.speedTextView.text = "xx"
}
*/
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ProxyPageFactory.Holder {
val holder = factory.newInstance()
val toolbarHeight = config.context.getPixels(R.dimen.toolbar_height)
val tabHeight = config.context.getPixels(R.dimen.tab_layout_height)
holder.recyclerView.bindInsets(surface, toolbarHeight + tabHeight)
holder.recyclerView.addScrolledToBottomObserver { view, bottom ->
val position = view.position
val state = states[position]
if (state.bottom != bottom) {
state.bottom = bottom
stateChanged(position)
}
}
return holder
}
override fun onBindViewHolder(holder: ProxyPageFactory.Holder, position: Int) {
val adapter = adapters[position]
states[position].bottom = false
holder.recyclerView.apply {
this.position = position
this.swapAdapter(adapter, false)
}
}
override fun getItemCount(): Int {
return adapters.size
}
override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
this.parent = recyclerView
recyclerView.isFocusable = false
}
override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) {
this.parent = null
}
private var RecyclerView.position: Int
get() {
return tag as? Int ?: -1
}
set(value) {
tag = value
}
}
@@ -0,0 +1,278 @@
package com.github.kr328.clash.design.adapter
import android.annotation.SuppressLint
import android.graphics.Color
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.core.model.Proxy
import com.github.kr328.clash.design.R
import com.github.kr328.clash.design.adapter.ProxyAdapter.Holder
import com.github.kr328.clash.design.component.ProxyViewConfig
import com.github.kr328.clash.design.component.ProxyViewState
import com.github.kr328.clash.design.model.ProxyPageState
import com.github.kr328.clash.design.model.ProxyState
import com.github.kr328.clash.design.ui.Surface
import com.github.kr328.clash.design.util.firstVisibleView
import com.github.kr328.clash.design.util.invalidateChildren
import com.github.kr328.clash.design.util.swapDataSet
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.withContext
import okhttp3.internal.filterList
//private val stateChanged: (Int) -> Unit,
class ServerListAdapter(private val surface: Surface,
private val config: ProxyViewConfig,
private val clicked: (String) -> Unit,) :
RecyclerView.Adapter<ServerListAdapter.ViewHolder>() {
class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
val flag: ImageView = view.findViewById(R.id.country_flag)
val vpnnodemainid:View = view.findViewById(R.id.vpnnodemainid)
val serverInfo: TextView = view.findViewById(R.id.server_info)
val serverLimit: TextView = view.findViewById(R.id.server_limit)
val signal: ImageView = view.findViewById(R.id.signal_strength)
val latency: TextView = view.findViewById(R.id.latency)
}
companion object {
private const val HEADER_VIEW_TYPE = 0
private const val ITEM_VIEW_TYPE = 1
}
override fun getItemViewType(position: Int): Int {
// 判断是否是第一行,如果是,返回 HEADER_VIEW_TYPE,否则返回 ITEM_VIEW_TYPE
return if (position == 0) HEADER_VIEW_TYPE else ITEM_VIEW_TYPE
}
var urlTesting: Boolean = true
var servers: List<ProxyViewState> = emptyList()
var selectable: Boolean = false
suspend fun updateAdapter(
position: Int,
proxies: List<Proxy>,
selectable: Boolean,
parent: ProxyState,
links: Map<String, ProxyState>
) {
var newlistProxy = mutableListOf<Proxy>()
// newlistProxy.add(Proxy("智能连接","智能连接","自动切换速度最快的节点",Proxy.Type.Unknown,0))
newlistProxy.addAll(proxies)
val states = withContext(Dispatchers.Default) {
newlistProxy.map {
val link = if (it.type.group) links[it.name] else null
ProxyViewState(config, it, parent, link)
}
}
/* states.forEach {
println(">>>>> ${it.proxy.title} ${it.proxy.subtitle} ${it.proxy.type.name} ${it.proxy.delay}")
}*/
val newstates = states.filterNot {
it.proxy.title.uppercase() == "DIRECT"
|| it.proxy.title.uppercase() == "REJECT"
|| it.proxy.subtitle == "Selector"
|| it.proxy.title.contains("流量")
|| it.proxy.title.contains("故障转移")
|| it.proxy.title.contains("套餐到期")
|| it.proxy.title.contains("节点异常")
|| it.proxy.title.contains("续费")
|| it.proxy.title.contains("充值")
|| it.proxy.title.contains("官网")
|| it.proxy.title.contains("剩余")
}
this.swapDataSet(this::servers, newstates, false)
withContext(Dispatchers.Main) {
requestRedrawVisible()
}
}
@SuppressLint("NotifyDataSetChanged")
fun requestRedrawVisible() {
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.server_item_layout, parent, false)
return ViewHolder(view)
}
val countryMap = mapOf(
"美国" to "us",
"United States" to "us",
"中国" to "zh",
"China" to "zh",
"香港" to "hk",
"Hong" to "hk",
"日本" to "jp",
"Japan" to "jp",
"新加坡" to "sg",
"Singapore" to "sg",
"越南" to "vn",
"Vietnam" to "vn",
"马来西亚" to "my",
"Malaysia" to "my",
"泰国" to "th",
"Thailand" to "th",
"韩国" to "kr",
"South Korea" to "kr",
"巴西" to "br",
"Brazil" to "br",
"印度" to "in",
"India" to "in",
"智利" to "cl",
"Chile" to "cl",
"迪拜" to "ae",
"United Arab" to "ae",
"德国" to "de",
"Germany" to "de",
"法国" to "fr",
"France" to "fr",
"英国" to "gb",
"Great Britain" to "gb",
"意大利" to "it",
"Italy" to "it",
"澳大利亚" to "au",
"Australia" to "au",
"土耳其" to "tr",
"台湾" to "tw",
"Turkey" to "tr"
// 添加其他国家映射
)
fun getCountryCodeFromLog(logLine: String): String? {
// 遍历 countryMap,找到匹配的国家名称
for ((countryName, countryCode) in countryMap) {
if (logLine.contains(countryName)) {
return countryCode
}
}
return "" // 没有匹配的国家名称
}
var state: ProxyViewState? = null
var selectableView: ViewHolder? = null
var selectableData: ProxyViewState? = null
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val server = servers[position]
server.update(true)
holder.apply {
state = server
}
if (server.selected){
selectableView = holder
selectableData = server
}
val flog_name = getCountryCodeFromLog(server.title) ?: ""
val resourceId = holder.itemView.context.resources.getIdentifier("country_flag_"+flog_name, "drawable", holder.itemView.context.packageName)
// 检查资源 ID 是否有效
if (resourceId != 0) {
holder.flag.setImageResource(resourceId)
}else{
val aiicon = holder.itemView.context.resources.getIdentifier("icon_ai", "drawable", holder.itemView.context.packageName)
holder.flag.setImageResource(aiicon)
}
holder.serverInfo.text = server.title
holder.serverInfo.text = server.title
if (server.title == "自动选择"){
holder.serverLimit.text = "智能匹配最优路线"
}else{
holder.serverLimit.text = server.subtitle
}
//holder.signal.setImageResource(server.signalRes)
if ( server.proxy.delay > 5000){
val resourcelow = holder.itemView.context.resources.getIdentifier("ic_signal_no", "drawable", holder.itemView.context.packageName)
holder.signal.setImageResource(resourcelow)
}else if (server.proxy.delay >= 1000 && server.proxy.delay < 5000){
val resourcelow = holder.itemView.context.resources.getIdentifier("ic_signal_low", "drawable", holder.itemView.context.packageName)
holder.signal.setImageResource(resourcelow)
}else if (server.proxy.delay > 500 && server.proxy.delay < 1000){
val resourcelow = holder.itemView.context.resources.getIdentifier("ic_signal_two", "drawable", holder.itemView.context.packageName)
holder.signal.setImageResource(resourcelow)
}else if (server.proxy.delay < 500 && server.proxy.delay > 300){
val resourcelow = holder.itemView.context.resources.getIdentifier("ic_signal_three", "drawable", holder.itemView.context.packageName)
holder.signal.setImageResource(resourcelow)
}else{
val resourcelow = holder.itemView.context.resources.getIdentifier("ic_signal_center", "drawable", holder.itemView.context.packageName)
holder.signal.setImageResource(resourcelow)
}
if (server.delayText.length > 0){
holder.latency.text = " ${server.delayText}ms"
}else{
holder.latency.text = " 0ms"
}
if (server.selected)
{
holder.serverInfo.setTextColor(Color.parseColor("#2a9843"))
}else{
holder.serverInfo.setTextColor(Color.parseColor("#888888"))
}
val current = server
holder.vpnnodemainid.setOnClickListener {
// selectable = true
// server.selected = true
if (selectableView != null) {
if(selectableView == holder){
//do nothing
clicked(current.proxy.name)
}else{
//之前的设置为未选择
selectableData?.selected = false
current.selected = true
selectableView = holder
selectableData = current
clicked(current.proxy.name)
current.update(true)
}
}else
{
current.selected = true
selectableView = holder
selectableData = current
clicked(current.proxy.name)
current.update(true)
}
}
}
override fun getItemCount(): Int = servers.size
}
@@ -0,0 +1,114 @@
package com.github.kr328.clash.design.adapter
import android.annotation.SuppressLint
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import com.github.kr328.clash.design.R
import com.github.kr328.clash.network.TicketMessage
import com.github.kr328.clash.network.TicketsData
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
class TicketDetailAdapter( private val onItemClick: (plan: TicketMessage) -> Unit ) : RecyclerView.Adapter<RecyclerView.ViewHolder >() {
companion object {
const val VIEW_TYPE_LEFT = 0
const val VIEW_TYPE_RIGHT = 1
}
private val subscriptions = mutableListOf<TicketMessage>()
override fun getItemViewType(position: Int): Int {
return if (subscriptions[position].is_me ?: false) VIEW_TYPE_RIGHT else VIEW_TYPE_LEFT
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return if (viewType == VIEW_TYPE_RIGHT) {
val view = LayoutInflater.from(parent.context).inflate(R.layout.activity_item_message_right, parent, false)
RightTicketDetailViewHolder(view)
} else {
val view = LayoutInflater.from(parent.context).inflate(R.layout.activity_item_message_left, parent, false)
LeftTicketDetailViewHolder(view)
}
// val viewlef = LayoutInflater.from(parent.context).inflate(R.layout.activity_item_message_left, parent, false)
// return TicketDetailViewHolder(viewlef)
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
val message = subscriptions[position]
if (holder is RightTicketDetailViewHolder) {
holder.bind(message)
} else if (holder is LeftTicketDetailViewHolder) {
holder.bind(message)
}
}
// override fun onBindViewHolder(holder: TicketDetailViewHolder, position: Int) {
// val subscription = subscriptions[position]
// holder.bind(subscription)
// holder.itemView.setOnClickListener {
// onItemClick(subscription) // 将点击的 position 传递给回调函数
// }
// }
override fun getItemCount(): Int {
return subscriptions.size
}
@SuppressLint("NotifyDataSetChanged")
fun setData(newData: List<TicketMessage>) {
subscriptions.clear()
subscriptions.addAll(newData)
notifyDataSetChanged()
}
@SuppressLint("NotifyDataSetChanged")
fun addData(newData: List<TicketMessage>) {
subscriptions.addAll(newData)
notifyDataSetChanged()
}
}
class LeftTicketDetailViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val titleTextView: TextView = itemView.findViewById(R.id.messageTextView)
private val creationTimeTextView: TextView = itemView.findViewById(R.id.timeTextView)
fun formatTimestamp(timestamp: Long): String {
val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) // 定义格式
val date = Date(timestamp*1000L) // 将时间戳转为 Date 对象
return dateFormat.format(date) // 格式化为字符串
}
fun bind(data: TicketMessage) {
// 绑定数据
titleTextView.text = "${data.message ?: ""}"
creationTimeTextView.text = "${formatTimestamp(data.created_at ?: 0)}"
}
}
class RightTicketDetailViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val titleTextView: TextView = itemView.findViewById(R.id.messageTextView)
private val creationTimeTextView: TextView = itemView.findViewById(R.id.timeTextView)
fun formatTimestamp(timestamp: Long): String {
val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) // 定义格式
val date = Date(timestamp*1000L) // 将时间戳转为 Date 对象
return dateFormat.format(date) // 格式化为字符串
}
fun bind(data: TicketMessage) {
// 绑定数据
titleTextView.text = "${data.message ?: ""}"
creationTimeTextView.text = "${formatTimestamp(data.created_at ?: 0)}"
}
}
@@ -0,0 +1,98 @@
package com.github.kr328.clash.design.adapter
import android.annotation.SuppressLint
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import com.github.kr328.clash.common.util.intent
import com.github.kr328.clash.design.R
import com.github.kr328.clash.design.util.showCustomDialog
import com.github.kr328.clash.network.TicketsData
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
//class
class TicketsDataAdapter( private val onItemClick: (plan: TicketsData) -> Unit , private val onCloseItemClick: (plan: TicketsData) -> Unit ) : RecyclerView.Adapter<TicketViewHolder>() {
private val subscriptions = mutableListOf<TicketsData>()
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TicketViewHolder {
val view = LayoutInflater.from(parent.context).inflate(R.layout.activity_gondan_item, parent, false)
return TicketViewHolder(view)
}
override fun onBindViewHolder(holder: TicketViewHolder, position: Int) {
val subscription = subscriptions[position]
holder.bind(subscription)
holder.itemView.setOnClickListener {
onItemClick(subscription) // 将点击的 position 传递给回调函数
}
holder.viewButton.setOnClickListener {
onItemClick(subscription) // 将点击的 position 传递给回调函数
}
holder.closeButton.setOnClickListener {
onCloseItemClick(subscription)
}
}
override fun getItemCount(): Int {
return subscriptions.size
}
@SuppressLint("NotifyDataSetChanged")
fun setData(newData: List<TicketsData>) {
subscriptions.clear()
subscriptions.addAll(newData)
notifyDataSetChanged()
}
@SuppressLint("NotifyDataSetChanged")
fun addData(newData: List<TicketsData>) {
subscriptions.addAll(newData)
notifyDataSetChanged()
}
}
class TicketViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val titleTextView: TextView = itemView.findViewById(R.id.titleTextView)
private val creationTimeTextView: TextView = itemView.findViewById(R.id.creationTimeTextView)
private val statusTextView: TextView = itemView.findViewById(R.id.statusTextView)
val closeButton: Button = itemView.findViewById(R.id.closeButton)
val viewButton: TextView = itemView.findViewById(R.id.viewButton)
fun formatTimestamp(timestamp: Long): String {
val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) // 定义格式
val date = Date(timestamp*1000L) // 将时间戳转为 Date 对象
return dateFormat.format(date) // 格式化为字符串
}
fun bind(data: TicketsData) {
// 绑定数据
titleTextView.text = "#${data.id ?: 0} - ${data.subject ?: ""}"
creationTimeTextView.text = "创建时间: ${formatTimestamp(data.created_at ?: 0)}"
if (data.status == 0){
statusTextView.text = "当前状态: 待回复"
closeButton.visibility = View.VISIBLE
}else{
statusTextView.text = "当前状态: 已关闭"
closeButton.visibility = View.GONE
}
//// 如果需要加载图片,可以使用图像加载库,如 Glide 或 Picasso
// Glide.with(itemView.context)
// .load(subscription.planImageUrl)
// .into(planImage)
}
}
@@ -0,0 +1,97 @@
package com.github.kr328.clash.design.component
import android.content.Context
import android.view.MenuItem
import android.view.View
import androidx.appcompat.widget.PopupMenu
import com.github.kr328.clash.design.AccessControlDesign.Request
import com.github.kr328.clash.design.R
import com.github.kr328.clash.design.model.AppInfoSort
import com.github.kr328.clash.design.store.UiStore
import kotlinx.coroutines.channels.Channel
class AccessControlMenu(
context: Context,
menuView: View,
private val uiStore: UiStore,
private val requests: Channel<Request>,
) : PopupMenu.OnMenuItemClickListener {
private val menu = PopupMenu(context, menuView)
fun show() {
menu.show()
}
override fun onMenuItemClick(item: MenuItem): Boolean {
if (item.isCheckable)
item.isChecked = !item.isChecked
when (item.itemId) {
R.id.select_all ->
requests.trySend(Request.SelectAll)
R.id.select_none ->
requests.trySend(Request.SelectNone)
R.id.select_invert ->
requests.trySend(Request.SelectInvert)
R.id.system_apps -> {
uiStore.accessControlSystemApp = !item.isChecked
requests.trySend(Request.ReloadApps)
}
R.id.name -> {
uiStore.accessControlSort = AppInfoSort.Label
requests.trySend(Request.ReloadApps)
}
R.id.package_name -> {
uiStore.accessControlSort = AppInfoSort.PackageName
requests.trySend(Request.ReloadApps)
}
R.id.install_time -> {
uiStore.accessControlSort = AppInfoSort.InstallTime
requests.trySend(Request.ReloadApps)
}
R.id.update_time -> {
uiStore.accessControlSort = AppInfoSort.UpdateTime
requests.trySend(Request.ReloadApps)
}
R.id.reverse -> {
uiStore.accessControlReverse = item.isChecked
requests.trySend(Request.ReloadApps)
}
R.id.import_from_clipboard -> {
requests.trySend(Request.Import)
}
R.id.export_to_clipboard -> {
requests.trySend(Request.Export)
}
else -> return false
}
return true
}
init {
menu.menuInflater.inflate(R.menu.menu_access_control, menu.menu)
when (uiStore.accessControlSort) {
AppInfoSort.Label ->
menu.menu.findItem(R.id.name).isChecked = true
AppInfoSort.PackageName ->
menu.menu.findItem(R.id.package_name).isChecked = true
AppInfoSort.InstallTime ->
menu.menu.findItem(R.id.install_time).isChecked = true
AppInfoSort.UpdateTime ->
menu.menu.findItem(R.id.update_time).isChecked = true
}
menu.menu.findItem(R.id.system_apps).isChecked = !uiStore.accessControlSystemApp
menu.menu.findItem(R.id.reverse).isChecked = uiStore.accessControlReverse
menu.setOnMenuItemClickListener(this)
}
}
@@ -0,0 +1,120 @@
package com.github.kr328.clash.design.component
import android.content.Context
import android.view.MenuItem
import android.view.View
import androidx.appcompat.widget.PopupMenu
import com.github.kr328.clash.core.model.ProxySort
import com.github.kr328.clash.core.model.TunnelState
import com.github.kr328.clash.design.ProxyDesign
import com.github.kr328.clash.design.R
import com.github.kr328.clash.design.store.UiStore
import kotlinx.coroutines.channels.Channel
class ProxyMenu(
context: Context,
menuView: View,
mode: TunnelState.Mode?,
private val uiStore: UiStore,
private val requests: Channel<ProxyDesign.Request>,
private val updateConfig: () -> Unit,
) : PopupMenu.OnMenuItemClickListener {
private val menu = PopupMenu(context, menuView)
fun show() {
menu.show()
}
override fun onMenuItemClick(item: MenuItem): Boolean {
item.isChecked = !item.isChecked
when (item.itemId) {
R.id.not_selectable -> {
uiStore.proxyExcludeNotSelectable = item.isChecked
requests.trySend(ProxyDesign.Request.ReLaunch)
}
R.id.single -> {
uiStore.proxyLine = 1
updateConfig()
requests.trySend(ProxyDesign.Request.ReloadAll)
}
R.id.doubles -> {
uiStore.proxyLine = 1
updateConfig()
requests.trySend(ProxyDesign.Request.ReloadAll)
}
R.id.multiple -> {
uiStore.proxyLine = 3
updateConfig()
requests.trySend(ProxyDesign.Request.ReloadAll)
}
R.id.default_ -> {
uiStore.proxySort = ProxySort.Default
requests.trySend(ProxyDesign.Request.ReloadAll)
}
R.id.name -> {
uiStore.proxySort = ProxySort.Title
requests.trySend(ProxyDesign.Request.ReloadAll)
}
R.id.delay -> {
uiStore.proxySort = ProxySort.Delay
requests.trySend(ProxyDesign.Request.ReloadAll)
}
R.id.dont_modify -> {
requests.trySend(ProxyDesign.Request.PatchMode(null))
}
R.id.direct_mode -> {
requests.trySend(ProxyDesign.Request.PatchMode(TunnelState.Mode.Direct))
}
R.id.global_mode -> {
requests.trySend(ProxyDesign.Request.PatchMode(TunnelState.Mode.Global))
}
R.id.rule_mode -> {
requests.trySend(ProxyDesign.Request.PatchMode(TunnelState.Mode.Rule))
}
else -> return false
}
return true
}
init {
menu.menuInflater.inflate(R.menu.menu_proxy, menu.menu)
menu.menu.apply {
findItem(R.id.not_selectable).isChecked = uiStore.proxyExcludeNotSelectable
when (uiStore.proxyLine){
1 -> findItem(R.id.single).isChecked = true
2 -> findItem(R.id.doubles).isChecked = true
3 -> findItem(R.id.multiple).isChecked = true
}
when (uiStore.proxySort) {
ProxySort.Default -> findItem(R.id.default_).isChecked = true
ProxySort.Title -> findItem(R.id.name).isChecked = true
ProxySort.Delay -> findItem(R.id.delay).isChecked = true
}
when (mode) {
null -> findItem(R.id.dont_modify).isChecked = true
TunnelState.Mode.Direct -> findItem(R.id.direct_mode).isChecked = true
TunnelState.Mode.Global -> findItem(R.id.global_mode).isChecked = true
TunnelState.Mode.Rule -> findItem(R.id.rule_mode).isChecked = true
else -> {}
}
}
menu.setOnMenuItemClickListener(this)
}
}
@@ -0,0 +1,61 @@
package com.github.kr328.clash.design.component
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.github.kr328.clash.design.view.VerticalScrollableHost
class ProxyPageFactory(private val config: ProxyViewConfig) {
class Holder(
val recyclerView: RecyclerView,
val root: View,
) : RecyclerView.ViewHolder(root)
private val childrenPool = RecyclerView.RecycledViewPool()
fun newInstance(): Holder {
val root = VerticalScrollableHost(config.context).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
}
val recyclerView = RecyclerView(config.context).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
}
root.addView(recyclerView)
recyclerView.apply {
layoutManager = GridLayoutManager(config.context, 6).apply {
spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
override fun getSpanSize(position: Int): Int {
var grids:Int = 0
when(config.proxyLine){
2 -> grids = 3
3 -> grids = 2
}
return if (config.proxyLine==1) 6 else grids
}
}
}
setRecycledViewPool(childrenPool)
clipToPadding = false
}
return Holder(recyclerView, root).apply {
root.tag = this
}
}
fun fromRoot(root: View): Holder {
return root.tag!! as Holder
}
}
@@ -0,0 +1,195 @@
package com.github.kr328.clash.design.component
import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.Path
import android.view.View
import com.github.kr328.clash.common.compat.getDrawableCompat
import com.github.kr328.clash.design.store.UiStore
class ProxyView(
context: Context,
config: ProxyViewConfig,
) : View(context) {
init {
background = context.getDrawableCompat(config.clickableBackground)
}
var state: ProxyViewState? = null
constructor(context: Context) : this(context, ProxyViewConfig(context, 2))
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val state = state ?: return super.onMeasure(widthMeasureSpec, heightMeasureSpec)
val width = when (MeasureSpec.getMode(widthMeasureSpec)) {
MeasureSpec.UNSPECIFIED ->
resources.displayMetrics.widthPixels
MeasureSpec.AT_MOST, MeasureSpec.EXACTLY ->
MeasureSpec.getSize(widthMeasureSpec)
else ->
throw IllegalArgumentException("invalid measure spec")
}
state.paint.apply {
reset()
textSize = state.config.textSize
getTextBounds("Stub!", 0, 1, state.rect)
}
val textHeight = state.rect.height()
val exceptHeight = (state.config.layoutPadding * 2 +
state.config.contentPadding * 2 +
textHeight * 2 +
state.config.textMargin).toInt()
val height = when (MeasureSpec.getMode(heightMeasureSpec)) {
MeasureSpec.UNSPECIFIED ->
exceptHeight
MeasureSpec.AT_MOST, MeasureSpec.EXACTLY ->
exceptHeight.coerceAtMost(MeasureSpec.getSize(heightMeasureSpec))
else ->
throw IllegalArgumentException("invalid measure spec")
}
setMeasuredDimension(width, height)
}
override fun draw(canvas: Canvas) {
val state = state ?: return super.draw(canvas)
if (state.update(false))
postInvalidate()
val width = width.toFloat()
val height = height.toFloat()
val paint = state.paint
paint.reset()
paint.color = state.background
paint.style = Paint.Style.FILL
// draw background
canvas.apply {
if (state.config.proxyLine==1) {
drawRect(0f, 0f, width, height, paint)
} else {
val path = state.path
path.reset()
path.addRoundRect(
state.config.layoutPadding,
state.config.layoutPadding,
width - state.config.layoutPadding,
height - state.config.layoutPadding,
state.config.cardRadius,
state.config.cardRadius,
Path.Direction.CW,
)
paint.setShadowLayer(
state.config.cardRadius,
state.config.cardOffset,
state.config.cardOffset,
state.config.shadow
)
drawPath(path, paint)
clipPath(path)
}
}
super.draw(canvas)
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
val state = state ?: return
val paint = state.paint
val width = width.toFloat()
val height = height.toFloat()
paint.textSize = state.config.textSize
// measure delay text bounds
val delayCount = paint.breakText(
state.delayText,
false,
(width - state.config.layoutPadding * 2 - state.config.contentPadding * 2)
.coerceAtLeast(0f),
null
)
state.paint.getTextBounds(state.delayText, 0, delayCount, state.rect)
val delayWidth = state.rect.width()
val mainTextWidth = (width -
state.config.layoutPadding * 2 -
state.config.contentPadding * 2 -
delayWidth -
state.config.textMargin * 2
)
.coerceAtLeast(0f)
// measure title text bounds
val titleCount = paint.breakText(
state.title,
false,
mainTextWidth,
null,
)
// measure subtitle text bounds
val subtitleCount = paint.breakText(
state.subtitle,
false,
mainTextWidth,
null,
)
// text draw measure
val textOffset = (paint.descent() + paint.ascent()) / 2
paint.reset()
paint.textSize = state.config.textSize
paint.isAntiAlias = true
paint.color = state.controls
// draw delay
canvas.apply {
val x = width - state.config.layoutPadding - state.config.contentPadding - delayWidth
val y = height / 2f - textOffset
drawText(state.delayText, 0, delayCount, x, y, paint)
}
// draw title
canvas.apply {
val x = state.config.layoutPadding + state.config.contentPadding
val y = state.config.layoutPadding +
(height - state.config.layoutPadding * 2) / 3f - textOffset
drawText(state.title, 0, titleCount, x, y, paint)
}
// draw subtitle
canvas.apply {
val x = state.config.layoutPadding + state.config.contentPadding
val y = state.config.layoutPadding +
(height - state.config.layoutPadding * 2) / 3f * 2 - textOffset
drawText(state.subtitle, 0, subtitleCount, x, y, paint)
}
}
}
@@ -0,0 +1,40 @@
package com.github.kr328.clash.design.component
import android.content.Context
import android.graphics.Color
import com.github.kr328.clash.design.R
import com.github.kr328.clash.design.util.getPixels
import com.github.kr328.clash.design.util.resolveThemedColor
import com.github.kr328.clash.design.util.resolveThemedResourceId
class ProxyViewConfig(val context: Context, var proxyLine: Int) {
private val colorSurface = context.resolveThemedColor(R.attr.colorSurface)
val clickableBackground =
context.resolveThemedResourceId(android.R.attr.selectableItemBackground)
val selectedControl = context.resolveThemedColor(R.attr.colorOnPrimary)
val selectedBackground = context.resolveThemedColor(R.attr.colorPrimary)
val unselectedControl = context.resolveThemedColor(R.attr.colorOnSurface)
val unselectedBackground: Int
get() = if (proxyLine==1) Color.TRANSPARENT else colorSurface
val layoutPadding = context.getPixels(R.dimen.proxy_layout_padding).toFloat()
val contentPadding
get() = if (proxyLine==2) context.getPixels(R.dimen.proxy_content_padding).toFloat() else context.getPixels(R.dimen.proxy_content_padding_grid3).toFloat()
val textMargin
get() = if (proxyLine==2) context.getPixels(R.dimen.proxy_text_margin).toFloat() else context.getPixels(R.dimen.proxy_text_margin_grid3).toFloat()
val textSize
get() = if (proxyLine==2) context.getPixels(R.dimen.proxy_text_size).toFloat() else context.getPixels(R.dimen.proxy_text_size_grid3).toFloat()
val shadow = Color.argb(
0x15,
Color.red(Color.DKGRAY),
Color.green(Color.DKGRAY),
Color.blue(Color.DKGRAY),
)
val cardRadius = context.getPixels(R.dimen.proxy_card_radius).toFloat()
var cardOffset = context.getPixels(R.dimen.proxy_card_offset).toFloat()
}
@@ -0,0 +1,127 @@
package com.github.kr328.clash.design.component
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Path
import android.graphics.Rect
import com.github.kr328.clash.core.model.Proxy
import com.github.kr328.clash.design.model.ProxyState
import kotlin.math.absoluteValue
import kotlin.math.max
class ProxyViewState(
val config: ProxyViewConfig,
val proxy: Proxy,
private val parent: ProxyState,
private val link: ProxyState?
) {
val paint = Paint()
val rect = Rect()
val path = Path()
var title: String = ""
var subtitle: String = ""
var delayText: String = ""
var background: Int = config.unselectedBackground
var controls: Int = config.unselectedControl
private var delay: Int = 0
var selected: Boolean = false
private var parentNow: String = ""
private var linkNow: String? = null
private var lastFrameTime = System.currentTimeMillis()
fun update(snap: Boolean): Boolean {
val frameTime = System.currentTimeMillis()
var invalidate = false
if (proxy.type.group) {
title = proxy.name
if (link == null) {
subtitle = proxy.type.name
} else {
if (linkNow !== link.now) {
linkNow = link.now
subtitle = "%s(%s)".format(
proxy.type.name,
link.now.ifEmpty { "*" }
)
}
}
} else {
title = proxy.title
subtitle = proxy.subtitle
}
if (delay != proxy.delay) {
delay = proxy.delay
delayText = if (proxy.delay in 0..Short.MAX_VALUE) proxy.delay.toString() else ""
}
if (parentNow !== parent.now) {
parentNow = parent.now
selected = proxy.name == parent.now
}
controls = if (selected) config.selectedControl else config.unselectedControl
if (snap) {
background = if (selected) config.selectedBackground else config.unselectedBackground
} else {
val target = if (selected) config.selectedBackground else config.unselectedBackground
if (background != target) {
val sa = Color.alpha(background)
val sr = Color.red(background)
val sg = Color.green(background)
val sb = Color.blue(background)
val ta = Color.alpha(target)
val tr = Color.red(target)
val tg = Color.green(target)
val tb = Color.blue(target)
val da = ta - sa
val dr = tr - sr
val dg = tg - sg
val db = tb - sb
val max = max(
da.absoluteValue,
max(
dr.absoluteValue,
max(
dg.absoluteValue,
db.absoluteValue
)
)
)
val frameOffset = frameTime - lastFrameTime
val colorOffset = (frameOffset / max.toFloat().coerceAtLeast(0.001f))
.coerceIn(0.0f, 1.0f)
background = if (colorOffset > 0.999f) {
target
} else {
Color.argb(
(sa + da * colorOffset).toInt(),
(sr + dr * colorOffset).toInt(),
(sg + dg * colorOffset).toInt(),
(sb + db * colorOffset).toInt()
)
}
invalidate = true
}
}
lastFrameTime = frameTime
return invalidate
}
}
@@ -0,0 +1,94 @@
package com.github.kr328.clash.design.dialog
import android.app.Dialog
import android.content.Context
import android.os.Bundle
import android.view.ViewGroup
import android.view.WindowManager
import android.widget.FrameLayout
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.view.ViewCompat
import com.github.kr328.clash.common.compat.isAllowForceDarkCompat
import com.github.kr328.clash.common.compat.isSystemBarsTranslucentCompat
import com.github.kr328.clash.design.R
import com.github.kr328.clash.design.ui.Insets
import com.github.kr328.clash.design.ui.Surface
import com.github.kr328.clash.design.util.getPixels
import com.github.kr328.clash.design.util.resolveThemedResourceId
import com.github.kr328.clash.design.util.setOnInsertsChangedListener
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
class AppBottomSheetDialog(context: Context) : BottomSheetDialog(context) {
private var insets: Insets = Insets.EMPTY
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setCancelable(true)
window!!.apply {
isSystemBarsTranslucentCompat = true
isAllowForceDarkCompat = false
}
findViewById<ViewGroup>(com.google.android.material.R.id.container)?.apply {
fitsSystemWindows = false
}
findViewById<FrameLayout>(com.google.android.material.R.id.design_bottom_sheet)?.apply {
setOnInsertsChangedListener {
if (insets != it) {
insets = it
(layoutParams as CoordinatorLayout.LayoutParams).also { params ->
if (ViewCompat.getLayoutDirection(this) == ViewCompat.LAYOUT_DIRECTION_LTR) {
params.setMargins(it.start, 0, it.end, 0)
} else {
params.setMargins(it.end, 0, it.start, 0)
}
val top = context.getPixels(R.dimen.bottom_sheet_background_padding_top)
val height = context.getPixels(R.dimen.bottom_sheet_header_height)
setPaddingRelative(
0,
top * 2 + height,
0,
it.bottom
)
}
}
}
}
setOnShowListener {
behavior.halfExpandedRatio = 0.99f
behavior.state = BottomSheetBehavior.STATE_EXPANDED
}
}
}
class FullScreenDialog(
context: Context
) : Dialog(context, context.resolveThemedResourceId(R.attr.fullScreenDialogTheme)) {
val surface = Surface()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
window!!.apply {
isSystemBarsTranslucentCompat = true
setLayout(
WindowManager.LayoutParams.MATCH_PARENT,
WindowManager.LayoutParams.MATCH_PARENT
)
decorView.setOnInsertsChangedListener {
if (surface.insets != it)
surface.insets = it
}
}
}
}
@@ -0,0 +1,96 @@
package com.github.kr328.clash.design.dialog
import android.content.Context
import androidx.appcompat.app.AlertDialog
import androidx.core.widget.doOnTextChanged
import com.github.kr328.clash.design.R
import com.github.kr328.clash.design.databinding.DialogTextFieldBinding
import com.github.kr328.clash.design.util.*
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
suspend fun Context.requestModelTextInput(
initial: String,
title: CharSequence,
hint: CharSequence? = null,
error: CharSequence? = null,
validator: Validator = ValidatorAcceptAll,
): String {
return this.requestModelTextInput(initial, title, null, hint, error, validator)!!
}
suspend fun Context.requestModelTextInput(
initial: String?,
title: CharSequence,
reset: CharSequence?,
hint: CharSequence? = null,
error: CharSequence? = null,
validator: Validator = ValidatorAcceptAll,
): String? {
return suspendCancellableCoroutine {
val binding = DialogTextFieldBinding
.inflate(layoutInflater, this.root, false)
val builder = MaterialAlertDialogBuilder(this)
.setTitle(title)
.setView(binding.root)
.setCancelable(true)
.setPositiveButton(R.string.ok) { _, _ ->
val text = binding.textField.text?.toString() ?: ""
if (validator(text))
it.resume(text)
else
it.resume(initial)
}
.setNegativeButton(R.string.cancel) { _, _ -> }
.setOnDismissListener { _ ->
if (!it.isCompleted)
it.resume(initial)
}
if (reset != null) {
builder.setNeutralButton(reset) { _, _ ->
it.resume(null)
}
}
val dialog = builder.create()
it.invokeOnCancellation {
dialog.dismiss()
}
dialog.setOnShowListener {
if (hint != null)
binding.textLayout.hint = hint
binding.textField.apply {
binding.textLayout.isErrorEnabled = error != null
doOnTextChanged { text, _, _, _ ->
if (!validator(text?.toString() ?: "")) {
if (error != null)
binding.textLayout.error = error
dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = false
} else {
if (error != null)
binding.textLayout.error = null
dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = true
}
}
setText(initial)
setSelection(0, initial?.length ?: 0)
requestTextInput()
}
}
dialog.show()
}
}
@@ -0,0 +1,65 @@
package com.github.kr328.clash.design.dialog
import android.content.Context
import com.github.kr328.clash.design.databinding.DialogFetchStatusBinding
import com.github.kr328.clash.design.util.layoutInflater
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
interface ModelProgressBarConfigure {
var isIndeterminate: Boolean
var text: String?
var progress: Int
var max: Int
}
interface ModelProgressBarScope {
suspend fun configure(block: suspend ModelProgressBarConfigure.() -> Unit)
}
suspend fun Context.withModelProgressBar(block: suspend ModelProgressBarScope.() -> Unit) {
val view = DialogFetchStatusBinding.inflate(this.layoutInflater)
val dialog = MaterialAlertDialogBuilder(this)
.setCancelable(false)
.setView(view.root)
.show()
val configureImpl = object : ModelProgressBarConfigure {
override var isIndeterminate: Boolean
get() = view.progressIndicator.isIndeterminate
set(value) {
view.progressIndicator.isIndeterminate = value
}
override var text: String?
get() = view.text.text?.toString()
set(value) {
view.text.text = value
}
override var progress: Int
get() = view.progressIndicator.progress
set(value) {
view.progressIndicator.setProgressCompat(value, true)
}
override var max: Int
get() = view.progressIndicator.max
set(value) {
view.progressIndicator.max = value
}
}
val scopeImpl = object : ModelProgressBarScope {
override suspend fun configure(block: suspend ModelProgressBarConfigure.() -> Unit) {
withContext(Dispatchers.Main) {
configureImpl.block()
}
}
}
try {
scopeImpl.block()
} finally {
dialog.dismiss()
}
}
@@ -0,0 +1,11 @@
package com.github.kr328.clash.design.model
import android.graphics.drawable.Drawable
data class AppInfo(
val packageName: String,
val label: String,
val icon: Drawable,
val installTime: Long,
val updateDate: Long,
)
@@ -0,0 +1,8 @@
package com.github.kr328.clash.design.model
enum class AppInfoSort(comparator: Comparator<AppInfo>) : Comparator<AppInfo> by comparator {
Label(compareBy(AppInfo::label)),
PackageName(compareBy(AppInfo::packageName)),
InstallTime(compareBy(AppInfo::installTime)),
UpdateTime(compareBy(AppInfo::updateDate)),
}
@@ -0,0 +1,5 @@
package com.github.kr328.clash.design.model
interface Behavior {
var autoRestart: Boolean
}
@@ -0,0 +1,5 @@
package com.github.kr328.clash.design.model
enum class DarkMode {
Auto, ForceLight, ForceDark
}
@@ -0,0 +1,9 @@
package com.github.kr328.clash.design.model
data class File(
val id: String,
val name: String,
val size: Long,
val lastModified: Long,
val isDirectory: Boolean
)
@@ -0,0 +1,23 @@
package com.github.kr328.clash.design.model
import java.util.*
data class LogFile(val fileName: String, val date: Date) {
companion object {
private val REGEX_FILE = Regex("clash-(\\d+).log")
private const val FORMAT_FILE_NAME = "clash-%d.log"
fun parseFromFileName(fileName: String): LogFile? {
return REGEX_FILE.matchEntire(fileName)?.run {
LogFile(fileName, Date(groupValues[1].toLong()))
}
}
fun generate(): LogFile {
val current = Date()
val fileName = FORMAT_FILE_NAME.format(current.time)
return LogFile(fileName, current)
}
}
}
@@ -0,0 +1,5 @@
package com.github.kr328.clash.design.model
class ProfilePageState {
var allUpdating = false
}
@@ -0,0 +1,38 @@
package com.github.kr328.clash.design.model
import android.content.Context
import android.content.Intent
import android.graphics.drawable.Drawable
import com.github.kr328.clash.common.compat.getDrawableCompat
import com.github.kr328.clash.design.R
sealed class ProfileProvider {
class File(private val context: Context) : ProfileProvider() {
override val name: String
get() = context.getString(R.string.file)
override val summary: String
get() = context.getString(R.string.import_from_file)
override val icon: Drawable?
get() = context.getDrawableCompat(R.drawable.ic_baseline_attach_file)
}
class Url(private val context: Context) : ProfileProvider() {
override val name: String
get() = context.getString(R.string.url)
override val summary: String
get() = context.getString(R.string.import_from_url)
override val icon: Drawable?
get() = context.getDrawableCompat(R.drawable.ic_baseline_cloud_download)
}
class External(
override val name: String,
override val summary: String,
override val icon: Drawable?,
val intent: Intent,
) : ProfileProvider()
abstract val name: String
abstract val summary: String
abstract val icon: Drawable?
}
@@ -0,0 +1,28 @@
package com.github.kr328.clash.design.model
import androidx.databinding.BaseObservable
import androidx.databinding.Bindable
import com.github.kr328.clash.core.model.Provider
import com.github.kr328.clash.design.BR
class ProviderState(
val provider: Provider,
updatedAt: Long,
updating: Boolean,
) : BaseObservable() {
var updatedAt: Long = updatedAt
@Bindable get
set(value) {
field = value
notifyPropertyChanged(BR.updatedAt)
}
var updating: Boolean = updating
@Bindable get
set(value) {
field = value
notifyPropertyChanged(BR.updating)
}
}
@@ -0,0 +1,6 @@
package com.github.kr328.clash.design.model
class ProxyPageState {
var bottom = false
var urlTesting = false
}
@@ -0,0 +1,3 @@
package com.github.kr328.clash.design.model
data class ProxyState(var now: String)
@@ -0,0 +1,7 @@
package com.github.kr328.clash.design.network
import com.github.kr328.clash.network.SubscribeData
object APIGlobalObject {
var subData: SubscribeData? = null
}
@@ -0,0 +1,64 @@
package com.github.kr328.clash.network
import android.content.Context
import com.github.kr328.clash.design.PreferenceManager
import com.google.gson.GsonBuilder
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit
object ApiClient {
private val okHttpClient = OkHttpClient.Builder()
.addInterceptor(HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
})
.build()
// Lazy initialization of configURL
private val configURL: String
get() {
val url = PreferenceManager.baseURL
return if (url.isNullOrEmpty()) {
"https://vungles.com/" // Default fallback URL to prevent crash
} else {
if (!url.startsWith("http://") && !url.startsWith("https://")) {
"https://$url"
} else {
url
}
}
}
// Retrofit initialization
val retrofit: Retrofit by lazy {
Retrofit.Builder()
.baseUrl(configURL)
.addConverterFactory(GsonConverterFactory.create())
.client(okHttpClient)
.build()
}
}
object ApiClientConfig {
private const val ConfigURL = "https://vungles.com/api/test/" // BASE 请求配置文件地址 见说明文档
// private const val ConfigURL = "https://uuvpn.oss-cn-hongkong.aliyuncs.com/api/test/" // BASE 请求配置文件地址 见说明文档
//这个链接主要是为了配置转化,一般有的订阅地址被墙之后国内无法访问,这里就是了防止被墙,使用我们的香港服务器进行顶级域名防护
//const val ConfigNodeURL = "https://api.xxxx.com/api/parseyamlclash.php?target=clashmeta&url=" //通过香港服务器转接一次Clash转化的订阅地址
const val ConfigNodeURL = ""
private val okHttpClient = OkHttpClient.Builder()
.addInterceptor(HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
})
.build()
val retrofit: Retrofit = Retrofit.Builder()
.baseUrl(ConfigURL)
.addConverterFactory(GsonConverterFactory.create())
.client(okHttpClient)
.build()
}
@@ -0,0 +1,476 @@
package com.github.kr328.clash.network
import android.content.Context
import com.google.gson.annotations.SerializedName
import kotlinx.coroutines.time.withTimeout
import kotlinx.coroutines.withTimeout
import okhttp3.RequestBody
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.POST
import retrofit2.http.Query
import java.io.IOException
import java.io.Serializable
import java.util.concurrent.TimeoutException
/// 所有网络接口
interface ApiService {
@GET("config")
suspend fun getConfig(): Response<ConfigResponse>
@POST("passport/auth/login")
suspend fun loginUser(@Body request: LoginRequest): Response<LoginResponse>
@POST("passport/auth/register")
suspend fun registerUser(@Body request: LoginRequest): Response<LoginResponse>
@GET("user/info")
suspend fun getUserData(@Header("Authorization") auth_data: String): Response<UserInfoResponse>
@GET("user/getSubscribe")
suspend fun getSubscribe(@Header("Authorization") auth_data: String): Response<SubscribeResponse>
@GET("user/plan/fetch")
suspend fun getplans(@Header("Authorization") auth_data: String): Response<PlansResponse>
@GET("user/order/fetch")
suspend fun getOrders(@Header("Authorization") auth_data: String): Response<OrdersResponse>
@POST("user/order/save") //period=half_year_price&plan_id=2&coupon_code=
suspend fun saveOrder(@Header("Authorization") auth_data: String,@Body request: SaveOrderRequest): Response<SubmitOrderResponse>
@GET("user/order/detail") //period=half_year_price&plan_id=2&coupon_code=
suspend fun getOrderdetail(@Header("Authorization") auth_data: String, @Query("trade_no") trade_no: String): Response<QueryOrderResponse>
@GET("user/order/getPaymentMethod")
suspend fun getPaymentList(@Header("Authorization") auth_data: String): Response<PaymentResponse>
@POST("user/order/checkout")
suspend fun checkoutOrder(@Header("Authorization") auth_data: String,@Body request: CheckoutrderRequest): Response<PublicResponse>
@GET("user/invite/fetch")
suspend fun getinviteList(@Header("Authorization") auth_data: String) :Response<InviteResponse>
@GET("user/invite/save")
suspend fun saveinvite(@Header("Authorization") auth_data: String) :Response<SaveInviteCodeResponse>
@GET("user/ticket/fetch")
suspend fun getTickets(@Header("Authorization") auth_data: String): Response<TicketsResponse>
@GET("user/ticket/fetch")
suspend fun getTicketsByTID(@Header("Authorization") auth_data: String, @Query("id") ticketID: Int): Response<TicketDetailResponse>
@POST("user/ticket/close")
suspend fun closeTicket(@Header("Authorization") auth_data: String,@Body request: Map<String, Int>): Response<PublicResponse>
@POST("user/ticket/save")
suspend fun saveTicket(@Header("Authorization") auth_data: String,@Body request: MutableMap<String, String>): Response<PublicResponse>
@POST("user/ticket/reply")
suspend fun replyTicket(@Header("Authorization") auth_data: String,@Body request: MutableMap<String, String>): Response<PublicResponse>
}
data class SaveInviteCodeResponse(
val `data`: Any?,
val message: String?,
val status: String?
)
data class TicketDetailResponse(
val `data`: TicketDetailData?,
val message: String?,
val status: String?
)
data class TicketDetailData(
val created_at: Int?,
val id: Int?,
val level: Int?,
val message: List<TicketMessage>?,
val reply_status: Int?,
val status: Int?,
val subject: String?,
val updated_at: Int?,
val user_id: Int?
)
data class TicketMessage(
val created_at: Long?,
val id: Int?,
val is_me: Boolean?,
val message: String?,
val ticket_id: Int?,
val updated_at: Long?
)
data class TicketsResponse(
val `data`: List<TicketsData>?,
val message: String?,
val status: String?
)
data class TicketsData(
val created_at: Long?,
val id: Int?,
val level: Int?,
val message: Any?,
val reply_status: Int?,
val status: Int?,
val subject: String?,
val updated_at: Long?,
val user_id: Int?
)
suspend fun <T> safeApiCall(
apiCall: suspend () -> Response<T>
): T? {
return try {
val response = withTimeout(13000) {
apiCall()
}
if (response.isSuccessful) {
response.body()
} else {
println("请求失败,错误码: ${response.code()}")
null
}
} catch (e: TimeoutException) {
println("请求超时,请检查网络连接: ${e.message}")
null
} catch (e: IOException) {
println("网络错误,请检查网络连接: ${e.message}")
null
} catch (e: Exception) {
println("未知错误: ${e.message}")
null
}
}
suspend fun <T> safeApiRequestCall(
apiCall: suspend () -> Response<T>
): Response<T>? {
return try {
val response = withTimeout(13000) {
apiCall()
}
if (response.isSuccessful) {
response
} else {
println("请求失败,错误码: ${response.code()}")
response
}
} catch (e: TimeoutException) {
println("请求超时,请检查网络连接: ${e.message}")
null
} catch (e: IOException) {
println("网络错误,请检查网络连接: ${e.message}")
null
} catch (e: Exception) {
println("未知错误: ${e.message}")
null
}
}
data class InviteResponse(
val `data`: InviteData?,
val error: Any?,
val message: String?,
val status: String?
)
data class InviteData(
val codes: List<InviteCode>?,
val stat: List<Int>?
)
data class InviteCode(
val code: String?,
val created_at: Int?,
val pv: Int?,
val status: Int?,
val updated_at: Int?,
val user_id: Int?
)
data class CheckoutrderRequest(
val trade_no: String,
val method: Int
)
data class PaymentResponse(
val `data`: List<PaymentData>?,
val message: String?,
val status: String?
)
data class PaymentData(
val handling_fee_fixed: Any?,
val handling_fee_percent: String?,
val icon: Any?,
val id: Int?,
val name: String?,
val payment: String?
)
//
data class QueryOrderRequest(
val trade_no: String
)
data class QueryOrderResponse(
val `data`: QueryOrderData?,
val message: String?,
val status: String?
)
data class QueryOrderData(
val actual_commission_balance: Any?,
val balance_amount: Any?,
val callback_no: Any?,
val commission_balance: Int?,
val commission_status: Int?,
val coupon_code: Any?,
val coupon_id: Any?,
val created_at: Int?,
val discount_amount: Any?,
val handling_amount: Int?,
val id: Int?,
val invite_user_id: Any?,
val paid_at: Any?,
val payment_id: Int?,
val period: String?,
val plan: PlanData?,
val plan_id: Int?,
val refund_amount: Any?,
val site_id: Any?,
val status: Int?,
val surplus_amount: Any?,
val surplus_order_ids: Any?,
val tixianstatus: Any?,
val total_amount: Int?,
val trade_no: String?,
val try_out_plan_id: Int?,
val type: Int?,
val updated_at: Int?,
val user_id: Int?
){
// status 对应的中文描述
val statusZh: String
get() = when (status) {
0 -> "待支付"
1 -> "已支付"
2 -> "已取消"
3 -> "已支付"
else -> "未知"
}
// period 对应的中文描述
val periodZh: String
get() = when (period) {
"month_price" -> "月付"
"quarter_price" -> "季付"
"half_year_price" -> "半年付"
"year_price" -> "年付"
"two_year_price" -> "两年付"
"three_year_price" -> "三年付"
"onetime_price" -> "一次性付"
else -> ""
}
}
data class SaveOrderRequest(
// period=half_year_price&plan_id=2&coupon_code=
val period: String,
val plan_id: Int,
val coupon_code: String?
)
data class PublicResponse(
val data: String?,
val message: String?,
val status: String?
)
data class SubmitOrderResponse(
val data: String?,
val message: String?,
val status: String?
)
data class ConfigResponse (
val baseURL: String,
val baseDYURL: String,
val mainregisterURL: String,
val paymentURL: String,
val telegramurl: String,
val kefuurl: String,
val websiteURL: String,
val crisptoken: String,
val banners: List<String>,
val message: String,
val code: Int
)
data class RegisterRequest(val username: String, val password: String)
data class RegisterResponse(val success: Boolean, val message: String)
data class LoginRequest(val email: String, val password: String ,val captchaData: String = "")
data class LoginResponse(val message: String?, val data: LoginData?)
data class LoginData (
val token: String?,
val isAdmin: Int?,
val auth_data: String?
)
data class SubscribeResponse (
val data: SubscribeData
)
data class SubscribeData (
@SerializedName("plan_id") val plan_id : Int?,
@SerializedName("token") val token : String,
@SerializedName("expired_at") val expired_at : Long?,
@SerializedName("u") val u : Long,
@SerializedName("d") val d : Long,
@SerializedName("transfer_enable") val transfer_enable : Long?,
@SerializedName("email") val email : String,
@SerializedName("uuid") val uuid : String,
@SerializedName("plan") val plan : PlanData?,
@SerializedName("subscribe_url") val subscribe_url : String,
@SerializedName("reset_day") val reset_day : String?
)
data class PlanData (
@SerializedName("id") val id : Int,
@SerializedName("group_id") val group_id : Int?,
@SerializedName("transfer_enable") val transfer_enable : Long?,
@SerializedName("name") val name : String,
@SerializedName("speed_limit") val speed_limit : Long?,
@SerializedName("show") val show : Int?,
@SerializedName("sort") val sort : Int?,
@SerializedName("renew") val renew : Int?,
@SerializedName("content") val content : String,
@SerializedName("month_price") val month_price : Long?,
@SerializedName("quarter_price") val quarter_price : Long?,
@SerializedName("half_year_price") val half_year_price : Long?,
@SerializedName("year_price") val year_price : Long?,
@SerializedName("two_year_price") val two_year_price : Long?,
@SerializedName("three_year_price") val three_year_price : Long?,
@SerializedName("onetime_price") val onetime_price : Int?,
@SerializedName("reset_price") val reset_price : Long?,
@SerializedName("reset_traffic_method") val reset_traffic_method : String?,
@SerializedName("capacity_limit") val capacity_limit : Long?,
@SerializedName("created_at") val created_at : Long?,
@SerializedName("updated_at") val updated_at : Long?
) : Serializable // 添加 Serializable 接口
data class UserInfoResponse (
val data: UserInfoData
)
data class UserInfoData (
val email: String,
val transferEnable: Long?,
val lastLoginAt: Long,
val createdAt: Long?,
val banned: Long?,
val remindExpire: Long?,
val remindTraffic: Long?,
val expiredAt: Long?,
val balance: Long?,
val commissionBalance: Long?,
val uuid: String,
val avatarURL: String?
)
data class PlansResponse(
val data: List<PlanData>?,
val message: String?,
val status: String?
)
data class OrdersResponse(
val `data`: List<OrderData>?,
val message: String?,
val status: String?
)
data class OrderData(
val actual_commission_balance: Any?,
val balance_amount: Any?,
val callback_no: Any?,
val commission_balance: Int?,
val commission_status: Int?,
val coupon_code: Any?,
val coupon_id: Any?,
val created_at: Long?,
val discount_amount: Any?,
val handling_amount: Int?,
val invite_user_id: Any?,
val paid_at: Any?,
val payment_id: Int?,
val period: String?,
val plan: PlanData?,
val plan_id: Int?,
val refund_amount: Any?,
val site_id: Any?,
val status: Int?,
val surplus_amount: Any?,
val surplus_order_ids: Any?,
val tixianstatus: Any?,
val total_amount: Int?,
val trade_no: String?,
val type: Int?,
val updated_at: Long?
){
// status 对应的中文描述
val statusZh: String
get() = when (status) {
0 -> "待支付"
1 -> "已支付"
2 -> "已取消"
3 -> "已支付"
else -> "未知"
}
// period 对应的中文描述
val periodZh: String
get() = when (period) {
"month_price" -> "月付"
"quarter_price" -> "季付"
"half_year_price" -> "半年付"
"year_price" -> "年付"
"two_year_price" -> "两年付"
"three_year_price" -> "三年付"
"onetime_price" -> "一次性付"
else -> ""
}
}
@@ -0,0 +1,25 @@
package com.github.kr328.clash.design.preference
import android.view.View
import androidx.annotation.StringRes
import com.github.kr328.clash.design.databinding.PreferenceCategoryBinding
import com.github.kr328.clash.design.util.layoutInflater
fun PreferenceScreen.category(
@StringRes text: Int,
) {
val binding = PreferenceCategoryBinding
.inflate(context.layoutInflater, root, false)
binding.textView.text = context.getString(text)
addElement(object : Preference {
override val view: View
get() = binding.root
override var enabled: Boolean
get() = binding.root.isEnabled
set(value) {
binding.root.isEnabled = value
}
})
}
@@ -0,0 +1,73 @@
package com.github.kr328.clash.design.preference
import android.graphics.drawable.Drawable
import android.view.View
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import com.github.kr328.clash.common.compat.getDrawableCompat
import com.github.kr328.clash.design.databinding.PreferenceClickableBinding
import com.github.kr328.clash.design.util.layoutInflater
interface ClickablePreference : Preference {
var title: CharSequence
var icon: Drawable?
var summary: CharSequence?
fun clicked(clicked: () -> Unit)
}
fun PreferenceScreen.clickable(
@StringRes title: Int,
@DrawableRes icon: Int? = null,
@StringRes summary: Int? = null,
configure: ClickablePreference.() -> Unit = {}
): ClickablePreference {
val binding = PreferenceClickableBinding
.inflate(context.layoutInflater, root, false)
val impl = object : ClickablePreference {
override var icon: Drawable?
get() = binding.iconView.background
set(value) {
binding.iconView.background = value
}
override var title: CharSequence
get() = binding.titleView.text
set(value) {
binding.titleView.text = value
}
override var summary: CharSequence?
get() = binding.summaryView.text
set(value) {
binding.summaryView.text = value
binding.summaryView.visibility = if (value == null) View.GONE else View.VISIBLE
}
override val view: View
get() = binding.root
override fun clicked(clicked: () -> Unit) {
binding.root.setOnClickListener {
clicked()
}
}
}
impl.title = context.getText(title)
if (icon != null) {
impl.icon = context.getDrawableCompat(icon)
}
if (summary != null) {
impl.summary = context.getText(summary)
} else {
impl.summary = null
}
impl.configure()
addElement(impl)
return impl
}
@@ -0,0 +1,82 @@
package com.github.kr328.clash.design.preference
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import com.github.kr328.clash.design.R
import com.github.kr328.clash.design.dialog.requestModelTextInput
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.reflect.KMutableProperty0
interface EditableTextPreference : ClickablePreference {
var placeholder: CharSequence?
var empty: CharSequence?
var text: String?
}
fun <T> PreferenceScreen.editableText(
value: KMutableProperty0<T>,
adapter: NullableTextAdapter<T>,
@StringRes title: Int,
@DrawableRes icon: Int? = null,
@StringRes placeholder: Int? = null,
@StringRes empty: Int? = null,
configure: EditableTextPreference.() -> Unit = {},
): EditableTextPreference {
val impl = object : EditableTextPreference, ClickablePreference by clickable(title, icon) {
override var placeholder: CharSequence? = null
override var empty: CharSequence? = null
override var text: String? = null
set(value) {
field = value
when {
value == null -> {
this.summary = this.placeholder
}
value.isEmpty() -> {
this.summary = this.empty
}
else -> {
this.summary = value
}
}
}
}
if (placeholder != null) {
impl.placeholder = context.getText(placeholder)
}
if (empty != null) {
impl.empty = context.getText(empty)
}
impl.configure()
launch(Dispatchers.Main) {
impl.text = withContext(Dispatchers.IO) {
adapter.from(value.get())
}
impl.clicked {
this@editableText.launch(Dispatchers.Main) {
val text = context.requestModelTextInput(
initial = impl.text,
title = impl.title,
reset = context.getText(R.string.reset),
hint = impl.title,
)
val newValue = withContext(Dispatchers.IO) {
adapter.to(text).apply(value::set)
}
impl.text = adapter.from(newValue)
}
}
}
return impl
}
@@ -0,0 +1,110 @@
package com.github.kr328.clash.design.preference
import android.content.Context
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import com.github.kr328.clash.design.R
import com.github.kr328.clash.design.adapter.EditableTextListAdapter
import com.github.kr328.clash.design.dialog.requestModelTextInput
import kotlinx.coroutines.*
import kotlin.reflect.KMutableProperty0
interface EditableTextListPreference<T> : ClickablePreference {
var placeholder: CharSequence?
var list: List<T>?
}
fun <T> PreferenceScreen.editableTextList(
value: KMutableProperty0<List<T>?>,
adapter: TextAdapter<T>,
@StringRes title: Int,
@DrawableRes icon: Int? = null,
@StringRes placeholder: Int? = null,
configure: EditableTextListPreference<T>.() -> Unit = {},
): EditableTextListPreference<T> {
val impl =
object : EditableTextListPreference<T>, ClickablePreference by clickable(title, icon) {
override var list: List<T>? = null
set(value) {
field = value
when {
value == null -> {
this.summary = this.placeholder
}
value.isEmpty() -> {
this.summary = context.getString(R.string.empty)
}
else -> {
this.summary = context.getString(R.string.format_elements, value.size)
}
}
}
override var placeholder: CharSequence? = null
}
if (placeholder != null) {
impl.placeholder = context.getText(placeholder)
}
impl.configure()
launch(Dispatchers.Main) {
val v = withContext(Dispatchers.IO) {
value.get()
}
impl.list = v
impl.clicked {
this@editableTextList.launch(Dispatchers.Main) {
val newList = requestEditTextList(
impl.list,
context,
adapter,
impl.title
)
withContext(Dispatchers.IO) {
value.set(newList)
}
impl.list = newList
}
}
}
return impl
}
private suspend fun <T> requestEditTextList(
initialValue: List<T>?,
context: Context,
adapter: TextAdapter<T>,
title: CharSequence,
): List<T>? {
val recyclerAdapter = EditableTextListAdapter(
context,
initialValue?.toMutableList() ?: mutableListOf(),
adapter
)
val result = requestEditableListOverlay(context, recyclerAdapter, title) {
val text = context.requestModelTextInput(
initial = "",
title = title,
hint = title
)
if (text.isNotBlank()) {
recyclerAdapter.addElement(text)
}
}
return when (result) {
EditableListOverlayResult.Cancel -> initialValue
EditableListOverlayResult.Apply -> recyclerAdapter.values
EditableListOverlayResult.Reset -> null
}
}
@@ -0,0 +1,149 @@
package com.github.kr328.clash.design.preference
import android.content.Context
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import com.github.kr328.clash.design.R
import com.github.kr328.clash.design.adapter.EditableTextMapAdapter
import com.github.kr328.clash.design.databinding.DialogEditableMapTextFieldBinding
import com.github.kr328.clash.design.util.layoutInflater
import com.github.kr328.clash.design.util.root
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.*
import kotlin.coroutines.resume
import kotlin.reflect.KMutableProperty0
interface EditableTextMapPreference<K, V> : ClickablePreference {
var placeholder: CharSequence?
var map: Map<K, V>?
}
fun <K, V> PreferenceScreen.editableTextMap(
value: KMutableProperty0<Map<K, V>?>,
keyAdapter: TextAdapter<K>,
valueAdapter: TextAdapter<V>,
@StringRes title: Int,
@DrawableRes icon: Int? = null,
@StringRes placeholder: Int? = null,
configure: EditableTextMapPreference<K, V>.() -> Unit = {}
): EditableTextMapPreference<K, V> {
val impl =
object : EditableTextMapPreference<K, V>, ClickablePreference by clickable(title, icon) {
override var placeholder: CharSequence? = null
override var map: Map<K, V>? = null
set(value) {
field = value
when {
value == null -> {
this.summary = this.placeholder
}
value.isEmpty() -> {
this.summary = context.getString(R.string.empty)
}
else -> {
this.summary = context.getString(R.string.format_elements, value.size)
}
}
}
}
if (placeholder != null) {
impl.placeholder = context.getText(placeholder)
}
impl.configure()
launch(Dispatchers.Main) {
val v = withContext(Dispatchers.IO) {
value.get()
}
impl.map = v
impl.clicked {
this@editableTextMap.launch(Dispatchers.Main) {
val newMap = requestEditTextMap(
impl.map,
context,
keyAdapter,
valueAdapter,
impl.title
)
withContext(Dispatchers.IO) {
value.set(newMap)
}
impl.map = newMap
}
}
}
return impl
}
private suspend fun <K, V> requestEditTextMap(
initialValue: Map<K, V>?,
context: Context,
keyAdapter: TextAdapter<K>,
valueAdapter: TextAdapter<V>,
title: CharSequence
): Map<K, V>? {
val editableValue = withContext(Dispatchers.Default) {
initialValue?.map { it.key to it.value }?.toMutableList() ?: mutableListOf()
}
val recyclerAdapter = EditableTextMapAdapter(
context,
editableValue,
keyAdapter,
valueAdapter,
)
val result = requestEditableListOverlay(context, recyclerAdapter, title) {
val newItem = requestModelInputEntry(context, title)
if (newItem != null) {
recyclerAdapter.addElement(newItem.first, newItem.second)
}
}
return when (result) {
EditableListOverlayResult.Cancel -> initialValue
EditableListOverlayResult.Apply -> recyclerAdapter.values.toMap()
EditableListOverlayResult.Reset -> null
}
}
private suspend fun requestModelInputEntry(
context: Context,
title: CharSequence
): Pair<String, String>? {
return suspendCancellableCoroutine { ctx ->
val binding = DialogEditableMapTextFieldBinding
.inflate(context.layoutInflater, context.root, false)
val dialog = MaterialAlertDialogBuilder(context)
.setTitle(title)
.setNegativeButton(R.string.cancel) { _, _ -> }
.setPositiveButton(R.string.ok) { _, _ ->
val k = binding.keyView.text?.toString()?.trim() ?: ""
val v = binding.valueView.text?.toString()?.trim() ?: ""
if (k.isNotEmpty() && v.isNotEmpty()) {
ctx.resume(k to v)
}
}
.setView(binding.root)
.create()
dialog.setOnCancelListener {
if (!ctx.isCompleted) {
ctx.resume(null)
}
}
dialog.show()
}
}
@@ -0,0 +1,72 @@
package com.github.kr328.clash.design.preference
import android.content.Context
import androidx.recyclerview.widget.RecyclerView
import com.github.kr328.clash.design.databinding.DialogPreferenceListBinding
import com.github.kr328.clash.design.dialog.FullScreenDialog
import com.github.kr328.clash.design.util.applyLinearAdapter
import com.github.kr328.clash.design.util.layoutInflater
import com.github.kr328.clash.design.util.root
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
internal enum class EditableListOverlayResult {
Cancel, Apply, Reset
}
internal suspend fun requestEditableListOverlay(
context: Context,
adapter: RecyclerView.Adapter<*>,
title: CharSequence,
addNewItem: suspend () -> Unit
): EditableListOverlayResult {
return coroutineScope {
suspendCancellableCoroutine { ctx ->
val dialog = FullScreenDialog(context)
val binding = DialogPreferenceListBinding
.inflate(context.layoutInflater, context.root, false)
binding.surface = dialog.surface
binding.mainList.applyLinearAdapter(context, adapter)
binding.titleView.text = title
binding.newView.setOnClickListener {
launch {
addNewItem()
}
}
binding.resetView.setOnClickListener {
ctx.resume(EditableListOverlayResult.Reset)
dialog.dismiss()
}
binding.cancelView.setOnClickListener {
dialog.dismiss()
}
binding.okView.setOnClickListener {
ctx.resume(EditableListOverlayResult.Apply)
dialog.dismiss()
}
dialog.setContentView(binding.root)
dialog.setOnDismissListener {
if (!ctx.isCompleted) {
ctx.resume(EditableListOverlayResult.Cancel)
}
}
ctx.invokeOnCancellation {
dialog.dismiss()
}
dialog.show()
}
}
}
@@ -0,0 +1,20 @@
package com.github.kr328.clash.design.preference
import android.view.View
fun interface OnChangedListener {
fun onChanged()
}
interface Preference {
val view: View
var enabled: Boolean
get() = view.isEnabled
set(value) {
view.isEnabled = value
view.isClickable = value
view.isFocusable = value
view.alpha = if (value) 1.0f else 0.33f
}
}
@@ -0,0 +1,38 @@
package com.github.kr328.clash.design.preference
import android.content.Context
import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.LinearLayout.LayoutParams
import android.widget.LinearLayout.LayoutParams.MATCH_PARENT
import android.widget.LinearLayout.LayoutParams.WRAP_CONTENT
import kotlinx.coroutines.CoroutineScope
interface PreferenceScreen : CoroutineScope {
val context: Context
val root: ViewGroup
}
fun CoroutineScope.preferenceScreen(
context: Context,
configure: PreferenceScreen.() -> Unit
): PreferenceScreen {
val root = LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
}
val impl = object : PreferenceScreen, CoroutineScope by this {
override val context: Context
get() = context
override val root: ViewGroup
get() = root
}
impl.configure()
return impl
}
fun PreferenceScreen.addElement(preference: Preference) {
root.addView(preference.view, LayoutParams(MATCH_PARENT, WRAP_CONTENT))
}
@@ -0,0 +1,96 @@
package com.github.kr328.clash.design.preference
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.appcompat.widget.ListPopupWindow
import com.github.kr328.clash.design.R
import com.github.kr328.clash.design.adapter.PopupListAdapter
import com.github.kr328.clash.design.util.getPixels
import com.github.kr328.clash.design.util.measureWidth
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.reflect.KMutableProperty0
interface SelectableListPreference<T> : ClickablePreference {
var selected: Int
var listener: OnChangedListener?
}
fun <T> PreferenceScreen.selectableList(
value: KMutableProperty0<T>,
values: Array<T>,
valuesText: Array<Int>,
@StringRes title: Int,
@DrawableRes icon: Int? = null,
configure: SelectableListPreference<T>.() -> Unit = {},
): SelectableListPreference<T> {
val impl = object : SelectableListPreference<T>, ClickablePreference by clickable(title, icon) {
override var selected: Int = 0
set(value) {
field = value
this.summary = context.getText(valuesText[value])
}
override var listener: OnChangedListener? = null
}
impl.configure()
launch(Dispatchers.Main) {
val initial = withContext(Dispatchers.IO) {
value.get()
}
impl.selected = values.indexOf(initial)
impl.clicked {
popupSelectMenu(impl, value, valuesText.map { context.getText(it) }, values)
}
}
return impl
}
private fun <T> PreferenceScreen.popupSelectMenu(
impl: SelectableListPreference<T>,
value: KMutableProperty0<T>,
valuesText: List<CharSequence>,
values: Array<T>,
) {
ListPopupWindow(context).apply {
val adapter = PopupListAdapter(
context,
valuesText,
impl.selected,
)
setAdapter(adapter)
anchorView = impl.view
width = adapter.measureWidth(context)
.coerceAtLeast(context.getPixels(R.dimen.dialog_menu_min_width))
isModal = true
horizontalOffset = context.getPixels(R.dimen.item_header_component_size) +
context.getPixels(R.dimen.item_header_margin) * 2
setOnItemClickListener { _, _, position, _ ->
dismiss()
launch(Dispatchers.Main) {
withContext(Dispatchers.IO) {
value.set(values[position])
}
impl.selected = position
impl.listener?.onChanged()
}
}
show()
}
}
@@ -0,0 +1,101 @@
package com.github.kr328.clash.design.preference
import android.graphics.drawable.Drawable
import android.view.View
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import com.github.kr328.clash.common.compat.getDrawableCompat
import com.github.kr328.clash.design.databinding.PreferenceSwitchBinding
import com.github.kr328.clash.design.util.layoutInflater
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.reflect.KMutableProperty0
interface SwitchPreference : Preference {
var icon: Drawable?
var title: CharSequence?
var summary: CharSequence?
var listener: OnChangedListener?
}
fun PreferenceScreen.switch(
value: KMutableProperty0<Boolean>,
@DrawableRes icon: Int? = null,
@StringRes title: Int? = null,
@StringRes summary: Int? = null,
configure: SwitchPreference.() -> Unit = {},
): SwitchPreference {
val binding = PreferenceSwitchBinding
.inflate(context.layoutInflater, root, false)
val impl = object : SwitchPreference {
override val view: View
get() = binding.root
override var icon: Drawable?
get() = binding.iconView.background
set(value) {
binding.iconView.background = value
}
override var title: CharSequence?
get() = binding.titleView.text
set(value) {
binding.titleView.text = value
}
override var summary: CharSequence?
get() = binding.summaryView.text
set(value) {
binding.summaryView.text = value
}
override var listener: OnChangedListener? = null
override var enabled: Boolean
get() = binding.root.isEnabled
set(value) {
binding.root.isEnabled = value
binding.root.isFocusable = value
binding.root.isClickable = value
binding.root.alpha = if (value) 1.0f else 0.33f
}
}
if (icon != null) {
impl.icon = context.getDrawableCompat(icon)
}
if (title != null) {
impl.title = context.getString(title)
}
if (summary != null) {
impl.summary = context.getString(summary)
}
impl.configure()
addElement(impl)
launch(Dispatchers.Main) {
val initialValue = withContext(Dispatchers.IO) {
value.get()
}
binding.switchView.apply {
isChecked = initialValue
binding.root.setOnClickListener {
isChecked = !isChecked
this@switch.launch(Dispatchers.Main) {
withContext(Dispatchers.IO) {
value.set(isChecked)
}
impl.listener?.onChanged()
}
}
}
}
return impl
}
@@ -0,0 +1,42 @@
package com.github.kr328.clash.design.preference
import android.view.View
import androidx.annotation.StringRes
import com.github.kr328.clash.design.databinding.PreferenceTipsBinding
import com.github.kr328.clash.design.util.getHtml
import com.github.kr328.clash.design.util.layoutInflater
import com.github.kr328.clash.design.util.root
interface TipsPreference : Preference {
var text: CharSequence?
}
fun PreferenceScreen.tips(
@StringRes text: Int,
configure: TipsPreference.() -> Unit = {},
): TipsPreference {
val binding = PreferenceTipsBinding
.inflate(context.layoutInflater, context.root, false)
val impl = object : TipsPreference {
override var text: CharSequence?
get() = binding.tips.text
set(value) {
binding.tips.text = value
}
override val view: View
get() = binding.root
override var enabled: Boolean
get() = binding.root.isEnabled
set(value) {
binding.root.isEnabled = value
}
}
binding.tips.text = context.getHtml(text)
impl.configure()
addElement(impl)
return impl
}
@@ -0,0 +1,49 @@
package com.github.kr328.clash.design.preference
interface NullableTextAdapter<T> {
fun from(value: T): String?
fun to(text: String?): T
companion object {
val Port = object : NullableTextAdapter<Int?> {
override fun from(value: Int?): String? {
if (value == null) return null
return if (value > 0) value.toString() else ""
}
override fun to(text: String?): Int? {
if (text == null) return null
return text.toIntOrNull() ?: 0
}
}
val String = object : NullableTextAdapter<String?> {
override fun from(value: String?): String? {
return value
}
override fun to(text: String?): String? {
return text
}
}
}
}
interface TextAdapter<T> {
fun from(value: T): String
fun to(text: String): T
companion object {
val String = object : TextAdapter<String> {
override fun from(value: String): String {
return value
}
override fun to(text: String): String {
return text
}
}
}
}
@@ -0,0 +1,68 @@
package com.github.kr328.clash.design.store
import android.content.Context
import com.github.kr328.clash.common.store.Store
import com.github.kr328.clash.common.store.asStoreProvider
import com.github.kr328.clash.core.model.ProxySort
import com.github.kr328.clash.design.model.AppInfoSort
import com.github.kr328.clash.design.model.DarkMode
class UiStore(context: Context) {
private val store = Store(
context
.getSharedPreferences(PREFERENCE_NAME, Context.MODE_PRIVATE)
.asStoreProvider()
)
var enableVpn: Boolean by store.boolean(
key = "enable_vpn",
defaultValue = true
)
var darkMode: DarkMode by store.enum(
key = "dark_mode",
defaultValue = DarkMode.Auto,
values = DarkMode.values()
)
var proxyExcludeNotSelectable by store.boolean(
key = "proxy_exclude_not_selectable",
defaultValue = false,
)
var proxyLine: Int by store.int(
key = "proxy_line",
defaultValue = 1
)
var proxySort: ProxySort by store.enum(
key = "proxy_sort",
defaultValue = ProxySort.Default,
values = ProxySort.values()
)
var proxyLastGroup: String by store.string(
key = "proxy_last_group",
defaultValue = ""
)
var accessControlSort: AppInfoSort by store.enum(
key = "access_control_sort",
defaultValue = AppInfoSort.Label,
values = AppInfoSort.values(),
)
var accessControlReverse: Boolean by store.boolean(
key = "access_control_reverse",
defaultValue = false
)
var accessControlSystemApp: Boolean by store.boolean(
key = "access_control_system_app",
defaultValue = false,
)
companion object {
private const val PREFERENCE_NAME = "ui"
}
}
@@ -0,0 +1,5 @@
package com.github.kr328.clash.design.ui
enum class DayNight {
Day, Night
}
@@ -0,0 +1,7 @@
package com.github.kr328.clash.design.ui
data class Insets(val start: Int, val top: Int, val end: Int, val bottom: Int) {
companion object {
val EMPTY = Insets(0, 0, 0, 0)
}
}
@@ -0,0 +1,19 @@
package com.github.kr328.clash.design.ui
import androidx.databinding.BaseObservable
import androidx.databinding.Bindable
import androidx.databinding.library.baseAdapters.BR
class ObservableCurrentTime : BaseObservable() {
var value: Long = System.currentTimeMillis()
@Bindable get
private set(value) {
field = value
notifyPropertyChanged(BR.value)
}
fun update() {
value = System.currentTimeMillis()
}
}
@@ -0,0 +1,15 @@
package com.github.kr328.clash.design.ui
import androidx.databinding.BaseObservable
import androidx.databinding.Bindable
import com.github.kr328.clash.design.BR
class Surface : BaseObservable() {
var insets: Insets = Insets.EMPTY
@Bindable get
set(value) {
field = value
notifyPropertyChanged(BR.insets)
}
}
@@ -0,0 +1,5 @@
package com.github.kr328.clash.design.ui
enum class ToastDuration {
Short, Long, Indefinite
}
@@ -0,0 +1,21 @@
package com.github.kr328.clash.design.util
import android.app.Activity
import android.content.Context
import android.widget.ImageView
import android.widget.TextView
import com.github.kr328.clash.design.R
import com.github.kr328.clash.design.view.ActivityBarLayout
fun ActivityBarLayout.applyFrom(context: Context) {
if (context is Activity) {
findViewById<ImageView>(R.id.activity_bar_close_view)?.apply {
setOnClickListener {
context.onBackPressed()
}
}
findViewById<TextView>(R.id.activity_bar_title_view)?.apply {
text = context.title
}
}
}
@@ -0,0 +1,16 @@
package com.github.kr328.clash.design.util
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import com.github.kr328.clash.common.compat.foreground
import com.github.kr328.clash.design.model.AppInfo
fun PackageInfo.toAppInfo(pm: PackageManager): AppInfo {
return AppInfo(
packageName = packageName,
icon = applicationInfo.loadIcon(pm).foreground(),
label = applicationInfo.loadLabel(pm).toString(),
installTime = firstInstallTime,
updateDate = lastUpdateTime,
)
}
@@ -0,0 +1,9 @@
package com.github.kr328.clash.design.util
import android.view.View
import androidx.databinding.BindingAdapter
@BindingAdapter("android:minHeight")
fun bindMinHeight(view: View, value: Float) {
view.minimumHeight = value.toInt()
}
@@ -0,0 +1,33 @@
package com.github.kr328.clash.design.util
import android.app.Activity
import android.content.Context
import android.text.Spanned
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.annotation.DimenRes
import androidx.annotation.StringRes
import com.github.kr328.clash.common.compat.fromHtmlCompat
val Context.layoutInflater: LayoutInflater
get() = LayoutInflater.from(this)
val Context.root: ViewGroup?
get() {
return when (this) {
is Activity -> {
findViewById(android.R.id.content)
}
else -> {
null
}
}
}
fun Context.getPixels(@DimenRes resId: Int): Int {
return resources.getDimensionPixelSize(resId)
}
fun Context.getHtml(@StringRes resId: Int): Spanned {
return fromHtmlCompat(getString(resId))
}
@@ -0,0 +1,45 @@
package com.github.kr328.clash.design.util
import android.content.Context
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.view.LayoutInflater
import androidx.appcompat.app.AlertDialog
import com.github.kr328.clash.design.databinding.CustomDialogBinding
fun Context.showCustomDialog(
title: String,
message: String,
positiveButtonText: String = "确定",
negativeButtonText: String = "取消",
onPositiveClick: (() -> Unit)? = null,
onNegativeClick: (() -> Unit)? = null
) {
// 使用 ViewBinding 加载自定义布局
val binding = CustomDialogBinding.inflate(LayoutInflater.from(this))
// 设置标题和消息
binding.dialogTitle.text = title
binding.dialogMessage.text = message
binding.positiveButton.text = positiveButtonText
binding.negativeButton.text = negativeButtonText
// 创建 AlertDialog
val dialog = AlertDialog.Builder(this).setView(binding.root).create()
// 去除默认背景
dialog.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
// 设置按钮点击事件
binding.positiveButton.setOnClickListener {
onPositiveClick?.invoke()
dialog.dismiss()
}
binding.negativeButton.setOnClickListener {
onNegativeClick?.invoke()
dialog.dismiss()
}
// 显示对话框
dialog.show()
}
@@ -0,0 +1,29 @@
package com.github.kr328.clash.design.util
import androidx.recyclerview.widget.DiffUtil
fun <T> List<T>.diffWith(
newList: List<T>,
detectMove: Boolean = false,
id: (T) -> Any? = { it }
): DiffUtil.DiffResult {
val oldList = this
return DiffUtil.calculateDiff(object : DiffUtil.Callback() {
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
return id(oldList[oldItemPosition]) == id(newList[newItemPosition])
}
override fun getOldListSize(): Int {
return oldList.size
}
override fun getNewListSize(): Int {
return newList.size
}
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
return oldList[oldItemPosition] == newList[newItemPosition]
}
}, detectMove)
}
@@ -0,0 +1,59 @@
package com.github.kr328.clash.design.util
import android.animation.ValueAnimator
import androidx.recyclerview.widget.RecyclerView
import com.github.kr328.clash.design.R
import com.github.kr328.clash.design.view.ActivityBarLayout
import com.github.kr328.clash.design.view.ObservableScrollView
private class AppBarElevationController(
private val activityBar: ActivityBarLayout
) {
private var animator: ValueAnimator? = null
var elevated: Boolean = false
set(value) {
if (field == value)
return
field = value
animator?.end()
animator = if (value) {
ValueAnimator.ofFloat(
activityBar.elevation,
activityBar.context.getPixels(R.dimen.toolbar_elevation).toFloat()
)
} else {
ValueAnimator.ofFloat(
activityBar.elevation,
0f
)
}.apply {
addUpdateListener {
activityBar.elevation = it.animatedValue as Float
}
start()
}
}
}
fun RecyclerView.bindAppBarElevation(activityBar: ActivityBarLayout) {
addOnScrollListener(object : RecyclerView.OnScrollListener() {
private val controller = AppBarElevationController(activityBar)
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
controller.elevated = !recyclerView.isTop
}
})
}
fun ObservableScrollView.bindAppBarElevation(activityBar: ActivityBarLayout) {
val controller = AppBarElevationController(activityBar)
addOnScrollChangedListener { view, _, _, _, _ ->
controller.elevated = !view.isTop
}
}
@@ -0,0 +1,82 @@
package com.github.kr328.clash.design.util
import android.content.Context
import com.github.kr328.clash.common.compat.preferredLocale
import com.github.kr328.clash.core.model.Provider
import com.github.kr328.clash.design.R
import com.github.kr328.clash.service.model.Profile
import java.text.SimpleDateFormat
import java.util.*
private const val DATE_DATE_ONLY = "yyyy-MM-dd"
private const val DATE_TIME_ONLY = "HH:mm:ss.SSS"
private const val DATE_ALL = "$DATE_DATE_ONLY $DATE_TIME_ONLY"
fun Profile.Type.toString(context: Context): String {
return when (this) {
Profile.Type.File -> context.getString(R.string.file)
Profile.Type.Url -> context.getString(R.string.url)
Profile.Type.External -> context.getString(R.string.external)
}
}
fun Provider.type(context: Context): String {
val type = when (type) {
Provider.Type.Proxy -> context.getString(R.string.proxy)
Provider.Type.Rule -> context.getString(R.string.rule)
}
val vehicle = when (vehicleType) {
Provider.VehicleType.HTTP -> context.getString(R.string.http)
Provider.VehicleType.File -> context.getString(R.string.file)
Provider.VehicleType.Compatible -> context.getString(R.string.compatible)
}
return context.getString(R.string.format_provider_type, type, vehicle)
}
@JvmOverloads
fun Date.format(
context: Context,
includeDate: Boolean = true,
includeTime: Boolean = true,
): String {
val locale = context.resources.configuration.preferredLocale
return when {
includeDate && includeTime ->
SimpleDateFormat(DATE_ALL, locale).format(this)
includeDate ->
SimpleDateFormat(DATE_DATE_ONLY, locale).format(this)
includeTime ->
SimpleDateFormat(DATE_TIME_ONLY, locale).format(this)
else -> ""
}
}
fun Long.toBytesString(): String {
return when {
this > 1024.0 * 1024 * 1024 * 1024 * 1024 * 1024 ->
String.format("%.2f EiB", (this.toDouble() / 1024 / 1024 / 1024 / 1024 / 1024 / 1024))
this > 1024.0 * 1024 * 1024 * 1024 * 1024 ->
String.format("%.2f PiB", (this.toDouble() / 1024 / 1024 / 1024 / 1024 / 1024))
this > 1024.0 * 1024 * 1024 * 1024 ->
String.format("%.2f TiB", (this.toDouble() / 1024 / 1024 / 1024 / 1024))
this > 1024 * 1024 * 1024 ->
String.format("%.2f GiB", (this.toDouble() / 1024 / 1024 / 1024))
this > 1024 * 1024 ->
String.format("%.2f MiB", (this.toDouble() / 1024 / 1024))
this > 1024 ->
String.format("%.2f KiB", (this.toDouble() / 1024))
else ->
"$this Bytes"
}
}
fun Double.toProgress(): Int {
return this.toInt()
}
fun Long.toDateStr(): String {
val simpleDateFormat =SimpleDateFormat("yyyy-MM-dd HH:mm:ss")
return simpleDateFormat.format(Date(this))
}
@@ -0,0 +1,37 @@
package com.github.kr328.clash.design.util
import android.view.View
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import com.github.kr328.clash.design.ui.Insets
fun View.setOnInsertsChangedListener(adaptLandscape: Boolean = true, listener: (Insets) -> Unit) {
setOnApplyWindowInsetsListener { v, ins ->
val compat = WindowInsetsCompat.toWindowInsetsCompat(ins)
val insets = compat.getInsets(WindowInsetsCompat.Type.systemBars())
val rInsets = if (ViewCompat.getLayoutDirection(v) == ViewCompat.LAYOUT_DIRECTION_LTR) {
Insets(
insets.left,
insets.top,
insets.right,
insets.bottom,
)
} else {
Insets(
insets.right,
insets.top,
insets.left,
insets.bottom,
)
}
listener(if (adaptLandscape) rInsets.landscape(v.context) else rInsets)
compat.toWindowInsets()!!
}
requestApplyInsets()
}
@@ -0,0 +1,18 @@
package com.github.kr328.clash.design.util
import android.content.Context
import com.github.kr328.clash.design.R
import java.util.concurrent.TimeUnit
fun Long.elapsedIntervalString(context: Context): String {
val day = TimeUnit.MILLISECONDS.toDays(this)
val hour = TimeUnit.MILLISECONDS.toHours(this)
val minute = TimeUnit.MILLISECONDS.toMinutes(this)
return when {
day > 0 -> context.getString(R.string.format_days_ago, day)
hour > 0 -> context.getString(R.string.format_hours_ago, hour)
minute > 0 -> context.getString(R.string.format_minutes_ago, minute)
else -> context.getString(R.string.recently)
}
}
@@ -0,0 +1,23 @@
package com.github.kr328.clash.design.util
import android.content.Context
import com.github.kr328.clash.design.R
import com.github.kr328.clash.design.ui.Insets
fun Insets.landscape(context: Context): Insets {
val displayMetrics = context.resources.displayMetrics
val minWidth = context.getPixels(R.dimen.surface_landscape_min_width)
val width = displayMetrics.widthPixels
val height = displayMetrics.heightPixels
return if (width > height && width > minWidth) {
val expectedWidth = width.coerceAtMost(height.coerceAtLeast(minWidth))
val padding = (width - expectedWidth).coerceAtLeast(start + end) / 2
copy(start = padding.coerceAtLeast(start), end = padding.coerceAtLeast(end))
} else {
this
}
}
@@ -0,0 +1,37 @@
package com.github.kr328.clash.design.util
import android.content.Context
import android.view.View
import android.view.View.MeasureSpec
import android.widget.FrameLayout
import android.widget.ListAdapter
fun ListAdapter.measureWidth(context: Context): Int {
val parent = FrameLayout(context)
val widthMeasureSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)
val heightMeasureSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)
var itemView: View? = null
var maxWidth = 0
var itemType = 0
for (i in 0 until count) {
val positionType = getItemViewType(i)
if (positionType != itemType) {
itemType = positionType
itemView = null
}
itemView = getView(i, itemView, parent)
itemView.measure(widthMeasureSpec, heightMeasureSpec)
val itemWidth: Int = itemView.measuredWidth
if (itemWidth > maxWidth) {
maxWidth = itemWidth
}
}
return maxWidth
}
@@ -0,0 +1,38 @@
package com.github.kr328.clash.utity
import android.app.Activity
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.widget.ProgressBar
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import com.github.kr328.clash.design.R
object LoadingDialog {
private var dialog: AlertDialog? = null
fun show(context: Context, message: String = "请稍等...") {
if (dialog == null) {
val inflater = LayoutInflater.from(context)
val view: View = inflater.inflate(R.layout.loading_dialog, null)
val progressBar: ProgressBar = view.findViewById(R.id.progressBar)
val messageTextView: TextView = view.findViewById(R.id.loadingMessage)
messageTextView.text = message
dialog = AlertDialog.Builder(context)
.setCancelable(false)
.setView(view)
.create()
dialog?.show()
}
}
fun hide() {
dialog?.dismiss()
dialog = null
}
}

Some files were not shown because too many files have changed in this diff Show More