add android code

add android code
This commit is contained in:
Zeus
2024-10-23 22:33:02 +08:00
parent e5ed61e5e1
commit f28fdbb377
1672 changed files with 194066 additions and 0 deletions
@@ -0,0 +1,25 @@
plugins {
kotlin("android")
kotlin("kapt")
id("com.android.library")
}
dependencies {
implementation("com.airbnb.android:lottie:6.5.2")
implementation(project(":common"))
implementation(project(":core"))
implementation(project(":service"))
implementation(libs.kotlin.coroutine)
implementation(libs.androidx.core)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.activity)
implementation(libs.androidx.coordinator)
implementation(libs.androidx.recyclerview)
implementation(libs.androidx.fragment)
implementation(libs.androidx.viewpager)
implementation(libs.google.material)
}
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -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,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,96 @@
package com.github.kr328.clash.design
import android.content.Context
import android.net.Uri
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 HelpDesign(
context: Context,
openLink: (Uri) -> Unit,
) : Design<Unit>(context) {
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.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)
}
}
@@ -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,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,199 @@
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.View
import androidx.appcompat.app.AlertDialog
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.trafficTotal
import com.github.kr328.clash.design.databinding.DesignAboutBinding
import com.github.kr328.clash.design.databinding.DesignMainBinding
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.google.android.material.tabs.TabLayoutMediator
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import java.util.concurrent.TimeUnit
class MainDesign(context: Context) : Design<MainDesign.Request>(context) {
enum class Request {
ToggleStatus,
OpenProxy,
OpenProfiles,
OpenProviders,
OpenLogs,
OpenSettings,
OpenHelp,
OpenAbout,
OpenSettingsDIY,
OpenSettingsKEFU,
OpenModeDirect,
OpenModeGlobal,
OpenModeRule,
}
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")
}else{
binding.connectionButton.setAnimation("1d2a0fe5.json")
}
}
}
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"))
}
}
}
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)
}
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 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 adapter = ImageSliderAdapter(imageList)
viewPager2.adapter = adapter
// 设置 TabLayoutMediator 来同步 ViewPager2 和 TabLayout
TabLayoutMediator(tabLayout, viewPager2) { tab, position ->
// Tab 的自定义逻辑可以放在这里
}.attach()
binding.modeSelection.addOnButtonCheckedListener { group, checkedId, isChecked ->
// binding.modeSelectionbutton1.setBackgroundColor(Color.parseColor("#383838"))
// binding.modeSelectionbutton2.setBackgroundColor(Color.parseColor("#383838"))
// binding.modeSelectionbutton3.setBackgroundColor(Color.parseColor("#383838"))
// Reset all buttons to default background tint
binding.modeSelectionbutton1.backgroundTintList = ColorStateList.valueOf(Color.parseColor("#383838"))
binding.modeSelectionbutton2.backgroundTintList = ColorStateList.valueOf(Color.parseColor("#383838"))
binding.modeSelectionbutton3.backgroundTintList = ColorStateList.valueOf(Color.parseColor("#383838"))
when (checkedId) {
R.id.modeSelectionbutton1 -> {
request(Request.OpenModeDirect)
binding.modeSelectionbutton1.backgroundTintList = ColorStateList.valueOf(Color.parseColor("#2b9a45"))
}
R.id.modeSelectionbutton2 -> {
request(Request.OpenModeGlobal)
binding.modeSelectionbutton2.backgroundTintList = ColorStateList.valueOf(Color.parseColor("#2b9a45"))
}
R.id.modeSelectionbutton3 -> {
request(Request.OpenModeRule)
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,161 @@
package com.github.kr328.clash.design
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.view.View
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.service.model.AccessControlMode
import com.github.kr328.clash.service.store.ServiceStore
import kotlinx.coroutines.launch
class NetworkSettingsDesign(
context: Context,
uiStore: UiStore,
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()
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
}
}
}
category(R.string.behavior)
category(R.string.vpn_service_options)
switch(
value = srvStore::bypassPrivateNetwork,
title = R.string.bypass_private_network,
summary = R.string.bypass_private_network_summary,
configure = vpnDependencies::add,
)
switch(
value = srvStore::dnsHijacking,
title = R.string.dns_hijacking,
summary = R.string.dns_hijacking_summary,
configure = vpnDependencies::add,
)
switch(
value = srvStore::allowBypass,
title = R.string.allow_bypass,
summary = R.string.allow_bypass_summary,
configure = vpnDependencies::add,
)
switch(
value = srvStore::allowIpv6,
title = R.string.allow_ipv6,
summary = R.string.allow_ipv6_summary,
configure = vpnDependencies::add,
)
if (Build.VERSION.SDK_INT >= 29) {
switch(
value = srvStore::systemProxy,
title = R.string.system_proxy,
summary = R.string.system_proxy_summary,
configure = vpnDependencies::add,
)
}
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,
)
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,
)
clickable(
title = R.string.access_control_packages,
summary = R.string.access_control_packages_summary,
) {
clicked {
requests.trySend(Request.StartAccessControlList)
}
vpnDependencies.add(this)
}
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,47 @@
package com.github.kr328.clash.design
import android.content.Context
import android.content.SharedPreferences
import androidx.lifecycle.LiveData
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)
}
var selectnodeName: String
get() = prefs.getString("selectnodeName", "") ?: "自动选择"
set(value) {
prefs.edit().putString("selectnodeName", value).apply()
}
}
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,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,179 @@
package com.github.kr328.clash.design
import android.content.Context
import android.content.res.ColorStateList
import android.view.View
import android.widget.Toast
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.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.google.android.material.tabs.TabLayoutMediator
import kotlinx.coroutines.Dispatchers
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
}
}
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
}
override val root: View = binding.root
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 showModeSwitchTips() {
withContext(Dispatchers.Main) {
Toast.makeText(context, R.string.mode_switch_tips, Toast.LENGTH_LONG).show()
}
}
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.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)
)
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
}
}
}
@@ -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,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,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,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,157 @@
package com.github.kr328.clash.design.adapter
import android.content.Context
import android.graphics.Color
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.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)
}
var selectable: Boolean = false
var states: List<ProxyViewState> = emptyList()
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ProxyAdapter.Holder {
val view = LayoutInflater.from(parent.context).inflate(R.layout.proxy_view_item, parent, false)
return Holder(view)
}
var selectableView: Holder? = null
var selectableData: ProxyViewState? = null
override fun onBindViewHolder(holder: Holder, position: Int) {
val current = states[position]
holder.apply {
proxyName.text = current.proxy.name
proxySubName.text = current.proxy.subtitle
if (current.proxy.delay > 10000){
proxyLatency.text = "超时"
proxyLatency.setTextColor(Color.parseColor("#e73f31"))
}else if (current.proxy.delay > 500 && current.proxy.delay < 10000){
proxyLatency.text = "${current.proxy.delay}ms"
proxyLatency.setTextColor(Color.YELLOW)
}else if (current.proxy.delay < 500 && current.proxy.delay > 300){
proxyLatency.text = "${current.proxy.delay}ms"
proxyLatency.setTextColor(Color.parseColor("#fab610"))
}else{
proxyLatency.text = "${current.proxy.delay}ms"
proxyLatency.setTextColor(Color.parseColor("#2a9843"))
}
// 示例:延迟时间
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,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,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,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,124 @@
package com.github.kr328.clash.design.util
import android.content.Context
import android.view.View
import androidx.core.view.children
import androidx.databinding.Observable
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.github.kr328.clash.design.BR
import com.github.kr328.clash.design.ui.Surface
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlin.reflect.KMutableProperty0
fun RecyclerView.applyLinearAdapter(context: Context, adapter: RecyclerView.Adapter<*>) {
this.layoutManager = LinearLayoutManager(context)
this.adapter = adapter
}
suspend fun <H : RecyclerView.ViewHolder, T> RecyclerView.Adapter<H>.swapDataSet(
property: KMutableProperty0<List<T>>,
newDataset: List<T>,
compareEquals: Boolean = true
) {
val ignore = withContext(Dispatchers.Default) {
compareEquals && property.get() == newDataset
}
if (ignore) return
withContext(Dispatchers.Main) {
if (property.get().size == newDataset.size) {
property.set(newDataset)
notifyItemRangeChanged(0, newDataset.size)
} else {
notifyItemRangeRemoved(0, property.get().size)
property.set(newDataset)
notifyItemRangeInserted(0, newDataset.size)
}
}
}
suspend fun <H : RecyclerView.ViewHolder, T> RecyclerView.Adapter<H>.patchDataSet(
property: KMutableProperty0<List<T>>,
newDataset: List<T>,
detectMove: Boolean = false,
id: (T) -> Any? = { it }
) {
val result = withContext(Dispatchers.Default) {
property.get().diffWith(newDataset, detectMove, id)
}
withContext(Dispatchers.Main) {
property.set(newDataset)
result.dispatchUpdatesTo(this@patchDataSet)
}
}
fun RecyclerView.invalidateChildren() {
children.forEach {
it.postInvalidate()
}
}
fun RecyclerView.bindInsets(surface: Surface, top: Int = 0, bottom: Int = 0) {
fun applyInsets() {
val t = surface.insets.top + top
val b = surface.insets.bottom + bottom
setPaddingRelative(0, t, 0, b)
}
surface.addOnPropertyChangedCallback(object : Observable.OnPropertyChangedCallback() {
override fun onPropertyChanged(sender: Observable?, propertyId: Int) {
if (propertyId == BR.insets) {
applyInsets()
}
}
})
applyInsets()
}
fun RecyclerView.addScrolledToBottomObserver(listener: (RecyclerView, Boolean) -> Unit) {
val observer = object : RecyclerView.OnScrollListener() {
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
if (newState == RecyclerView.SCROLL_STATE_IDLE) {
listener(this@addScrolledToBottomObserver, recyclerView.isBottom)
}
}
}
addOnScrollListener(observer)
}
val RecyclerView.firstVisibleView: View?
get() {
return when (val mgr = layoutManager) {
is LinearLayoutManager ->
mgr.findViewByPosition(mgr.findFirstVisibleItemPosition())
else ->
throw UnsupportedOperationException("unsupported manager: $mgr")
}
}
val RecyclerView.isTop: Boolean
get() = computeHorizontalScrollOffset() == 0 && computeVerticalScrollOffset() == 0
val RecyclerView.isBottom: Boolean
get() {
return when (val mgr = layoutManager) {
is GridLayoutManager -> {
mgr.findFirstVisibleItemPosition() != 0 &&
mgr.findLastVisibleItemPosition() == adapter!!.itemCount - 1
}
else -> {
throw UnsupportedOperationException("unsupported layout manager")
}
}
}
@@ -0,0 +1,6 @@
package com.github.kr328.clash.design.util
import com.github.kr328.clash.design.view.ObservableScrollView
val ObservableScrollView.isTop: Boolean
get() = scrollX == 0 && scrollY == 0
@@ -0,0 +1,77 @@
package com.github.kr328.clash.design.util
import android.content.Context
import android.graphics.drawable.Drawable
import android.util.AttributeSet
import android.util.TypedValue
import androidx.annotation.AttrRes
import androidx.annotation.StyleRes
import com.github.kr328.clash.common.compat.getDrawableCompat
import com.github.kr328.clash.design.R
interface ClickableScope {
fun focusable(defaultValue: Boolean): Boolean
fun clickable(defaultValue: Boolean): Boolean
fun background(): Drawable?
fun foreground(): Drawable?
}
val Context.selectableItemBackground: Drawable?
get() {
return getDrawableCompat(resolveThemedResourceId(android.R.attr.selectableItemBackground))
}
fun Context.resolveClickableAttrs(
attributeSet: AttributeSet?,
@AttrRes defaultAttrRes: Int = 0,
@StyleRes defaultStyleRes: Int = 0,
block: ClickableScope.() -> Unit,
) {
theme.obtainStyledAttributes(
attributeSet,
R.styleable.Clickable,
defaultAttrRes,
defaultStyleRes
).apply {
val impl = object : ClickableScope {
override fun focusable(defaultValue: Boolean): Boolean {
return getBoolean(R.styleable.Clickable_android_focusable, defaultValue)
}
override fun clickable(defaultValue: Boolean): Boolean {
return getBoolean(R.styleable.Clickable_android_clickable, defaultValue)
}
override fun background(): Drawable? {
return getDrawable(R.styleable.Clickable_android_background)
}
override fun foreground(): Drawable? {
return getDrawable(R.styleable.Clickable_android_focusable)
}
}
impl.apply(block)
recycle()
}
}
fun Context.resolveThemedColor(@AttrRes resId: Int): Int {
return TypedValue().apply {
theme.resolveAttribute(resId, this, true)
}.data
}
fun Context.resolveThemedBoolean(@AttrRes resId: Int): Boolean {
return TypedValue().apply {
theme.resolveAttribute(resId, this, true)
}.data != 0
}
fun Context.resolveThemedResourceId(@AttrRes resId: Int): Int {
return TypedValue().apply {
theme.resolveAttribute(resId, this, true)
}.resourceId
}
@@ -0,0 +1,23 @@
package com.github.kr328.clash.design.util
import com.github.kr328.clash.design.Design
import com.github.kr328.clash.design.R
import com.github.kr328.clash.design.ui.ToastDuration
import com.google.android.material.dialog.MaterialAlertDialogBuilder
suspend fun Design<*>.showExceptionToast(message: CharSequence) {
showToast(message, ToastDuration.Long) {
setAction(R.string.detail) {
MaterialAlertDialogBuilder(it.context)
.setTitle(R.string.error)
.setMessage(message)
.setCancelable(true)
.setPositiveButton(R.string.ok) { _, _ -> }
.show()
}
}
}
suspend fun Design<*>.showExceptionToast(exception: Exception) {
showExceptionToast(exception.message ?: "Unknown")
}
@@ -0,0 +1,25 @@
package com.github.kr328.clash.design.util
import com.github.kr328.clash.common.util.PatternFileName
typealias Validator = (String) -> Boolean
val ValidatorAcceptAll: Validator = {
true
}
val ValidatorFileName: Validator = {
PatternFileName.matches(it) && it.isNotBlank()
}
val ValidatorNotBlank: Validator = {
it.isNotBlank()
}
val ValidatorHttpUrl: Validator = {
it.startsWith("https://", ignoreCase = true) || it.startsWith("http://", ignoreCase = true)
}
val ValidatorAutoUpdateInterval: Validator = {
it.isEmpty() || (it.toLongOrNull() ?: 0) >= 15
}
@@ -0,0 +1,16 @@
package com.github.kr328.clash.design.util
import android.view.View
import android.view.inputmethod.InputMethodManager
import androidx.core.content.getSystemService
fun View.requestTextInput() {
post {
requestFocus()
postDelayed({
context.getSystemService<InputMethodManager>()
?.showSoftInput(this, 0)
}, 300)
}
}
@@ -0,0 +1,62 @@
package com.github.kr328.clash.design.view
import android.content.Context
import android.graphics.drawable.Drawable
import android.util.AttributeSet
import android.view.View
import android.widget.FrameLayout
import androidx.annotation.AttrRes
import androidx.annotation.StyleRes
import com.github.kr328.clash.design.R
import com.github.kr328.clash.design.databinding.ComponentActionLabelBinding
import com.github.kr328.clash.design.util.layoutInflater
class ActionLabel @JvmOverloads constructor(
context: Context,
attributeSet: AttributeSet? = null,
@AttrRes defStyleAttr: Int = 0,
@StyleRes defStyleRes: Int = 0
) : FrameLayout(context, attributeSet, defStyleAttr, defStyleRes) {
private val binding = ComponentActionLabelBinding
.inflate(context.layoutInflater, this, true)
var icon: Drawable?
get() = binding.iconView.background
set(value) {
binding.iconView.background = value
}
var text: CharSequence?
get() = binding.textView.text
set(value) {
binding.textView.text = value
}
var subtext: CharSequence?
get() = binding.subtextView.text
set(value) {
binding.subtextView.text = value
binding.subtextView.visibility = if (value == null) View.GONE else View.VISIBLE
}
override fun setOnClickListener(l: OnClickListener?) {
binding.root.setOnClickListener(l)
}
init {
context.theme.obtainStyledAttributes(
attributeSet,
R.styleable.ActionLabel,
defStyleAttr,
defStyleRes
).apply {
try {
icon = getDrawable(R.styleable.ActionLabel_icon)
text = getString(R.styleable.ActionLabel_text)
subtext = getString(R.styleable.ActionLabel_subtext)
} finally {
recycle()
}
}
}
}
@@ -0,0 +1,87 @@
package com.github.kr328.clash.design.view
import android.content.Context
import android.graphics.drawable.Drawable
import android.util.AttributeSet
import android.widget.FrameLayout
import androidx.annotation.AttrRes
import androidx.annotation.StyleRes
import com.github.kr328.clash.design.R
import com.github.kr328.clash.design.databinding.ComponentActionTextFieldBinding
import com.github.kr328.clash.design.util.layoutInflater
class ActionTextField @JvmOverloads constructor(
context: Context,
attributeSet: AttributeSet? = null,
@AttrRes defStyleAttr: Int = 0,
@StyleRes defStyleRes: Int = 0
) : FrameLayout(context, attributeSet, defStyleAttr, defStyleRes) {
private val binding = ComponentActionTextFieldBinding
.inflate(context.layoutInflater, this, true)
var icon: Drawable?
get() = binding.iconView.background
set(value) {
binding.iconView.background = value
}
var title: CharSequence?
get() = binding.titleView.text
set(value) {
binding.titleView.text = value
}
var text: CharSequence?
get() = binding.textView.text
set(value) {
if (isEnabled)
binding.textView.text = value
else
binding.textView.text = context.getText(R.string.unavailable)
}
var placeholder: CharSequence?
get() = binding.textView.hint
set(value) {
binding.textView.hint = value
}
override fun setEnabled(enabled: Boolean) {
super.setEnabled(enabled)
if (enabled) {
binding.root.alpha = 1.0f
binding.actionView.isFocusable = true
binding.actionView.isClickable = true
} else {
binding.root.alpha = 0.33f
binding.actionView.isFocusable = false
binding.actionView.isClickable = false
}
text = text
}
override fun setOnClickListener(l: OnClickListener?) {
binding.actionView.setOnClickListener(l)
}
init {
context.theme.obtainStyledAttributes(
attributeSet,
R.styleable.ActionTextField,
defStyleAttr,
defStyleRes
).apply {
try {
isEnabled = getBoolean(R.styleable.ActionTextField_enabled, true)
icon = getDrawable(R.styleable.ActionTextField_icon)
title = getString(R.styleable.ActionTextField_title)
text = getString(R.styleable.ActionTextField_text)
placeholder = getString(R.styleable.ActionTextField_placeholder)
} finally {
recycle()
}
}
}
}
@@ -0,0 +1,28 @@
package com.github.kr328.clash.design.view
import android.content.Context
import android.util.AttributeSet
import android.view.MotionEvent
import android.widget.FrameLayout
import androidx.annotation.AttrRes
import androidx.annotation.StyleRes
import com.github.kr328.clash.design.util.resolveThemedColor
class ActivityBarLayout @JvmOverloads constructor(
context: Context,
attributeSet: AttributeSet? = null,
@AttrRes defStyleAttr: Int = 0,
@StyleRes defStyleRes: Int = 0
) : FrameLayout(context, attributeSet, defStyleAttr, defStyleRes) {
init {
alpha = 0.96f
setBackgroundColor(context.resolveThemedColor(android.R.attr.windowBackground))
}
override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
super.dispatchTouchEvent(ev)
return true
}
}
@@ -0,0 +1,25 @@
package com.github.kr328.clash.design.view
import android.content.Context
import android.graphics.Canvas
import android.util.AttributeSet
import androidx.annotation.AttrRes
import androidx.recyclerview.widget.RecyclerView
class AppRecyclerView @JvmOverloads constructor(
context: Context,
attributeSet: AttributeSet? = null,
@AttrRes defStyleAttr: Int = 0
) : RecyclerView(context, attributeSet, defStyleAttr) {
init {
isFocusable = false
}
override fun onDraw(c: Canvas?) {
super.onDraw(c)
}
override fun dispatchDraw(canvas: Canvas?) {
super.dispatchDraw(canvas)
}
}
@@ -0,0 +1,65 @@
package com.github.kr328.clash.design.view
import android.content.Context
import android.graphics.drawable.Drawable
import android.util.AttributeSet
import androidx.annotation.AttrRes
import com.github.kr328.clash.design.R
import com.github.kr328.clash.design.databinding.ComponentLargeActionLabelBinding
import com.github.kr328.clash.design.util.*
import com.google.android.material.card.MaterialCardView
class LargeActionCard @JvmOverloads constructor(
context: Context,
attributeSet: AttributeSet? = null,
@AttrRes defStyleAttr: Int = 0
) : MaterialCardView(context, attributeSet, defStyleAttr) {
private val binding = ComponentLargeActionLabelBinding
.inflate(context.layoutInflater, this, true)
var text: CharSequence?
get() = binding.textView.text
set(value) {
binding.textView.text = value
}
var subtext: CharSequence?
get() = binding.subtextView.text
set(value) {
binding.subtextView.text = value
}
var icon: Drawable?
get() = binding.iconView.background
set(value) {
binding.iconView.background = value
}
init {
context.resolveClickableAttrs(attributeSet, defStyleAttr) {
isFocusable = focusable(true)
isClickable = clickable(true)
foreground = foreground() ?: context.selectableItemBackground
}
context.theme.obtainStyledAttributes(
attributeSet,
R.styleable.LargeActionCard,
defStyleAttr,
0
).apply {
try {
icon = getDrawable(R.styleable.LargeActionCard_icon)
text = getString(R.styleable.LargeActionCard_text)
subtext = getString(R.styleable.LargeActionCard_subtext)
} finally {
recycle()
}
}
minimumHeight = context.getPixels(R.dimen.large_action_card_min_height)
radius = context.getPixels(R.dimen.large_action_card_radius).toFloat()
elevation = context.getPixels(R.dimen.large_action_card_elevation).toFloat()
setCardBackgroundColor(context.resolveThemedColor(R.attr.colorSurface))
}
}
@@ -0,0 +1,75 @@
package com.github.kr328.clash.design.view
import android.content.Context
import android.graphics.drawable.Drawable
import android.util.AttributeSet
import android.view.View
import android.widget.FrameLayout
import androidx.annotation.AttrRes
import androidx.annotation.StyleRes
import com.github.kr328.clash.design.R
import com.github.kr328.clash.design.databinding.ComponentLargeActionLabelBinding
import com.github.kr328.clash.design.util.layoutInflater
import com.github.kr328.clash.design.util.resolveClickableAttrs
import com.github.kr328.clash.design.util.selectableItemBackground
class LargeActionLabel @JvmOverloads constructor(
context: Context,
attributeSet: AttributeSet? = null,
@AttrRes defStyleAttr: Int = 0,
@StyleRes defStyleRes: Int = 0
) : FrameLayout(context, attributeSet, defStyleAttr, defStyleRes) {
private val binding = ComponentLargeActionLabelBinding
.inflate(context.layoutInflater, this, true)
var icon: Drawable?
get() = binding.iconView.background
set(value) {
binding.iconView.background = value
}
var text: CharSequence?
get() = binding.textView.text
set(value) {
binding.textView.text = value
}
var subtext: CharSequence?
get() = binding.subtextView.text
set(value) {
binding.subtextView.text = value
if (value == null) {
binding.subtextView.visibility = View.GONE
} else {
binding.subtextView.visibility = View.VISIBLE
}
}
init {
context.resolveClickableAttrs(
attributeSet,
defStyleAttr,
defStyleRes
) {
isFocusable = focusable(true)
isClickable = clickable(true)
background = background() ?: context.selectableItemBackground
}
context.theme.obtainStyledAttributes(
attributeSet,
R.styleable.LargeActionLabel,
defStyleAttr,
defStyleRes
).apply {
try {
icon = getDrawable(R.styleable.LargeActionLabel_icon)
text = getString(R.styleable.LargeActionLabel_text)
subtext = getString(R.styleable.LargeActionLabel_subtext)
} finally {
recycle()
}
}
}
}
@@ -0,0 +1,32 @@
package com.github.kr328.clash.design.view
import android.content.Context
import android.util.AttributeSet
import android.widget.ScrollView
import androidx.annotation.AttrRes
import androidx.annotation.StyleRes
class ObservableScrollView @JvmOverloads constructor(
context: Context,
attributeSet: AttributeSet? = null,
@AttrRes defStyleAttr: Int = 0,
@StyleRes defStyleRes: Int = 0
) : ScrollView(context, attributeSet, defStyleAttr, defStyleRes) {
fun interface OnScrollChangedListener {
fun onChanged(scrollView: ObservableScrollView, x: Int, y: Int, oldl: Int, oldt: Int)
}
private val scrollChangedListeners: MutableSet<OnScrollChangedListener> = mutableSetOf()
override fun onScrollChanged(l: Int, t: Int, oldl: Int, oldt: Int) {
super.onScrollChanged(l, t, oldl, oldt)
scrollChangedListeners.forEach {
it.onChanged(this, l, t, oldl, oldt)
}
}
fun addOnScrollChangedListener(listener: OnScrollChangedListener) {
scrollChangedListeners.add(listener)
}
}
@@ -0,0 +1,41 @@
package com.github.kr328.clash.design.view
import android.content.Context
import android.util.AttributeSet
import android.view.MotionEvent
import android.widget.FrameLayout
import kotlin.math.absoluteValue
import kotlin.math.tan
class VerticalScrollableHost @JvmOverloads constructor(
context: Context,
attributeSet: AttributeSet? = null,
defStyleAttr: Int = 0,
defStyleRes: Int = 0
) : FrameLayout(context, attributeSet, defStyleAttr, defStyleRes) {
private var initialX = 0f
private var initialY = 0f
private val degree = tan(Math.toRadians(15.0))
override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
val parentView = parent ?: return super.onInterceptTouchEvent(ev)
if (ev.action == MotionEvent.ACTION_DOWN) {
initialX = ev.x
initialY = ev.y
parentView.requestDisallowInterceptTouchEvent(true)
} else if (ev.action == MotionEvent.ACTION_MOVE) {
val dx = ev.x - initialX
val dy = ev.y - initialY
val t = dy.absoluteValue / dx.absoluteValue
if (t < degree) {
parentView.requestDisallowInterceptTouchEvent(false)
}
}
return super.onInterceptTouchEvent(ev)
}
}

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