From f85254ace2148af8f013b0ca1a3eae6dc8a38de7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BD=97=E6=B4=AA=E6=9D=A5?= Date: Wed, 4 Mar 2026 14:33:43 +0800 Subject: [PATCH 01/10] app: add apps strategy --- .gitignore | 1 + app/build.gradle.kts | 5 +- app/src/main/AndroidManifest.xml | 5 + .../kr328/clash/AccessControlActivity.kt | 46 ++++- .../kr328/clash/AppsStrategyActivity.kt | 154 ++++++++++++++++ .../com/github/kr328/clash/MainActivity.kt | 19 +- .../kr328/clash/design/AppsStrategyDesign.kt | 172 ++++++++++++++++++ .../github/kr328/clash/design/MainDesign.kt | 7 + .../adapter/AppsStrategyConfigAdapter.kt | 44 +++++ .../layout/adapter_apps_strategy_config.xml | 119 ++++++++++++ .../main/res/layout/design_apps_strategy.xml | 60 ++++++ design/src/main/res/layout/design_main.xml | 12 ++ .../dialog_apps_strategy_config_edit.xml | 57 ++++++ design/src/main/res/values-ja-rJP/strings.xml | 3 + design/src/main/res/values-ko-rKR/strings.xml | 3 + design/src/main/res/values-ru/strings.xml | 3 + design/src/main/res/values-vi/strings.xml | 3 + design/src/main/res/values-zh-rHK/strings.xml | 3 + design/src/main/res/values-zh-rTW/strings.xml | 3 + design/src/main/res/values-zh/strings.xml | 3 + design/src/main/res/values/strings.xml | 8 + .../github/kr328/clash/service/TunService.kt | 13 +- .../clash/service/model/AppsStrategyConfig.kt | 26 +++ .../kr328/clash/service/store/ServiceStore.kt | 42 +++++ .../kr328/clash/service/util/Serializers.kt | 2 + 25 files changed, 798 insertions(+), 15 deletions(-) create mode 100644 app/src/main/java/com/github/kr328/clash/AppsStrategyActivity.kt create mode 100644 design/src/main/java/com/github/kr328/clash/design/AppsStrategyDesign.kt create mode 100644 design/src/main/java/com/github/kr328/clash/design/adapter/AppsStrategyConfigAdapter.kt create mode 100644 design/src/main/res/layout/adapter_apps_strategy_config.xml create mode 100644 design/src/main/res/layout/design_apps_strategy.xml create mode 100644 design/src/main/res/layout/dialog_apps_strategy_config_edit.xml create mode 100644 service/src/main/java/com/github/kr328/clash/service/model/AppsStrategyConfig.kt diff --git a/.gitignore b/.gitignore index a169a39695..8a8e9b91e0 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ build/ /app/foss/release /app/premium/release /captures +/.kotlin/ # Ignore Gradle GUI config gradle-app.setting diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5272b86afb..603769cc31 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -45,8 +45,11 @@ task("downloadGeoFiles") { doLast { geoFilesUrls.forEach { (downloadUrl, outputFileName) -> - val url = URL(downloadUrl) val outputPath = file("$geoFilesDownloadDir/$outputFileName") + if (outputPath.exists()) { + return@forEach + } + val url = URL(downloadUrl) outputPath.parentFile.mkdirs() url.openStream().use { input -> Files.copy(input, outputPath.toPath(), StandardCopyOption.REPLACE_EXISTING) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f6fc1ab655..11fc50c830 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -167,6 +167,11 @@ android:configChanges="uiMode" android:exported="false" android:label="@string/access_control_packages" /> + () { override suspend fun main() { + val configUuid = intent.uuid val service = ServiceStore(this) - val selected = withContext(Dispatchers.IO) { - service.accessControlPackages.toMutableSet() + val (config, selected) = withContext(Dispatchers.IO) { + if (configUuid != null) { + val cfg = service.appsStrategyConfigs.find { it.uuid == configUuid } + if (cfg != null) { + cfg to cfg.packages.toMutableSet() + } else { + null to service.accessControlPackages.toMutableSet() + } + } else { + null to service.accessControlPackages.toMutableSet() + } } defer { withContext(Dispatchers.IO) { - val changed = selected != service.accessControlPackages - service.accessControlPackages = selected - if (clashRunning && changed) { - stopClashService() - while (clashRunning) { - delay(200) + if (config != null) { + val changed = selected != config.packages.toSet() + if (changed) { + val updatedConfig = AppsStrategyConfig( + uuid = config.uuid, + name = config.name, + mode = config.mode, + packages = selected.toList() + ) + val configs = service.appsStrategyConfigs.map { + if (it.uuid == config.uuid) updatedConfig else it + } + service.appsStrategyConfigs = configs + } + } else { + val changed = selected != service.accessControlPackages + service.accessControlPackages = selected + if (clashRunning && changed) { + stopClashService() + while (clashRunning) { + delay(200) + } + startClashService() } - startClashService() } } } diff --git a/app/src/main/java/com/github/kr328/clash/AppsStrategyActivity.kt b/app/src/main/java/com/github/kr328/clash/AppsStrategyActivity.kt new file mode 100644 index 0000000000..4ff2c679d0 --- /dev/null +++ b/app/src/main/java/com/github/kr328/clash/AppsStrategyActivity.kt @@ -0,0 +1,154 @@ +package com.github.kr328.clash + +import com.github.kr328.clash.common.util.intent +import com.github.kr328.clash.common.util.setUUID +import com.github.kr328.clash.design.AppsStrategyDesign +import com.github.kr328.clash.design.dialog.requestModelTextInput +import com.github.kr328.clash.design.requestAppListsConfigEdit +import com.github.kr328.clash.service.model.AccessControlMode +import com.github.kr328.clash.service.model.AppsStrategyConfig +import com.github.kr328.clash.service.store.ServiceStore +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.isActive +import kotlinx.coroutines.selects.select +import kotlinx.coroutines.withContext +import java.util.* + +class AppsStrategyActivity : BaseActivity() { + override suspend fun main() { + val design = AppsStrategyDesign(this) + + setContentDesign(design) + + design.fetch() + + while (isActive) { + select { + events.onReceive { + when (it) { + Event.ActivityStart -> { + design.fetch() + } + + else -> Unit + } + } + design.requests.onReceive { + when (it) { + is AppsStrategyDesign.Request.Choose -> { + startActivity(AccessControlActivity::class.intent.setUUID(it.config.uuid)) + } + + is AppsStrategyDesign.Request.Edit -> { + val result = requestAppListsConfigEdit( + initialName = it.config.name, + initialMode = it.config.mode, + title = getString(com.github.kr328.clash.design.R.string.edit_config_name), + hint = getString(com.github.kr328.clash.design.R.string.config_name), + showDelete = true + ) + if (result != null) { + if (result.deleted) { + deleteConfig(it.config.uuid) + design.fetch() + } else if (result.name != it.config.name || result.mode != it.config.mode) { + updateConfig(it.config.uuid, result.name, result.mode) + design.fetch() + } + } + } + + is AppsStrategyDesign.Request.Active -> { + setActiveConfig(it.config.uuid) + } + + AppsStrategyDesign.Request.Create -> { + val name = requestModelTextInput( + initial = "", + title = getString(com.github.kr328.clash.design.R.string.new_config), + hint = getString(com.github.kr328.clash.design.R.string.config_name) + ) + if (name.isNotBlank()) { + createConfig(name) + } + } + } + } + } + } + } + + private suspend fun AppsStrategyDesign.fetch() { + val configs = queryConfigs() + val activeUuid = queryActiveUuid() + patchConfigs(configs, activeUuid) + } + + private suspend fun queryConfigs(): List { + return withContext(Dispatchers.IO) { + val service = ServiceStore(this@AppsStrategyActivity) + service.appsStrategyConfigs + } + } + + private suspend fun queryActiveUuid(): UUID? { + return withContext(Dispatchers.IO) { + val service = ServiceStore(this@AppsStrategyActivity) + service.activeAppsStrategyConfigUuid + } + } + + private suspend fun setActiveConfig(uuid: UUID) { + withContext(Dispatchers.IO) { + val service = ServiceStore(this@AppsStrategyActivity) + service.activeAppsStrategyConfigUuid = uuid + design?.fetch() + } + } + + private suspend fun deleteConfig(uuid: UUID) { + withContext(Dispatchers.IO) { + val service = ServiceStore(this@AppsStrategyActivity) + val configs = service.appsStrategyConfigs.filter { it.uuid != uuid } + service.appsStrategyConfigs = configs + if (service.activeAppsStrategyConfigUuid == uuid) { + service.activeAppsStrategyConfigUuid = null + } + } + } + + private suspend fun createConfig(name: String) { + withContext(Dispatchers.IO) { + val service = ServiceStore(this@AppsStrategyActivity) + val configs = service.appsStrategyConfigs.toMutableList() + val newConfig = AppsStrategyConfig( + uuid = UUID.randomUUID(), + name = name, + mode = com.github.kr328.clash.service.model.AccessControlMode.AcceptAll, + packages = emptyList() + ) + configs.add(newConfig) + service.appsStrategyConfigs = configs + design?.fetch() + } + } + + private suspend fun updateConfig(uuid: UUID, newName: String, newMode: AccessControlMode) { + withContext(Dispatchers.IO) { + val service = ServiceStore(this@AppsStrategyActivity) + val configs = service.appsStrategyConfigs.map { config -> + if (config.uuid == uuid) { + AppsStrategyConfig( + uuid = config.uuid, + name = newName, + mode = newMode, + packages = config.packages + ) + } else { + config + } + } + service.appsStrategyConfigs = configs + } + } +} diff --git a/app/src/main/java/com/github/kr328/clash/MainActivity.kt b/app/src/main/java/com/github/kr328/clash/MainActivity.kt index c4237906b2..4b0c686072 100644 --- a/app/src/main/java/com/github/kr328/clash/MainActivity.kt +++ b/app/src/main/java/com/github/kr328/clash/MainActivity.kt @@ -3,15 +3,14 @@ package com.github.kr328.clash import android.content.pm.PackageManager import android.os.Build import android.os.Bundle -import android.os.PersistableBundle import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts.RequestPermission -import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import com.github.kr328.clash.common.util.intent import com.github.kr328.clash.common.util.ticker import com.github.kr328.clash.design.MainDesign import com.github.kr328.clash.design.ui.ToastDuration +import com.github.kr328.clash.service.store.ServiceStore import com.github.kr328.clash.util.startClashService import com.github.kr328.clash.util.stopClashService import com.github.kr328.clash.util.withClash @@ -59,6 +58,8 @@ class MainActivity : BaseActivity() { startActivity(ProfilesActivity::class.intent) MainDesign.Request.OpenProviders -> startActivity(ProvidersActivity::class.intent) + MainDesign.Request.OpenAppsStrategy -> + startActivity(AppsStrategyActivity::class.intent) MainDesign.Request.OpenLogs -> { if (LogcatService.running) { startActivity(LogcatActivity::class.intent) @@ -99,6 +100,8 @@ class MainActivity : BaseActivity() { withProfile { setProfileName(queryActive()?.name) } + + setAppsStrategyName(queryActiveAppsStrategyName()) } private suspend fun MainDesign.fetchTraffic() { @@ -143,6 +146,18 @@ class MainActivity : BaseActivity() { } } + private suspend fun queryActiveAppsStrategyName(): String? { + return withContext(Dispatchers.IO) { + val service = ServiceStore(this@MainActivity) + val activeUuid = service.activeAppsStrategyConfigUuid + if (activeUuid != null) { + service.appsStrategyConfigs.find { it.uuid == activeUuid }?.name + } else { + null + } + } + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { diff --git a/design/src/main/java/com/github/kr328/clash/design/AppsStrategyDesign.kt b/design/src/main/java/com/github/kr328/clash/design/AppsStrategyDesign.kt new file mode 100644 index 0000000000..e609640b01 --- /dev/null +++ b/design/src/main/java/com/github/kr328/clash/design/AppsStrategyDesign.kt @@ -0,0 +1,172 @@ +package com.github.kr328.clash.design + +import android.content.Context +import android.view.View +import androidx.appcompat.app.AlertDialog +import androidx.core.widget.doOnTextChanged +import com.github.kr328.clash.design.adapter.AppsStrategyConfigAdapter +import com.github.kr328.clash.design.databinding.DesignAppsStrategyBinding +import com.github.kr328.clash.design.databinding.DialogAppsStrategyConfigEditBinding +import com.github.kr328.clash.design.util.Validator +import com.github.kr328.clash.design.util.ValidatorAcceptAll +import com.github.kr328.clash.design.util.applyFrom +import com.github.kr328.clash.design.util.applyLinearAdapter +import com.github.kr328.clash.design.util.bindAppBarElevation +import com.github.kr328.clash.design.util.layoutInflater +import com.github.kr328.clash.design.util.patchDataSet +import com.github.kr328.clash.design.util.requestTextInput +import com.github.kr328.clash.design.util.root +import com.github.kr328.clash.service.model.AccessControlMode +import com.github.kr328.clash.service.model.AppsStrategyConfig +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import java.util.* +import kotlin.coroutines.resume + +class AppsStrategyDesign(context: Context) : Design(context) { + + sealed class Request { + object Create : Request() + data class Edit(val config: AppsStrategyConfig) : Request() + data class Active(val config: AppsStrategyConfig) : Request() + data class Choose(val config: AppsStrategyConfig) : Request() + } + + private val binding = DesignAppsStrategyBinding + .inflate(context.layoutInflater, context.root, false) + + private val adapter = AppsStrategyConfigAdapter( + context, + onClicked = { config -> + requests.trySend(Request.Active(config)) + }, + onEditClicked = { config -> + requests.trySend(Request.Edit(config)) + }, + onChooseClicked = { config -> + requests.trySend(Request.Choose(config)) + } + ) + + override val root: View + get() = binding.root + + init { + binding.self = this + + binding.activityBarLayout.applyFrom(context) + + binding.mainList.recyclerList.also { + it.bindAppBarElevation(binding.activityBarLayout) + it.applyLinearAdapter(context, adapter) + } + } + + suspend fun patchConfigs(configs: List, activeUuid: UUID?) { + withContext(Dispatchers.Main) { + adapter.apply { + patchDataSet(this::configs, configs, id = { it.uuid }) + this.activeUuid = activeUuid + notifyItemRangeChanged(0, configs.size) + } + } + } + + fun requestCreate() { + requests.trySend(Request.Create) + } +} + +data class AppListsConfigEditResult( + val name: String, + val mode: AccessControlMode, + val deleted: Boolean = false +) + +suspend fun Context.requestAppListsConfigEdit( + initialName: String, + initialMode: AccessControlMode, + title: CharSequence, + hint: CharSequence? = null, + error: CharSequence? = null, + showDelete: Boolean = false, + validator: Validator = ValidatorAcceptAll, +): AppListsConfigEditResult? { + return suspendCancellableCoroutine { + val binding = DialogAppsStrategyConfigEditBinding + .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() ?: "" + val mode = when (binding.modeGroup.checkedRadioButtonId) { + R.id.mode_accept_selected -> AccessControlMode.AcceptSelected + R.id.mode_deny_selected -> AccessControlMode.DenySelected + else -> AccessControlMode.AcceptAll + } + + if (validator(text)) + it.resume(AppListsConfigEditResult(text, mode, false)) + else + it.resume(null) + } + .setNegativeButton(R.string.cancel) { _, _ -> } + .setOnDismissListener { _ -> + if (!it.isCompleted) + it.resume(null) + } + + if (showDelete) { + builder.setNeutralButton(R.string.delete) { _, _ -> + it.resume(AppListsConfigEditResult("", AccessControlMode.AcceptAll, true)) + } + } + + 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(initialName) + setSelection(0, initialName.length) + requestTextInput() + } + + // Set initial mode + when (initialMode) { + AccessControlMode.AcceptAll -> binding.modeAcceptAll.isChecked = true + AccessControlMode.AcceptSelected -> binding.modeAcceptSelected.isChecked = true + AccessControlMode.DenySelected -> binding.modeDenySelected.isChecked = true + } + } + + dialog.show() + } +} diff --git a/design/src/main/java/com/github/kr328/clash/design/MainDesign.kt b/design/src/main/java/com/github/kr328/clash/design/MainDesign.kt index f418e488d9..d699cc6545 100644 --- a/design/src/main/java/com/github/kr328/clash/design/MainDesign.kt +++ b/design/src/main/java/com/github/kr328/clash/design/MainDesign.kt @@ -19,6 +19,7 @@ class MainDesign(context: Context) : Design(context) { OpenProxy, OpenProfiles, OpenProviders, + OpenAppsStrategy, OpenLogs, OpenSettings, OpenHelp, @@ -37,6 +38,12 @@ class MainDesign(context: Context) : Design(context) { } } + suspend fun setAppsStrategyName(name: String?) { + withContext(Dispatchers.Main) { + binding.appsStrategyName = name + } + } + suspend fun setClashRunning(running: Boolean) { withContext(Dispatchers.Main) { binding.clashRunning = running diff --git a/design/src/main/java/com/github/kr328/clash/design/adapter/AppsStrategyConfigAdapter.kt b/design/src/main/java/com/github/kr328/clash/design/adapter/AppsStrategyConfigAdapter.kt new file mode 100644 index 0000000000..e08d424917 --- /dev/null +++ b/design/src/main/java/com/github/kr328/clash/design/adapter/AppsStrategyConfigAdapter.kt @@ -0,0 +1,44 @@ +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.design.databinding.AdapterAppsStrategyConfigBinding +import com.github.kr328.clash.design.util.layoutInflater +import com.github.kr328.clash.service.model.AppsStrategyConfig +import java.util.* + +class AppsStrategyConfigAdapter( + private val context: Context, + private val onClicked: (AppsStrategyConfig) -> Unit, + private val onEditClicked: (AppsStrategyConfig) -> Unit, + private val onChooseClicked: (AppsStrategyConfig) -> Unit, +) : RecyclerView.Adapter() { + + class Holder(val binding: AdapterAppsStrategyConfigBinding) : + RecyclerView.ViewHolder(binding.root) + + var configs: List = emptyList() + var activeUuid: UUID? = null + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder { + return Holder( + AdapterAppsStrategyConfigBinding + .inflate(context.layoutInflater, parent, false) + ) + } + + override fun onBindViewHolder(holder: Holder, position: Int) { + val config = configs[position] + val binding = holder.binding + + binding.config = config + binding.isActive = config.uuid == activeUuid + binding.clicked = View.OnClickListener { onClicked(config) } + binding.editClicked = View.OnClickListener { onEditClicked(config) } + binding.chooseClicked = View.OnClickListener { onChooseClicked(config) } + } + + override fun getItemCount(): Int = configs.size +} diff --git a/design/src/main/res/layout/adapter_apps_strategy_config.xml b/design/src/main/res/layout/adapter_apps_strategy_config.xml new file mode 100644 index 0000000000..c9b280f25c --- /dev/null +++ b/design/src/main/res/layout/adapter_apps_strategy_config.xml @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/design/src/main/res/layout/design_apps_strategy.xml b/design/src/main/res/layout/design_apps_strategy.xml new file mode 100644 index 0000000000..f9ca50a024 --- /dev/null +++ b/design/src/main/res/layout/design_apps_strategy.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/design/src/main/res/layout/design_main.xml b/design/src/main/res/layout/design_main.xml index 1d02d7b403..38bb08c82a 100644 --- a/design/src/main/res/layout/design_main.xml +++ b/design/src/main/res/layout/design_main.xml @@ -17,6 +17,9 @@ + @@ -107,6 +110,15 @@ app:subtext="@{profileName != null ? @string/format_profile_activated(profileName) : @string/not_selected}" app:text="@string/profile" /> + + + + + + + + + + + + + + + + + + + + + + diff --git a/design/src/main/res/values-ja-rJP/strings.xml b/design/src/main/res/values-ja-rJP/strings.xml index c346e3e760..d874b20e03 100644 --- a/design/src/main/res/values-ja-rJP/strings.xml +++ b/design/src/main/res/values-ja-rJP/strings.xml @@ -253,4 +253,7 @@ Override Destination カメラのアクセスが制限されています。設定から有効にしてください。 システムで予期しない例外が発生しました。 + 新規構成 + 構成名 + 構成名の編集 \ No newline at end of file diff --git a/design/src/main/res/values-ko-rKR/strings.xml b/design/src/main/res/values-ko-rKR/strings.xml index ab0bbf0a5e..65c52b07cb 100644 --- a/design/src/main/res/values-ko-rKR/strings.xml +++ b/design/src/main/res/values-ko-rKR/strings.xml @@ -253,4 +253,7 @@ Override Destination 카메라 접근이 제한되었습니다. 설정에서 허용해 주세요. 처리되지 않은 시스템 예외가 발생했습니다. + 새 구성 + 구성 이름 + 구성 이름 편집 \ No newline at end of file diff --git a/design/src/main/res/values-ru/strings.xml b/design/src/main/res/values-ru/strings.xml index 342b639d28..d82f658663 100644 --- a/design/src/main/res/values-ru/strings.xml +++ b/design/src/main/res/values-ru/strings.xml @@ -317,4 +317,7 @@ Override Destination Доступ к камере ограничен. Разрешите его в настройках. Произошла не обрабатываемая системная ошибка. + Новая конфигурация + Название конфигурации + Изменить название конфигурации diff --git a/design/src/main/res/values-vi/strings.xml b/design/src/main/res/values-vi/strings.xml index 2bf8aacad4..0d28ff86d7 100644 --- a/design/src/main/res/values-vi/strings.xml +++ b/design/src/main/res/values-vi/strings.xml @@ -239,4 +239,7 @@ Nhập từ Mã QR Quyền truy cập camera bị hạn chế. Vui lòng bật trong Cài đặt. Đã xảy ra ngoại lệ hệ thống không xử lý được. + Cấu hình mới + Tên cấu hình + Sửa tên cấu hình diff --git a/design/src/main/res/values-zh-rHK/strings.xml b/design/src/main/res/values-zh-rHK/strings.xml index 537b33f3ee..0f1c33aa3b 100644 --- a/design/src/main/res/values-zh-rHK/strings.xml +++ b/design/src/main/res/values-zh-rHK/strings.xml @@ -250,4 +250,7 @@ Override Destination 相機權限受限,請前往設定開啟。 發生系統未知異常,操作失敗。 + 新配置 + 配置名稱 + 編輯配置名稱 \ No newline at end of file diff --git a/design/src/main/res/values-zh-rTW/strings.xml b/design/src/main/res/values-zh-rTW/strings.xml index cf70cc2d41..8d1f234d22 100644 --- a/design/src/main/res/values-zh-rTW/strings.xml +++ b/design/src/main/res/values-zh-rTW/strings.xml @@ -250,4 +250,7 @@ Override Destination 相機權限受限,請前往設定開啟。 發生系統未知異常,操作失敗。 + 新配置 + 配置名稱 + 編輯配置名稱 diff --git a/design/src/main/res/values-zh/strings.xml b/design/src/main/res/values-zh/strings.xml index 48daff432b..43c56571af 100644 --- a/design/src/main/res/values-zh/strings.xml +++ b/design/src/main/res/values-zh/strings.xml @@ -262,4 +262,7 @@ Clash.Meta 服务已停止 相机权限受限,请前往设置开启。 发生系统未知异常,操作失败。 + 新配置 + 配置名称 + 编辑配置名称 diff --git a/design/src/main/res/values/strings.xml b/design/src/main/res/values/strings.xml index 518af90d0d..9615f9bda4 100644 --- a/design/src/main/res/values/strings.xml +++ b/design/src/main/res/values/strings.xml @@ -1,4 +1,9 @@ + + %d app selected + %d apps selected + + Clash Meta Clash Meta Alpha Clash Meta for Android @@ -350,4 +355,7 @@ Hide app from the Recent apps screen Camera access is restricted. Please enable it in Settings. An unhandled system exception occurred. + New Config + Config Name + Edit Config Name diff --git a/service/src/main/java/com/github/kr328/clash/service/TunService.kt b/service/src/main/java/com/github/kr328/clash/service/TunService.kt index 8550f8ab81..643bbcd364 100644 --- a/service/src/main/java/com/github/kr328/clash/service/TunService.kt +++ b/service/src/main/java/com/github/kr328/clash/service/TunService.kt @@ -154,15 +154,22 @@ class TunService : VpnService(), CoroutineScope by CoroutineScope(Dispatchers.De } // Access Control - when (store.accessControlMode) { + val activeConfig = store.activeAppsStrategyConfigUuid?.let { uuid -> + store.appsStrategyConfigs.find { it.uuid == uuid } + } + + val mode = activeConfig?.mode ?: store.accessControlMode + val packages = activeConfig?.packages ?: store.accessControlPackages.toList() + + when (mode) { AccessControlMode.AcceptAll -> Unit AccessControlMode.AcceptSelected -> { - (store.accessControlPackages + packageName).forEach { + (packages + packageName).forEach { runCatching { addAllowedApplication(it) } } } AccessControlMode.DenySelected -> { - (store.accessControlPackages - packageName).forEach { + (packages - packageName).forEach { runCatching { addDisallowedApplication(it) } } } diff --git a/service/src/main/java/com/github/kr328/clash/service/model/AppsStrategyConfig.kt b/service/src/main/java/com/github/kr328/clash/service/model/AppsStrategyConfig.kt new file mode 100644 index 0000000000..547db16de7 --- /dev/null +++ b/service/src/main/java/com/github/kr328/clash/service/model/AppsStrategyConfig.kt @@ -0,0 +1,26 @@ +package com.github.kr328.clash.service.model + +import com.github.kr328.clash.service.util.UUIDSerializer +import kotlinx.serialization.InternalSerializationApi +import kotlinx.serialization.Serializable +import java.util.* + +@OptIn(InternalSerializationApi::class) +@Serializable +class AppsStrategyConfig( + @Serializable(with = UUIDSerializer::class) + val uuid: UUID, + val name: String, + val mode: AccessControlMode, + val packages: List +) { + companion object { + fun create( + name: String, + mode: AccessControlMode, + packages: List + ): AppsStrategyConfig { + return AppsStrategyConfig(UUID.randomUUID(), name, mode, packages) + } + } +} diff --git a/service/src/main/java/com/github/kr328/clash/service/store/ServiceStore.kt b/service/src/main/java/com/github/kr328/clash/service/store/ServiceStore.kt index d361848ff5..c5d5173bbb 100644 --- a/service/src/main/java/com/github/kr328/clash/service/store/ServiceStore.kt +++ b/service/src/main/java/com/github/kr328/clash/service/store/ServiceStore.kt @@ -5,6 +5,9 @@ import com.github.kr328.clash.common.store.Store import com.github.kr328.clash.common.store.asStoreProvider import com.github.kr328.clash.service.PreferenceProvider import com.github.kr328.clash.service.model.AccessControlMode +import com.github.kr328.clash.service.model.AppsStrategyConfig +import kotlinx.serialization.json.Json +import java.io.File import java.util.* class ServiceStore(context: Context) { @@ -14,6 +17,15 @@ class ServiceStore(context: Context) { .asStoreProvider() ) + private val filesDir = context.filesDir + private val settingsDir = File(filesDir, "settings").apply { mkdirs() } + private val appListsConfigsFile = File(settingsDir, "app_lists_configs.json") + + private val json = Json { + ignoreUnknownKeys = true + prettyPrint = true + } + var activeProfile: UUID? by store.typedString( key = "active_profile", from = { if (it.isBlank()) null else UUID.fromString(it) }, @@ -65,4 +77,34 @@ class ServiceStore(context: Context) { key = "dynamic_notification", defaultValue = true ) + + var appsStrategyConfigs: List + get() { + return if (appListsConfigsFile.exists()) { + try { + json.decodeFromString( + kotlinx.serialization.builtins.ListSerializer(AppsStrategyConfig.serializer()), + appListsConfigsFile.readText() + ) + } catch (e: Exception) { + emptyList() + } + } else { + emptyList() + } + } + set(value) { + appListsConfigsFile.writeText( + json.encodeToString( + kotlinx.serialization.builtins.ListSerializer(AppsStrategyConfig.serializer()), + value + ) + ) + } + + var activeAppsStrategyConfigUuid: UUID? by store.typedString( + key = "active_apps_strategy_config_uuid", + from = { if (it.isBlank()) null else UUID.fromString(it) }, + to = { it?.toString() ?: "" } + ) } \ No newline at end of file diff --git a/service/src/main/java/com/github/kr328/clash/service/util/Serializers.kt b/service/src/main/java/com/github/kr328/clash/service/util/Serializers.kt index 5cbe51c017..7e8bfba987 100644 --- a/service/src/main/java/com/github/kr328/clash/service/util/Serializers.kt +++ b/service/src/main/java/com/github/kr328/clash/service/util/Serializers.kt @@ -1,5 +1,6 @@ package com.github.kr328.clash.service.util +import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.KSerializer import kotlinx.serialization.descriptors.PrimitiveKind import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor @@ -8,6 +9,7 @@ import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder import java.util.* +@OptIn(ExperimentalSerializationApi::class) class UUIDSerializer : KSerializer { override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("UUID", PrimitiveKind.STRING) From 6cf1523740768c918f6a52a034043f98e71a2ed7 Mon Sep 17 00:00:00 2001 From: clarence Date: Thu, 1 Oct 2026 15:42:22 +0800 Subject: [PATCH 02/10] Add WebDAV sync for subscriptions and access-control packages. Merge with the remote copy before uploading so subscriptions that exist only on another device are kept. Co-authored-by: Cursor --- app/src/main/AndroidManifest.xml | 5 + .../kr328/clash/AccessControlActivity.kt | 34 ++- .../kr328/clash/NetworkSettingsActivity.kt | 28 ++ .../github/kr328/clash/SettingsActivity.kt | 2 + .../kr328/clash/WebdavSettingsActivity.kt | 70 +++++ build.gradle.kts | 5 + .../clash/design/NetworkSettingsDesign.kt | 11 +- .../kr328/clash/design/SettingsDesign.kt | 2 +- .../clash/design/WebdavSettingsDesign.kt | 145 +++++++++ .../src/main/res/layout/design_settings.xml | 7 + design/src/main/res/values-zh/strings.xml | 19 ++ design/src/main/res/values/strings.xml | 19 ++ gradle/libs.versions.toml | 6 + service/build.gradle.kts | 9 + .../kr328/clash/service/ProfileManager.kt | 34 ++- .../kr328/clash/service/ProfileProcessor.kt | 35 ++- .../clash/service/remote/IProfileManager.kt | 1 + .../clash/service/webdav/WebdavAutoSync.kt | 71 +++++ .../clash/service/webdav/WebdavCipher.kt | 76 +++++ .../clash/service/webdav/WebdavClient.kt | 286 ++++++++++++++++++ .../clash/service/webdav/WebdavLocalState.kt | 68 +++++ .../kr328/clash/service/webdav/WebdavMerge.kt | 88 ++++++ .../clash/service/webdav/WebdavModels.kt | 96 ++++++ .../clash/service/webdav/WebdavProfiles.kt | 258 ++++++++++++++++ .../clash/service/webdav/WebdavProtocol.kt | 223 ++++++++++++++ .../service/webdav/WebdavSettingsStore.kt | 90 ++++++ .../kr328/clash/service/webdav/WebdavSync.kt | 230 ++++++++++++++ service/src/main/res/values-zh/strings.xml | 19 ++ service/src/main/res/values/strings.xml | 19 ++ .../clash/service/webdav/WebdavSyncTest.kt | 219 ++++++++++++++ 30 files changed, 2156 insertions(+), 19 deletions(-) create mode 100644 app/src/main/java/com/github/kr328/clash/WebdavSettingsActivity.kt create mode 100644 design/src/main/java/com/github/kr328/clash/design/WebdavSettingsDesign.kt create mode 100644 service/src/main/java/com/github/kr328/clash/service/webdav/WebdavAutoSync.kt create mode 100644 service/src/main/java/com/github/kr328/clash/service/webdav/WebdavCipher.kt create mode 100644 service/src/main/java/com/github/kr328/clash/service/webdav/WebdavClient.kt create mode 100644 service/src/main/java/com/github/kr328/clash/service/webdav/WebdavLocalState.kt create mode 100644 service/src/main/java/com/github/kr328/clash/service/webdav/WebdavMerge.kt create mode 100644 service/src/main/java/com/github/kr328/clash/service/webdav/WebdavModels.kt create mode 100644 service/src/main/java/com/github/kr328/clash/service/webdav/WebdavProfiles.kt create mode 100644 service/src/main/java/com/github/kr328/clash/service/webdav/WebdavProtocol.kt create mode 100644 service/src/main/java/com/github/kr328/clash/service/webdav/WebdavSettingsStore.kt create mode 100644 service/src/main/java/com/github/kr328/clash/service/webdav/WebdavSync.kt create mode 100644 service/src/test/java/com/github/kr328/clash/service/webdav/WebdavSyncTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 77ce911cf2..7e32ed8708 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -172,6 +172,11 @@ android:configChanges="uiMode" android:exported="false" android:label="@string/meta_features" /> + () { defer { withContext(Dispatchers.IO) { - if (config != null) { - val changed = selected != config.packages.toSet() - if (changed) { + val changed = if (config != null) { + val packagesChanged = selected != config.packages.toSet() + if (packagesChanged) { val updatedConfig = AppsStrategyConfig( uuid = config.uuid, name = config.name, @@ -54,17 +58,37 @@ class AccessControlActivity : BaseActivity() { if (it.uuid == config.uuid) updatedConfig else it } service.appsStrategyConfigs = configs + WebdavAutoSync.noteAppsStrategy(this@AccessControlActivity, config.uuid) } + packagesChanged } else { - val changed = selected != service.accessControlPackages + val packagesChanged = selected != service.accessControlPackages service.accessControlPackages = selected - if (clashRunning && changed) { + if (packagesChanged) { + WebdavAutoSync.noteAccessControl(this@AccessControlActivity) + } + if (clashRunning && packagesChanged) { stopClashService() while (clashRunning) { delay(200) } startClashService() } + packagesChanged + } + if (changed) { + val settings = WebdavSettingsStore(this@AccessControlActivity).load() + if (settings.autoSync && settings.url.isNotBlank() && settings.username.isNotBlank() && settings.password.isNotEmpty()) { + withProfile { + webdavPerform( + WebdavSync.ACTION_SYNC, + settings.url, + settings.username, + settings.password, + settings.remotePath, + ) + } + } } } } diff --git a/app/src/main/java/com/github/kr328/clash/NetworkSettingsActivity.kt b/app/src/main/java/com/github/kr328/clash/NetworkSettingsActivity.kt index c7cc72451f..6503c11a8e 100644 --- a/app/src/main/java/com/github/kr328/clash/NetworkSettingsActivity.kt +++ b/app/src/main/java/com/github/kr328/clash/NetworkSettingsActivity.kt @@ -2,8 +2,14 @@ package com.github.kr328.clash import com.github.kr328.clash.common.util.intent import com.github.kr328.clash.design.NetworkSettingsDesign +import com.github.kr328.clash.design.util.showExceptionToast import com.github.kr328.clash.service.store.ServiceStore +import com.github.kr328.clash.service.webdav.WebdavAutoSync +import com.github.kr328.clash.service.webdav.WebdavSettingsStore +import com.github.kr328.clash.service.webdav.WebdavSync +import com.github.kr328.clash.util.withProfile import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch import kotlinx.coroutines.selects.select class NetworkSettingsActivity : BaseActivity() { @@ -30,10 +36,32 @@ class NetworkSettingsActivity : BaseActivity() { when (it) { NetworkSettingsDesign.Request.StartAccessControlList -> startActivity(AccessControlActivity::class.intent) + NetworkSettingsDesign.Request.AccessControlChanged -> + launch { syncAccessControl(design) } } } } } } + private suspend fun syncAccessControl(design: NetworkSettingsDesign) { + WebdavAutoSync.noteAccessControl(this) + val settings = WebdavSettingsStore(this).load() + if (!settings.autoSync || settings.url.isBlank() || settings.username.isBlank() || settings.password.isEmpty()) { + return + } + try { + withProfile { + webdavPerform( + WebdavSync.ACTION_SYNC, + settings.url, + settings.username, + settings.password, + settings.remotePath, + ) + } + } catch (e: Exception) { + design.showExceptionToast(e) + } + } } diff --git a/app/src/main/java/com/github/kr328/clash/SettingsActivity.kt b/app/src/main/java/com/github/kr328/clash/SettingsActivity.kt index 95a0393367..65c47b3d30 100644 --- a/app/src/main/java/com/github/kr328/clash/SettingsActivity.kt +++ b/app/src/main/java/com/github/kr328/clash/SettingsActivity.kt @@ -26,6 +26,8 @@ class SettingsActivity : BaseActivity() { startActivity(OverrideSettingsActivity::class.intent) SettingsDesign.Request.StartMetaFeature -> startActivity(MetaFeatureSettingsActivity::class.intent) + SettingsDesign.Request.StartWebdav -> + startActivity(WebdavSettingsActivity::class.intent) } } } diff --git a/app/src/main/java/com/github/kr328/clash/WebdavSettingsActivity.kt b/app/src/main/java/com/github/kr328/clash/WebdavSettingsActivity.kt new file mode 100644 index 0000000000..346e76f3e5 --- /dev/null +++ b/app/src/main/java/com/github/kr328/clash/WebdavSettingsActivity.kt @@ -0,0 +1,70 @@ +package com.github.kr328.clash + +import com.github.kr328.clash.design.WebdavSettingsDesign +import com.github.kr328.clash.design.ui.ToastDuration +import com.github.kr328.clash.design.R as DesignR +import com.github.kr328.clash.design.util.showExceptionToast +import com.github.kr328.clash.service.webdav.WebdavLocalState +import com.github.kr328.clash.service.webdav.WebdavSettingsStore +import com.github.kr328.clash.service.webdav.WebdavSync +import com.github.kr328.clash.util.withProfile +import kotlinx.coroutines.isActive +import kotlinx.coroutines.selects.select + +class WebdavSettingsActivity : BaseActivity() { + private var busy = false + + override suspend fun main() { + val store = WebdavSettingsStore(this) + val design = WebdavSettingsDesign( + this, + store, + WebdavLocalState(this).lastMessage(), + ) + + setContentDesign(design) + + while (isActive) { + select { + events.onReceive { + } + design.requests.onReceive { + val action = when (it) { + WebdavSettingsDesign.Request.Test -> WebdavSync.ACTION_TEST + WebdavSettingsDesign.Request.Sync -> WebdavSync.ACTION_SYNC + WebdavSettingsDesign.Request.Pull -> WebdavSync.ACTION_PULL + } + runAction(design, store, action) + } + } + } + } + + private suspend fun runAction( + design: WebdavSettingsDesign, + store: WebdavSettingsStore, + action: String, + ) { + if (busy) return + busy = true + try { + val settings = store.load() + design.showToast(DesignR.string.webdav_working, ToastDuration.Short) + val message = withProfile { + webdavPerform( + action, + settings.url, + settings.username, + settings.password, + settings.remotePath, + ) + } + design.setStatus(message) + design.showToast(message, ToastDuration.Long) + } catch (e: Exception) { + design.showExceptionToast(e) + } finally { + busy = false + } + } +} diff --git a/build.gradle.kts b/build.gradle.kts index 73ec8aff25..27d3cd66d2 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -194,10 +194,15 @@ subprojects { } compileOptions { + isCoreLibraryDesugaringEnabled = true sourceCompatibility = JavaVersion.VERSION_21 targetCompatibility = JavaVersion.VERSION_21 } } + + dependencies { + add("coreLibraryDesugaring", "com.android.tools:desugar_jdk_libs:2.1.4") + } } task("clean", type = Delete::class) { diff --git a/design/src/main/java/com/github/kr328/clash/design/NetworkSettingsDesign.kt b/design/src/main/java/com/github/kr328/clash/design/NetworkSettingsDesign.kt index 93e1ee3d07..ae653fbb14 100644 --- a/design/src/main/java/com/github/kr328/clash/design/NetworkSettingsDesign.kt +++ b/design/src/main/java/com/github/kr328/clash/design/NetworkSettingsDesign.kt @@ -22,7 +22,8 @@ class NetworkSettingsDesign( running: Boolean, ) : Design(context) { enum class Request { - StartAccessControlList + StartAccessControlList, + AccessControlChanged, } private val binding = DesignSettingsCommonBinding @@ -120,8 +121,12 @@ class NetworkSettingsDesign( R.string.deny_selected_apps ), title = R.string.access_control_mode, - configure = vpnDependencies::add, - ) + ) { + vpnDependencies.add(this) + listener = OnChangedListener { + requests.trySend(Request.AccessControlChanged) + } + } clickable( title = R.string.access_control_packages, diff --git a/design/src/main/java/com/github/kr328/clash/design/SettingsDesign.kt b/design/src/main/java/com/github/kr328/clash/design/SettingsDesign.kt index 0536a587cd..fb1e5bc7e1 100644 --- a/design/src/main/java/com/github/kr328/clash/design/SettingsDesign.kt +++ b/design/src/main/java/com/github/kr328/clash/design/SettingsDesign.kt @@ -10,7 +10,7 @@ import com.github.kr328.clash.design.util.root class SettingsDesign(context: Context) : Design(context) { enum class Request { - StartApp, StartNetwork, StartOverride, StartMetaFeature, + StartApp, StartNetwork, StartOverride, StartMetaFeature, StartWebdav, } private val binding = DesignSettingsBinding diff --git a/design/src/main/java/com/github/kr328/clash/design/WebdavSettingsDesign.kt b/design/src/main/java/com/github/kr328/clash/design/WebdavSettingsDesign.kt new file mode 100644 index 0000000000..5210472552 --- /dev/null +++ b/design/src/main/java/com/github/kr328/clash/design/WebdavSettingsDesign.kt @@ -0,0 +1,145 @@ +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.dialog.requestModelTextInput +import com.github.kr328.clash.design.preference.ClickablePreference +import com.github.kr328.clash.design.preference.NullableTextAdapter +import com.github.kr328.clash.design.preference.OnChangedListener +import com.github.kr328.clash.design.preference.TipsPreference +import com.github.kr328.clash.design.preference.category +import com.github.kr328.clash.design.preference.clickable +import com.github.kr328.clash.design.preference.editableText +import com.github.kr328.clash.design.preference.preferenceScreen +import com.github.kr328.clash.design.preference.switch +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 +import com.github.kr328.clash.service.webdav.WebdavSettingsStore +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class WebdavSettingsDesign( + context: Context, + private val store: WebdavSettingsStore, + status: String, +) : Design(context) { + enum class Request { + Test, Sync, Pull, + } + + private val binding = DesignSettingsCommonBinding + .inflate(context.layoutInflater, context.root, false) + + private var statusPreference: TipsPreference? = null + private var passwordPreference: ClickablePreference? = null + + 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.webdav_tips) + + statusPreference = tips(R.string.webdav_status_empty) + + category(R.string.webdav_server) + + editableText( + value = store::url, + adapter = NullableTextAdapter.String, + title = R.string.webdav_url, + placeholder = R.string.webdav_url_placeholder, + empty = R.string.webdav_url_placeholder, + ) + + editableText( + value = store::username, + adapter = NullableTextAdapter.String, + title = R.string.webdav_username, + empty = R.string.webdav_unset, + ) + + passwordPreference = clickable( + title = R.string.webdav_password, + summary = if (store.password.isEmpty()) R.string.webdav_unset else R.string.webdav_password_set, + ) { + clicked { + launch(Dispatchers.Main) { + val entered = context.requestModelTextInput( + initial = store.password, + title = context.getText(R.string.webdav_password), + hint = context.getText(R.string.webdav_password), + ) + store.password = entered + refreshPassword() + } + } + } + + editableText( + value = store::remotePath, + adapter = NullableTextAdapter.String, + title = R.string.webdav_remote_path, + placeholder = R.string.webdav_remote_path_default, + empty = R.string.webdav_remote_path_default, + ) + + category(R.string.webdav_sync) + + switch( + value = store::autoSync, + title = R.string.webdav_auto_sync, + summary = R.string.webdav_auto_sync_summary, + ) { + listener = OnChangedListener { + if (store.autoSync) { + requests.trySend(Request.Sync) + } + } + } + + clickable( + title = R.string.webdav_test, + icon = R.drawable.ic_baseline_info, + ) { + clicked { requests.trySend(Request.Test) } + } + + clickable( + title = R.string.webdav_sync_now, + icon = R.drawable.ic_baseline_sync, + ) { + clicked { requests.trySend(Request.Sync) } + } + + clickable( + title = R.string.webdav_pull, + icon = R.drawable.ic_baseline_cloud_download, + ) { + clicked { requests.trySend(Request.Pull) } + } + } + + binding.content.addView(screen.root) + setStatus(status) + } + + fun setStatus(text: String) { + statusPreference?.text = text.ifBlank { context.getString(R.string.webdav_status_empty) } + } + + private fun refreshPassword() { + passwordPreference?.summary = context.getString( + if (store.password.isEmpty()) R.string.webdav_unset else R.string.webdav_password_set, + ) + } +} diff --git a/design/src/main/res/layout/design_settings.xml b/design/src/main/res/layout/design_settings.xml index 885ec4a5c8..e2267e7aca 100644 --- a/design/src/main/res/layout/design_settings.xml +++ b/design/src/main/res/layout/design_settings.xml @@ -56,6 +56,13 @@ app:icon="@drawable/ic_baseline_meta" app:text="@string/meta_features" /> + + diff --git a/design/src/main/res/values-zh/strings.xml b/design/src/main/res/values-zh/strings.xml index bc8b0eed66..28881ef382 100644 --- a/design/src/main/res/values-zh/strings.xml +++ b/design/src/main/res/values-zh/strings.xml @@ -282,4 +282,23 @@ 启动 Clash 服务 停止 Clash 停止 Clash 服务 + + WebDAV 同步 + 同步已保存的订阅配置,以及访问控制里的应用包名。地址必须是 https。同步会先合并,不会盖掉其他设备独有的订阅。正在编辑、尚未保存的配置不会上传。 + 尚未同步 + 服务器 + 地址 + https://example.com/dav + 用户名 + 密码 + 已设置 + 未设置 + 远端目录 + /clash-meta + 自动同步 + 启动后,以及订阅或应用包名变化后自动同步 + 测试连接 + 立即同步 + 仅下载 + 正在同步… diff --git a/design/src/main/res/values/strings.xml b/design/src/main/res/values/strings.xml index 680ad40eb6..39dddba23e 100644 --- a/design/src/main/res/values/strings.xml +++ b/design/src/main/res/values/strings.xml @@ -378,4 +378,23 @@ Start Clash service Stop Clash Stop Clash service + + WebDAV Sync + Sync saved subscriptions and the app package names used by access control. The address must be https. A sync merges first and will not remove subscriptions that exist only on another device. Profiles that are still being edited are not uploaded. + Not synced yet + Server + Address + https://example.com/dav + Username + Password + Set + Not set + Remote folder + /clash-meta + Auto sync + Sync after startup and after subscriptions or package names change + Test connection + Sync now + Download only + Syncing… diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3190850eff..9df8b80328 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -18,6 +18,9 @@ room = "2.4.2" multiprocess = "1.0.0" quickie = "1.11.0" androidx-activity-ktx = "1.9.0" +okhttp = "4.12.0" +desugar = "2.1.4" +junit = "4.13.2" [libraries] build-android = { module = "com.android.tools.build:gradle", version.ref = "agp" } @@ -43,5 +46,8 @@ kaidl-runtime = { module = "com.github.kr328.kaidl:kaidl-runtime", version.ref = rikkax-multiprocess = { module = "dev.rikka.rikkax.preference:multiprocess", version.ref = "multiprocess" } quickie-bundled = { group = "io.github.g00fy2.quickie", name = "quickie-bundled", version.ref = "quickie" } androidx-activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "androidx-activity-ktx" } +okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } +desugar-jdk = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar" } +junit = { module = "junit:junit", version.ref = "junit" } [plugins] diff --git a/service/build.gradle.kts b/service/build.gradle.kts index caf639ec76..fdb34b3d89 100644 --- a/service/build.gradle.kts +++ b/service/build.gradle.kts @@ -19,6 +19,15 @@ dependencies { implementation(libs.androidx.room.ktx) implementation(libs.kaidl.runtime) implementation(libs.rikkax.multiprocess) + implementation(libs.okhttp) + + testImplementation(libs.junit) +} + +android { + testOptions { + unitTests.isReturnDefaultValues = true + } } afterEvaluate { diff --git a/service/src/main/java/com/github/kr328/clash/service/ProfileManager.kt b/service/src/main/java/com/github/kr328/clash/service/ProfileManager.kt index e33b591dcc..15fce01ae7 100644 --- a/service/src/main/java/com/github/kr328/clash/service/ProfileManager.kt +++ b/service/src/main/java/com/github/kr328/clash/service/ProfileManager.kt @@ -14,6 +14,9 @@ import com.github.kr328.clash.service.util.generateProfileUUID import com.github.kr328.clash.service.util.importedDir import com.github.kr328.clash.service.util.pendingDir import com.github.kr328.clash.service.util.sendProfileChanged +import com.github.kr328.clash.service.webdav.StoredWebdavSettings +import com.github.kr328.clash.service.webdav.WebdavAutoSync +import com.github.kr328.clash.service.webdav.WebdavSync import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -31,6 +34,7 @@ class ProfileManager(private val context: Context) : IProfileManager, ProfileReceiver.rescheduleAll(context) } + WebdavAutoSync.syncIfDue(context) } override suspend fun create(type: Profile.Type, name: String, source: String, ageSecretKey: String?): UUID { @@ -135,6 +139,7 @@ class ProfileManager(private val context: Context) : IProfileManager, ProfileProcessor.apply(context, uuid, callback) scheduleUpdate(uuid, false) + WebdavAutoSync.enqueue(context) } override suspend fun release(uuid: UUID) { @@ -142,11 +147,18 @@ class ProfileManager(private val context: Context) : IProfileManager, } override suspend fun delete(uuid: UUID) { - ImportedDao().queryByUUID(uuid)?.also { - ProfileReceiver.cancelNext(context, it) + val imported = ImportedDao().queryByUUID(uuid) + if (imported != null) { + val updatedAt = context.importedDir.resolve(uuid.toString()) + .walk() + .filter { it.isFile } + .maxOfOrNull { it.lastModified() } ?: 0L + WebdavAutoSync.noteDeleted(context, uuid, imported.source, imported.type.name, updatedAt) + ProfileReceiver.cancelNext(context, imported) } ProfileProcessor.delete(context, uuid) + WebdavAutoSync.enqueue(context) } override suspend fun queryByUUID(uuid: UUID): Profile? { @@ -175,6 +187,24 @@ class ProfileManager(private val context: Context) : IProfileManager, ProfileProcessor.active(context, profile.uuid) } + override suspend fun webdavPerform( + action: String, + url: String, + username: String, + password: String, + remotePath: String, + ): String { + return WebdavSync(context).perform( + action, + StoredWebdavSettings( + url = url, + username = username, + password = password, + remotePath = remotePath, + ), + ) + } + private suspend fun resolveProfile(uuid: UUID): Profile? { val imported = ImportedDao().queryByUUID(uuid) val pending = PendingDao().queryByUUID(uuid) diff --git a/service/src/main/java/com/github/kr328/clash/service/ProfileProcessor.kt b/service/src/main/java/com/github/kr328/clash/service/ProfileProcessor.kt index ff7d299945..4a9cfe18d0 100644 --- a/service/src/main/java/com/github/kr328/clash/service/ProfileProcessor.kt +++ b/service/src/main/java/com/github/kr328/clash/service/ProfileProcessor.kt @@ -13,6 +13,7 @@ import com.github.kr328.clash.service.model.Profile import com.github.kr328.clash.service.remote.IFetchObserver import com.github.kr328.clash.service.store.ServiceStore import com.github.kr328.clash.service.util.importedDir +import com.github.kr328.clash.service.webdav.WebdavAutoSync import com.github.kr328.clash.service.util.pendingDir import com.github.kr328.clash.service.util.processingDir import com.github.kr328.clash.service.util.sendProfileChanged @@ -132,6 +133,17 @@ object ProfileProcessor { } } } + WebdavAutoSync.enqueue(context) + } + + suspend fun guardImported(block: suspend () -> T): T { + return withContext(NonCancellable) { + processLock.withLock { + profileLock.withLock { + block() + } + } + } } private suspend fun fetchProfile( @@ -163,17 +175,24 @@ object ProfileProcessor { suspend fun delete(context: Context, uuid: UUID) { withContext(NonCancellable) { - profileLock.withLock { - ImportedDao().remove(uuid) - PendingDao().remove(uuid) + processLock.withLock { + profileLock.withLock { + ImportedDao().remove(uuid) + PendingDao().remove(uuid) + + val pending = context.pendingDir.resolve(uuid.toString()) + val imported = context.importedDir.resolve(uuid.toString()) - val pending = context.pendingDir.resolve(uuid.toString()) - val imported = context.importedDir.resolve(uuid.toString()) + pending.deleteRecursively() + imported.deleteRecursively() - pending.deleteRecursively() - imported.deleteRecursively() + val store = ServiceStore(context) + if (store.activeProfile == uuid) { + store.activeProfile = null + } - context.sendProfileChanged(uuid) + context.sendProfileChanged(uuid) + } } } } diff --git a/service/src/main/java/com/github/kr328/clash/service/remote/IProfileManager.kt b/service/src/main/java/com/github/kr328/clash/service/remote/IProfileManager.kt index 0eace2ef7b..a927ccb482 100644 --- a/service/src/main/java/com/github/kr328/clash/service/remote/IProfileManager.kt +++ b/service/src/main/java/com/github/kr328/clash/service/remote/IProfileManager.kt @@ -17,4 +17,5 @@ interface IProfileManager { suspend fun queryAll(): List suspend fun queryActive(): Profile? suspend fun setActive(profile: Profile) + suspend fun webdavPerform(action: String, url: String, username: String, password: String, remotePath: String): String } diff --git a/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavAutoSync.kt b/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavAutoSync.kt new file mode 100644 index 0000000000..b315e38824 --- /dev/null +++ b/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavAutoSync.kt @@ -0,0 +1,71 @@ +package com.github.kr328.clash.service.webdav + +import android.content.Context +import com.github.kr328.clash.common.Global +import com.github.kr328.clash.common.log.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.util.UUID + +object WebdavAutoSync { + private const val INTERVAL_MS = 15L * 60L * 1000L + + fun enqueue(context: Context) { + val app = context.applicationContext + Global.launch(Dispatchers.IO) { + runCatching { sync(app, force = true) } + .onFailure { Log.w("WebDAV sync: ${it.message}") } + } + } + + fun syncIfDue(context: Context) { + val app = context.applicationContext + Global.launch(Dispatchers.IO) { + runCatching { sync(app, force = false) } + .onFailure { Log.w("WebDAV sync: ${it.message}") } + } + } + + fun noteDeleted(context: Context, uuid: UUID, source: String, type: String, updatedAt: Long) { + val deletedAt = maxOf(System.currentTimeMillis(), updatedAt + 1) + WebdavLocalState(context).update { state -> + state.copy( + tombstones = state.tombstones + TombstonePayload( + uuid = uuid.toString(), + source = source, + type = type, + deletedAt = deletedAt, + ), + ) + } + } + + fun noteAccessControl(context: Context) { + val now = System.currentTimeMillis() + WebdavLocalState(context).update { it.copy(accessControlUpdatedAt = now) } + } + + fun noteAppsStrategy(context: Context, uuid: UUID) { + val now = System.currentTimeMillis() + WebdavLocalState(context).update { state -> + state.copy(strategyUpdatedAt = state.strategyUpdatedAt + (uuid.toString() to now)) + } + } + + fun noteActiveAppsStrategy(context: Context) { + val now = System.currentTimeMillis() + WebdavLocalState(context).update { it.copy(activeStrategyUpdatedAt = now) } + } + + private suspend fun sync(context: Context, force: Boolean) { + val settings = WebdavSettingsStore(context).load() + if (!settings.autoSync || settings.url.isBlank() || settings.username.isBlank() || settings.password.isEmpty()) { + return + } + if (!force) { + val last = WebdavLocalState(context).read().lastSyncAt + if (System.currentTimeMillis() - last < INTERVAL_MS) return + } + WebdavSync(context).perform(WebdavSync.ACTION_SYNC, settings) + } +} diff --git a/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavCipher.kt b/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavCipher.kt new file mode 100644 index 0000000000..91972bed74 --- /dev/null +++ b/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavCipher.kt @@ -0,0 +1,76 @@ +package com.github.kr328.clash.service.webdav + +import android.os.Build +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Base64 +import java.nio.ByteBuffer +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +internal object WebdavCipher { + private const val ALIAS = "clash_meta_webdav" + private const val ANDROID_KEYSTORE = "AndroidKeyStore" + + fun seal(plain: String): String { + if (plain.isEmpty()) return "" + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + return "v0:" + Base64.encodeToString(plain.toByteArray(Charsets.UTF_8), Base64.NO_WRAP) + } + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, key()) + val iv = cipher.iv + val body = cipher.doFinal(plain.toByteArray(Charsets.UTF_8)) + val packed = ByteBuffer.allocate(4 + iv.size + body.size) + packed.putInt(iv.size) + packed.put(iv) + packed.put(body) + return "v1:" + Base64.encodeToString(packed.array(), Base64.NO_WRAP) + } + + fun open(sealed: String): String { + if (sealed.isEmpty()) return "" + return try { + when { + sealed.startsWith("v0:") -> String( + Base64.decode(sealed.removePrefix("v0:"), Base64.DEFAULT), + Charsets.UTF_8, + ) + sealed.startsWith("v1:") && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M -> { + val packed = ByteBuffer.wrap(Base64.decode(sealed.removePrefix("v1:"), Base64.DEFAULT)) + val ivLength = packed.int + val iv = ByteArray(ivLength) + packed.get(iv) + val body = ByteArray(packed.remaining()) + packed.get(body) + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.DECRYPT_MODE, key(), GCMParameterSpec(128, iv)) + String(cipher.doFinal(body), Charsets.UTF_8) + } + else -> "" + } + } catch (_: Exception) { + "" + } + } + + private fun key(): SecretKey { + val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) } + (keyStore.getKey(ALIAS, null) as? SecretKey)?.let { return it } + val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE) + generator.init( + KeyGenParameterSpec.Builder( + ALIAS, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(256) + .build(), + ) + return generator.generateKey() + } +} diff --git a/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavClient.kt b/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavClient.kt new file mode 100644 index 0000000000..ab7e210287 --- /dev/null +++ b/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavClient.kt @@ -0,0 +1,286 @@ +package com.github.kr328.clash.service.webdav + +import android.util.Base64 +import com.github.kr328.clash.common.log.Log +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import java.util.concurrent.TimeUnit + +data class WebdavConfig( + val url: String, + val username: String, + val password: String, + val remotePath: String, + val clientId: String, +) + +interface HttpTransport { + fun execute(request: HttpRequest): HttpResponse +} + +data class HttpRequest( + val method: String, + val url: String, + val headers: Map, + val body: String? = null, +) + +data class HttpResponse( + val status: Int, + val statusText: String, + val body: String, + val headers: Map, +) { + val ok: Boolean get() = status in 200..299 + + fun header(name: String): String? { + val want = name.lowercase() + return headers.entries.firstOrNull { it.key.lowercase() == want }?.value + } +} + +class OkHttpTransport( + private val client: OkHttpClient = OkHttpClient.Builder() + .followRedirects(false) + .followSslRedirects(false) + .protocols(listOf(Protocol.HTTP_1_1)) + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(120, TimeUnit.SECONDS) + .writeTimeout(120, TimeUnit.SECONDS) + .callTimeout(180, TimeUnit.SECONDS) + .build(), +) : HttpTransport { + override fun execute(request: HttpRequest): HttpResponse { + val builder = Request.Builder().url(request.url) + for ((key, value) in request.headers) builder.header(key, value) + val body = when (request.method) { + "GET", "DELETE", "UNLOCK", "MOVE" -> null + "MKCOL" -> ByteArray(0).toRequestBody(null) + "PROPFIND" -> (request.body ?: "").toRequestBody("application/xml; charset=utf-8".toMediaType()) + else -> request.body?.toRequestBody("application/json; charset=utf-8".toMediaType()) + } + builder.method(request.method, body) + client.newCall(builder.build()).execute().use { response -> + val headerMap = linkedMapOf() + for (name in response.headers.names()) { + headerMap[name] = response.header(name).orEmpty() + } + return HttpResponse( + status = response.code, + statusText = response.message, + body = response.body?.string().orEmpty(), + headers = headerMap, + ) + } + } +} + +class WebdavClient( + private val transport: HttpTransport = OkHttpTransport(), +) { + fun get(config: WebdavConfig, fileName: String): WebdavGetResult { + val response = request("GET", fileUrl(config.url, config.remotePath, fileName), config) + if (response.status == 404) return WebdavGetResult(found = false, body = "", etag = null) + if (!response.ok) throw WebdavException(statusCode(response, "get"), response.status, statusMessage(response, "get")) + return WebdavGetResult(found = true, body = response.body, etag = response.header("etag")) + } + + fun list(config: WebdavConfig, subdir: String = ""): List { + val url = if (subdir.isBlank()) { + folderUrl(config.url, config.remotePath) + } else { + val nested = fileUrl(config.url, config.remotePath, subdir.trim('/')) + if (nested.endsWith("/")) nested else "$nested/" + } + val response = request( + "PROPFIND", + url, + config, + body = """""", + extra = mapOf("Depth" to "1", "Content-Type" to "application/xml; charset=utf-8"), + ) + if (response.status == 404) return emptyList() + if (response.status != 207 && !response.ok) { + throw WebdavException(statusCode(response, "propfind"), response.status, statusMessage(response, "propfind")) + } + return parsePropfindEntries(response.body, url) + } + + fun put( + config: WebdavConfig, + fileName: String, + body: String, + ifMatch: String? = null, + createOnly: Boolean = false, + ): WebdavPutResult { + val tempName = webdavTempFileName(fileName, config.clientId) + putDirect(config, tempName, body) + val destUrl = fileUrl(config.url, config.remotePath, fileName) + val extra = linkedMapOf( + "Destination" to destUrl, + "Overwrite" to if (createOnly) "F" else "T", + ) + if (!ifMatch.isNullOrEmpty()) { + extra["If-Match"] = ifMatch + extra["If"] = "<$destUrl> ($ifMatch)" + } + val moved = tryMove(config, tempName, extra) + if (moved != null) return moved + val written = putDirect(config, fileName, body, ifMatch, createOnly) + try { + delete(config, tempName) + } catch (_: Exception) { + } + return written + } + + fun delete(config: WebdavConfig, fileName: String) { + val response = request("DELETE", fileUrl(config.url, config.remotePath, fileName), config) + if (response.status == 404 || response.status == 204 || response.ok) return + throw WebdavException(statusCode(response, "delete"), response.status, statusMessage(response, "delete")) + } + + fun ensureParents(config: WebdavConfig) { + val target = fileUrl(config.url, config.remotePath, "$WEBDAV_PROFILES_DIR/_placeholder.json") + val root = if (config.url.trim().endsWith("/")) config.url.trim() else "${config.url.trim()}/" + for (collection in collectionUrlsBetween(root, target)) { + val colUrl = if (collection.endsWith("/")) collection else "$collection/" + val response = request("MKCOL", colUrl, config) + if (response.status == 201 || response.status == 405 || response.status == 409 || response.status == 301 || response.ok) { + continue + } + throw WebdavException(statusCode(response, "mkcol"), response.status, statusMessage(response, "mkcol")) + } + } + + private fun putDirect( + config: WebdavConfig, + fileName: String, + body: String, + ifMatch: String? = null, + createOnly: Boolean = false, + unlockTried: Boolean = false, + ): WebdavPutResult { + val url = fileUrl(config.url, config.remotePath, fileName) + val extra = linkedMapOf("Content-Type" to "application/json; charset=utf-8") + if (!ifMatch.isNullOrEmpty()) extra["If-Match"] = ifMatch + else if (createOnly) extra["If-None-Match"] = "*" + val response = request("PUT", url, config, body, extra) + if (response.status == 412) throw WebdavConflictException() + if (response.status == 423 && !unlockTried) { + val tokens = (extractLockTokens(response.body) + discoverLockTokens(config, fileName)).distinct() + for (token in tokens) unlock(config, fileName, token) + return putDirect(config, fileName, body, ifMatch, createOnly, unlockTried = true) + } + if (response.status == 201 || response.status == 204 || response.ok) { + return WebdavPutResult(etag = response.header("etag")) + } + throw WebdavException(statusCode(response, "put"), response.status, statusMessage(response, "put")) + } + + private fun tryMove( + config: WebdavConfig, + tempName: String, + extra: Map, + unlockTried: Boolean = false, + ): WebdavPutResult? { + val response = request("MOVE", fileUrl(config.url, config.remotePath, tempName), config, extra = extra) + if (response.status == 405 || response.status == 501) return null + if (response.status == 412) { + try { + delete(config, tempName) + } catch (_: Exception) { + } + throw WebdavConflictException() + } + if (response.status == 423 && !unlockTried) { + val tokens = (extractLockTokens(response.body) + discoverLockTokens(config, tempName)).distinct() + for (token in tokens) unlock(config, tempName, token) + return tryMove(config, tempName, extra, unlockTried = true) + } + if (response.status == 201 || response.status == 204 || response.ok) { + return WebdavPutResult(etag = response.header("etag")) + } + throw WebdavException(statusCode(response, "move"), response.status, statusMessage(response, "move")) + } + + private fun discoverLockTokens(config: WebdavConfig, fileName: String): List { + return try { + val response = request( + "PROPFIND", + fileUrl(config.url, config.remotePath, fileName), + config, + body = """""", + extra = mapOf("Depth" to "0", "Content-Type" to "application/xml; charset=utf-8"), + ) + if (response.status != 207 && !response.ok) emptyList() else extractLockTokens(response.body) + } catch (_: Exception) { + emptyList() + } + } + + private fun unlock(config: WebdavConfig, fileName: String, token: String) { + try { + request( + "UNLOCK", + fileUrl(config.url, config.remotePath, fileName), + config, + extra = mapOf("Lock-Token" to "<${token.trim('<', '>')}>"), + ) + } catch (_: Exception) { + } + } + + private fun request( + method: String, + url: String, + config: WebdavConfig, + body: String? = null, + extra: Map = emptyMap(), + ): HttpResponse { + if (config.username.trim().isEmpty()) throw WebdavException("empty_user") + if (config.password.isEmpty()) throw WebdavException("empty_password") + val auth = Base64.encodeToString( + "${config.username}:${config.password}".toByteArray(Charsets.UTF_8), + Base64.NO_WRAP, + ) + val headers = linkedMapOf( + "Authorization" to "Basic $auth", + "User-Agent" to WEBDAV_USER_AGENT, + ) + headers.putAll(extra) + return try { + val response = transport.execute(HttpRequest(method, url, headers, body)) + Log.i("WebDAV $method ${response.status}") + if (response.status in 300..399) throw WebdavException("redirect", response.status) + response + } catch (err: WebdavException) { + throw err + } catch (err: Exception) { + val message = err.message.orEmpty() + if (message.contains("abort", true) || message.contains("timeout", true)) { + throw WebdavException("timeout") + } + throw WebdavException("connect", detail = message) + } + } + + private fun statusCode(response: HttpResponse, context: String): String { + if (response.status == 401 || response.status == 403) return "auth" + if (response.status == 423) return "locked" + if (response.status == 404 && (context == "put" || context == "mkcol")) return "not_found" + if (response.status == 409 && (context == "put" || context == "mkcol")) return "conflict_parent" + if (response.status == 507) return "no_space" + return "http" + } + + private fun statusMessage(response: HttpResponse, context: String): String { + val snippet = response.body.replace(Regex("\\s+"), " ").trim().take(180) + return listOf(context, response.status.toString(), response.statusText, snippet) + .filter { it.isNotBlank() } + .joinToString(" ") + } +} diff --git a/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavLocalState.kt b/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavLocalState.kt new file mode 100644 index 0000000000..0dc6b11505 --- /dev/null +++ b/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavLocalState.kt @@ -0,0 +1,68 @@ +package com.github.kr328.clash.service.webdav + +import android.content.Context +import kotlinx.serialization.Serializable +import java.io.File +import java.io.RandomAccessFile + +@Serializable +data class LocalState( + val clientId: String = "", + val tombstones: List = emptyList(), + val configUpdatedAt: Map = emptyMap(), + val fingerprint: Map = emptyMap(), + val accessControlUpdatedAt: Long = 0, + val strategyUpdatedAt: Map = emptyMap(), + val activeStrategyUpdatedAt: Long = 0, + val lastSyncAt: Long = 0, + val lastMessage: String = "", +) + +class WebdavLocalState(context: Context) { + private val root = context.applicationContext.filesDir.resolve("webdav") + private val file = File(root, "state.json") + + fun read(): LocalState { + return locked { readUnlocked().withClientId() } + } + + fun update(block: (LocalState) -> LocalState) { + locked { + writeUnlocked(block(readUnlocked()).withClientId()) + } + } + + fun clientId(): String = read().clientId + + fun lastMessage(): String = read().lastMessage + + private fun LocalState.withClientId(): LocalState { + return if (clientId.isNotBlank()) this else copy(clientId = createWebdavClientId()) + } + + private fun readUnlocked(): LocalState { + if (!file.exists()) return LocalState() + return try { + webdavJson.decodeFromString(LocalState.serializer(), file.readText()) + } catch (_: Exception) { + LocalState() + } + } + + private fun writeUnlocked(state: LocalState) { + root.mkdirs() + val tmp = File(root, "state.json.tmp") + tmp.writeText(webdavJson.encodeToString(LocalState.serializer(), state)) + if (!tmp.renameTo(file)) { + file.writeText(tmp.readText()) + tmp.delete() + } + } + + private fun locked(block: () -> T): T { + root.mkdirs() + return RandomAccessFile(File(root, "state.lock"), "rw").use { access -> + access.channel.lock().use { block() } + } + } +} diff --git a/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavMerge.kt b/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavMerge.kt new file mode 100644 index 0000000000..2767580e7c --- /dev/null +++ b/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavMerge.kt @@ -0,0 +1,88 @@ +package com.github.kr328.clash.service.webdav + +data class MergeResult( + val profiles: List, + val tombstones: List, + val access: AccessControlPayload, +) + +fun mergeWebdav( + localProfiles: List, + remoteProfiles: List, + localTombstones: List, + remoteTombstones: List, + localAccess: AccessControlPayload, + remoteAccess: AccessControlPayload, + now: Long, +): MergeResult { + val tombstones = mergeTombstones(localTombstones + remoteTombstones, now) + val localByIdentity = localProfiles.associateBy { subscriptionIdentity(it.type, it.source, it.uuid) } + val remoteByIdentity = remoteProfiles.associateBy { subscriptionIdentity(it.type, it.source, it.uuid) } + val keptTombstones = tombstones.toMutableList() + val profiles = mutableListOf() + + for (identity in localByIdentity.keys + remoteByIdentity.keys) { + val local = localByIdentity[identity] + val remote = remoteByIdentity[identity] + val merged = when { + local == null -> remote + remote == null -> local + else -> mergeProfile(local, remote) + } ?: continue + val tombstone = keptTombstones + .filter { matchesTombstone(it, merged) } + .maxByOrNull { it.deletedAt } + if (tombstone != null && tombstone.deletedAt >= merged.updatedAt) { + continue + } + if (tombstone != null) { + keptTombstones.removeAll { matchesTombstone(it, merged) } + } + profiles.add(merged) + } + + return MergeResult( + profiles = profiles.sortedBy { it.uuid }, + tombstones = keptTombstones.sortedBy { it.uuid }, + access = if (remoteAccess.updatedAt > localAccess.updatedAt) remoteAccess else localAccess, + ) +} + +fun mergeTombstones(tombstones: List, now: Long): List { + val kept = linkedMapOf() + for (tombstone in tombstones) { + if (tombstone.deletedAt <= 0 || now - tombstone.deletedAt > WEBDAV_TOMBSTONE_TTL_MS) continue + val key = tombstoneKey(tombstone) + val previous = kept[key] + if (previous == null || tombstone.deletedAt > previous.deletedAt) { + kept[key] = tombstone + } + } + return kept.values.toList() +} + +private fun mergeProfile(local: ProfilePayload, remote: ProfilePayload): ProfilePayload { + val config = if (remote.updatedAt > local.updatedAt) remote else local + val createdAt = listOf(local.createdAt, remote.createdAt).filter { it > 0 }.minOrNull() + ?: config.createdAt + return config.copy( + uuid = local.uuid, + createdAt = createdAt, + ) +} + +fun matchesTombstone(tombstone: TombstonePayload, profile: ProfilePayload): Boolean { + if (tombstone.uuid.isNotBlank() && tombstone.uuid == profile.uuid) return true + val source = normalizeSubscriptionSource(profile.source) + if (source.isEmpty() || normalizeSubscriptionSource(tombstone.source) != source) return false + val type = if (tombstone.type.isBlank()) profile.type else tombstone.type + return type == "Url" || type == "External" +} + +private fun tombstoneKey(tombstone: TombstonePayload): String { + val source = normalizeSubscriptionSource(tombstone.source) + if ((tombstone.type == "Url" || tombstone.type == "External") && source.isNotEmpty()) { + return "src:$source" + } + return "id:${tombstone.uuid}" +} diff --git a/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavModels.kt b/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavModels.kt new file mode 100644 index 0000000000..ef1b54ec74 --- /dev/null +++ b/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavModels.kt @@ -0,0 +1,96 @@ +package com.github.kr328.clash.service.webdav + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +internal val webdavJson = Json { + ignoreUnknownKeys = true + encodeDefaults = true +} + +@Serializable +data class ProfileFilePayload( + val path: String, + val contentBase64: String, +) + +@Serializable +data class ProfilePayload( + val kind: String = WEBDAV_PROFILE_KIND, + val version: Int = WEBDAV_SYNC_VERSION, + val uuid: String, + val name: String, + val type: String, + val source: String = "", + val interval: Long = 0, + val upload: Long = 0, + val download: Long = 0, + val total: Long = 0, + val expire: Long = 0, + val createdAt: Long = 0, + val updatedAt: Long = 0, + val ageSecretKey: String? = null, + val files: List = emptyList(), +) + +@Serializable +data class TombstonePayload( + val uuid: String, + val source: String = "", + val type: String = "", + val deletedAt: Long = 0, +) + +@Serializable +data class AccessControlPayload( + val mode: String = "AcceptAll", + val packages: List = emptyList(), + val updatedAt: Long = 0, +) + +@Serializable +data class SyncMetaPayload( + val kind: String = WEBDAV_SYNC_KIND, + val version: Int = WEBDAV_SYNC_VERSION, + val updatedAt: Long = 0, + val accessControlMode: String = "AcceptAll", + val accessControlPackages: List = emptyList(), + val accessControlUpdatedAt: Long = 0, + val tombstones: List = emptyList(), +) { + fun access(): AccessControlPayload { + return AccessControlPayload(accessControlMode, accessControlPackages, accessControlUpdatedAt) + } +} + +fun encodeProfile(payload: ProfilePayload): String { + return webdavJson.encodeToString(ProfilePayload.serializer(), payload) +} + +fun encodeMeta(payload: SyncMetaPayload): String { + return webdavJson.encodeToString(SyncMetaPayload.serializer(), payload) +} + +fun parseProfile(body: String): ProfilePayload? { + return try { + val payload = webdavJson.decodeFromString(ProfilePayload.serializer(), body) + if (payload.kind != WEBDAV_PROFILE_KIND || payload.version != WEBDAV_SYNC_VERSION) null + else if (payload.uuid.isBlank() || payload.name.isBlank()) null + else payload + } catch (_: Exception) { + null + } +} + +fun parseMeta(body: String): SyncMetaPayload { + if (body.isBlank()) throw WebdavException("broken_meta") + val payload = try { + webdavJson.decodeFromString(SyncMetaPayload.serializer(), body) + } catch (_: Exception) { + throw WebdavException("broken_meta") + } + if (payload.kind != WEBDAV_SYNC_KIND || payload.version != WEBDAV_SYNC_VERSION) { + throw WebdavException("bad_version") + } + return payload +} diff --git a/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavProfiles.kt b/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavProfiles.kt new file mode 100644 index 0000000000..26aa254874 --- /dev/null +++ b/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavProfiles.kt @@ -0,0 +1,258 @@ +package com.github.kr328.clash.service.webdav + +import android.content.Context +import android.util.Base64 +import com.github.kr328.clash.service.ProfileProcessor +import com.github.kr328.clash.service.ProfileReceiver +import com.github.kr328.clash.service.data.Imported +import com.github.kr328.clash.service.data.ImportedDao +import com.github.kr328.clash.service.data.PendingDao +import com.github.kr328.clash.service.model.AccessControlMode +import com.github.kr328.clash.service.model.Profile +import com.github.kr328.clash.service.store.ServiceStore +import com.github.kr328.clash.service.util.importedDir +import com.github.kr328.clash.service.util.sendProfileChanged +import java.io.File +import java.security.MessageDigest +import java.util.UUID + +data class LocalSnapshot( + val profiles: List, + val access: AccessControlPayload, + val tombstones: List, +) + +class WebdavProfiles(private val context: Context) { + private val state = WebdavLocalState(context) + + suspend fun exportLocal(): LocalSnapshot { + val local = state.read() + val profiles = ImportedDao().queryAllUUIDs().mapNotNull { uuid -> + if (PendingDao().exists(uuid)) return@mapNotNull null + val imported = ImportedDao().queryByUUID(uuid) ?: return@mapNotNull null + val files = readFiles(context.importedDir.resolve(uuid.toString())) + val fingerprint = fingerprint(files) + val storedFingerprint = local.fingerprint[uuid.toString()] + val storedUpdatedAt = local.configUpdatedAt[uuid.toString()] ?: 0L + val fileUpdatedAt = files.maxOfOrNull { fileUpdatedAt(context, uuid, it.path) } ?: imported.createdAt + val updatedAt = if (fingerprint == storedFingerprint && storedUpdatedAt > 0) { + storedUpdatedAt + } else { + fileUpdatedAt.coerceAtLeast(1) + } + ProfilePayload( + uuid = uuid.toString(), + name = imported.name, + type = imported.type.name, + source = imported.source, + interval = imported.interval, + upload = imported.upload, + download = imported.download, + total = imported.total, + expire = imported.expire, + createdAt = imported.createdAt, + updatedAt = updatedAt, + ageSecretKey = imported.ageSecretKey, + files = files, + ) + } + val store = ServiceStore(context) + val packages = store.accessControlPackages.sorted() + val mode = store.accessControlMode.name + var accessUpdatedAt = local.accessControlUpdatedAt + if (accessUpdatedAt == 0L && (packages.isNotEmpty() || mode != AccessControlMode.AcceptAll.name)) { + val stamped = System.currentTimeMillis() + state.update { current -> + if (current.accessControlUpdatedAt > 0) current + else current.copy(accessControlUpdatedAt = stamped) + } + accessUpdatedAt = state.read().accessControlUpdatedAt + } + return LocalSnapshot( + profiles = profiles, + access = AccessControlPayload( + mode = mode, + packages = packages, + updatedAt = accessUpdatedAt, + ), + tombstones = local.tombstones, + ) + } + + suspend fun apply(result: MergeResult, local: LocalSnapshot): ApplyCounts { + var downloaded = 0 + var removed = 0 + var skipped = 0 + val localByUuid = local.profiles.associateBy { it.uuid } + val mergedIds = result.profiles.map { it.uuid }.toSet() + + for (profile in result.profiles) { + val previous = localByUuid[profile.uuid] + ?: local.profiles.find { sameSubscription(it, profile) } + if (previous != null && sameContent(previous, profile)) continue + val installed = install(profile) + if (!installed) { + skipped += 1 + } else if (previous == null || profile.updatedAt > previous.updatedAt) { + downloaded += 1 + } + } + + for (profile in local.profiles) { + if (profile.uuid in mergedIds) continue + if (local.profiles.any { it.uuid != profile.uuid && sameSubscription(it, profile) && it.uuid in mergedIds }) { + continue + } + val stillThere = result.profiles.any { sameSubscription(it, profile) } + if (stillThere) continue + if (PendingDao().exists(UUID.fromString(profile.uuid))) { + skipped += 1 + continue + } + remove(UUID.fromString(profile.uuid)) + removed += 1 + } + + applyAccess(result.access, local.access) + return ApplyCounts(downloaded = downloaded, removed = removed, skipped = skipped) + } + + private suspend fun install(payload: ProfilePayload): Boolean { + val uuid = UUID.fromString(payload.uuid) + val type = runCatching { Profile.Type.valueOf(payload.type) }.getOrElse { Profile.Type.File } + return ProfileProcessor.guardImported { + if (PendingDao().exists(uuid)) return@guardImported false + val dir = context.importedDir.resolve(uuid.toString()) + val staging = context.importedDir.resolve("$uuid.webdav-tmp") + staging.deleteRecursively() + staging.mkdirs() + var total = 0 + for (file in payload.files) { + val rel = safeRelativePath(file.path) + if (!isSyncedProfileFile(rel)) continue + val bytes = Base64.decode(file.contentBase64, Base64.DEFAULT) + if (bytes.size > WEBDAV_MAX_FILE_BYTES || total + bytes.size > WEBDAV_MAX_PROFILE_BYTES) { + throw WebdavException("too_large", detail = payload.name) + } + total += bytes.size + val dest = staging.resolve(rel) + dest.parentFile?.mkdirs() + val rootPath = staging.canonicalFile.path + File.separator + if (!dest.canonicalFile.path.startsWith(rootPath)) { + throw WebdavException("bad_path") + } + dest.writeBytes(bytes) + } + if (!staging.resolve("config.yaml").exists()) { + staging.resolve("config.yaml").writeText("") + } + dir.deleteRecursively() + if (!staging.renameTo(dir)) { + staging.copyRecursively(dir, overwrite = true) + staging.deleteRecursively() + } + val createdAt = payload.createdAt.takeIf { it > 0 } ?: System.currentTimeMillis() + val row = Imported( + uuid = uuid, + name = payload.name.ifBlank { "Profile" }, + type = type, + source = payload.source, + interval = payload.interval, + upload = payload.upload, + download = payload.download, + total = payload.total, + expire = payload.expire, + createdAt = createdAt, + ageSecretKey = payload.ageSecretKey, + ) + if (ImportedDao().exists(uuid)) ImportedDao().update(row) else ImportedDao().insert(row) + context.sendProfileChanged(uuid) + true + }.also { installed -> + if (!installed) return@also + state.update { current -> + current.copy( + configUpdatedAt = current.configUpdatedAt + (uuid.toString() to payload.updatedAt), + fingerprint = current.fingerprint + (uuid.toString() to fingerprint(payload.files)), + ) + } + if (type != Profile.Type.File) { + ImportedDao().queryByUUID(uuid)?.let { ProfileReceiver.scheduleNext(context, it) } + } + } + } + + private suspend fun remove(uuid: UUID) { + ImportedDao().queryByUUID(uuid)?.let { ProfileReceiver.cancelNext(context, it) } + ProfileProcessor.delete(context, uuid) + state.update { current -> + current.copy( + configUpdatedAt = current.configUpdatedAt - uuid.toString(), + fingerprint = current.fingerprint - uuid.toString(), + ) + } + } + + private fun applyAccess(remote: AccessControlPayload, local: AccessControlPayload) { + val fresh = state.read().accessControlUpdatedAt + if (remote.updatedAt <= maxOf(local.updatedAt, fresh)) return + val mode = AccessControlMode.values().find { it.name == remote.mode } ?: return + val store = ServiceStore(context) + store.accessControlMode = mode + store.accessControlPackages = remote.packages.toSet() + state.update { current -> + if (current.accessControlUpdatedAt > remote.updatedAt) current + else current.copy(accessControlUpdatedAt = remote.updatedAt) + } + } +} + +data class ApplyCounts( + val downloaded: Int, + val removed: Int, + val skipped: Int, +) + +private fun sameSubscription(left: ProfilePayload, right: ProfilePayload): Boolean { + return subscriptionIdentity(left.type, left.source, left.uuid) == + subscriptionIdentity(right.type, right.source, right.uuid) +} + +private fun sameContent(left: ProfilePayload, right: ProfilePayload): Boolean { + return left.uuid == right.uuid && + left.name == right.name && + left.type == right.type && + left.source == right.source && + left.interval == right.interval && + left.updatedAt == right.updatedAt && + left.ageSecretKey == right.ageSecretKey && + left.files == right.files +} + +private fun readFiles(dir: File): List { + if (!dir.exists()) return emptyList() + var total = 0 + return dir.walkTopDown().filter { it.isFile }.mapNotNull { file -> + val rel = dir.toPath().relativize(file.toPath()).toString().replace('\\', '/') + if (!isSyncedProfileFile(rel)) return@mapNotNull null + if (file.length() > WEBDAV_MAX_FILE_BYTES) throw WebdavException("too_large", detail = rel) + total += file.length().toInt() + if (total > WEBDAV_MAX_PROFILE_BYTES) throw WebdavException("too_large", detail = rel) + ProfileFilePayload(rel, Base64.encodeToString(file.readBytes(), Base64.NO_WRAP)) + }.sortedBy { it.path }.toList() +} + +private fun fileUpdatedAt(context: Context, uuid: UUID, path: String): Long { + return context.importedDir.resolve(uuid.toString()).resolve(path).lastModified() +} + +internal fun fingerprint(files: List): String { + val digest = MessageDigest.getInstance("SHA-256") + for (file in files.sortedBy { it.path }) { + digest.update(file.path.toByteArray(Charsets.UTF_8)) + digest.update(0) + digest.update(file.contentBase64.toByteArray(Charsets.UTF_8)) + digest.update(0) + } + return digest.digest().joinToString("") { "%02x".format(it) } +} diff --git a/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavProtocol.kt b/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavProtocol.kt new file mode 100644 index 0000000000..f7f0e0cbe3 --- /dev/null +++ b/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavProtocol.kt @@ -0,0 +1,223 @@ +package com.github.kr328.clash.service.webdav + +import java.net.URI +import java.net.URLDecoder +import java.security.SecureRandom +import java.text.SimpleDateFormat +import java.util.Locale +import java.util.TimeZone + +const val WEBDAV_SYNC_KIND = "clash-meta-android-webdav-sync" +const val WEBDAV_PROFILE_KIND = "clash-meta-profile" +const val WEBDAV_SYNC_VERSION = 1 +const val WEBDAV_META_FILE = "_sync.json" +const val WEBDAV_PROFILES_DIR = "profiles" +const val WEBDAV_DEFAULT_REMOTE_PATH = "/clash-meta" +const val WEBDAV_USER_AGENT = "ClashMetaForAndroid/1.0" +const val WEBDAV_TMP_SUFFIX = ".tmp" +const val WEBDAV_TMP_MAX_AGE_MS = 3L * 24 * 60 * 60 * 1000 +const val WEBDAV_TOMBSTONE_TTL_MS = 60L * 24 * 60 * 60 * 1000 +const val WEBDAV_MAX_FILE_BYTES = 8 * 1024 * 1024 +const val WEBDAV_MAX_PROFILE_BYTES = 32 * 1024 * 1024 + +open class WebdavException( + val code: String, + val status: Int? = null, + val detail: String = "", +) : Exception(if (detail.isBlank()) code else detail) + +class WebdavConflictException : WebdavException("conflict", 412) + +data class WebdavGetResult( + val found: Boolean, + val body: String, + val etag: String?, +) + +data class WebdavPutResult( + val etag: String?, +) + +data class WebdavListEntry( + val href: String, + val name: String, + val etag: String?, + val collection: Boolean, + val lastModified: String? = null, +) + +fun resolveWebdavRemoteFolder(path: String): String { + val trimmed = path.trim().trimEnd('/') + if (trimmed.isEmpty()) return WEBDAV_DEFAULT_REMOTE_PATH + if (trimmed.endsWith(".json", ignoreCase = true)) { + val slash = trimmed.lastIndexOf('/') + return if (slash <= 0) WEBDAV_DEFAULT_REMOTE_PATH else trimmed.substring(0, slash) + } + return if (trimmed.startsWith("/")) trimmed else "/$trimmed" +} + +fun joinWebdavUrl(base: String, remotePath: String): String { + val trimmed = base.trim() + if (trimmed.isEmpty()) throw WebdavException("empty_url") + val root = try { + URI(trimmed) + } catch (_: Exception) { + throw WebdavException("bad_url") + } + if (root.scheme?.lowercase() != "https") throw WebdavException("https") + val rel = remotePath.trim().trimStart('/') + if (rel.isEmpty()) throw WebdavException("empty_path") + val rootHref = if (trimmed.endsWith("/")) trimmed else "$trimmed/" + return URI(rootHref).resolve(rel).toString() +} + +fun folderUrl(base: String, remotePath: String): String { + val url = joinWebdavUrl(base, resolveWebdavRemoteFolder(remotePath)) + return if (url.endsWith("/")) url else "$url/" +} + +fun fileUrl(base: String, remotePath: String, name: String): String { + return joinWebdavUrl(base, "${resolveWebdavRemoteFolder(remotePath)}/$name") +} + +fun collectionUrlsBetween(rootUrl: String, fileUrl: String): List { + val root = URI(if (rootUrl.endsWith("/")) rootUrl else "$rootUrl/") + val file = URI(fileUrl) + val rootPath = root.path.trimEnd('/') + val filePath = file.path.trimEnd('/') + val rel = if (filePath.startsWith(rootPath)) filePath.substring(rootPath.length) else file.path + val parts = rel.split("/").filter { it.isNotEmpty() }.dropLast(1) + val out = mutableListOf() + var acc = if (root.toString().endsWith("/")) root.toString() else "${root}/" + for (part in parts) { + acc = URI(acc).resolve("$part/").toString() + out.add(acc.trimEnd('/')) + } + return out +} + +fun profileFileName(uuid: String): String = "$WEBDAV_PROFILES_DIR/$uuid.json" + +fun isProfileFileName(name: String): Boolean { + return name.startsWith("$WEBDAV_PROFILES_DIR/") && + name.endsWith(".json") && + !isWebdavTempFileName(name) +} + +fun profileUuidFromFileName(name: String): String? { + if (!isProfileFileName(name)) return null + return name.removePrefix("$WEBDAV_PROFILES_DIR/").removeSuffix(".json") +} + +fun ensureWebdavClientId(value: String?): String { + val id = value?.trim()?.lowercase().orEmpty() + return if (id.matches(Regex("^[0-9a-f]{8}$"))) id else createWebdavClientId() +} + +fun createWebdavClientId(): String { + val bytes = ByteArray(4) + SecureRandom().nextBytes(bytes) + return bytes.joinToString("") { "%02x".format(it) } +} + +fun webdavTempFileName(name: String, clientId: String): String { + val id = ensureWebdavClientId(clientId) + val suffix = ".$id$WEBDAV_TMP_SUFFIX" + return if (name.endsWith(suffix, ignoreCase = true)) name else "$name$suffix" +} + +fun isWebdavTempFileName(name: String): Boolean = name.endsWith(WEBDAV_TMP_SUFFIX, ignoreCase = true) + +fun parseHttpDate(raw: String?): Long? { + if (raw.isNullOrBlank()) return null + val patterns = arrayOf( + "EEE, dd MMM yyyy HH:mm:ss zzz", + "EEEE, dd-MMM-yy HH:mm:ss zzz", + "EEE MMM d HH:mm:ss yyyy", + ) + for (pattern in patterns) { + try { + val format = SimpleDateFormat(pattern, Locale.US) + format.timeZone = TimeZone.getTimeZone("GMT") + format.isLenient = true + return format.parse(raw.trim())?.time + } catch (_: Exception) { + } + } + return null +} + +fun isStaleWebdavTemp( + name: String, + lastModified: String?, + nowMs: Long, + maxAgeMs: Long = WEBDAV_TMP_MAX_AGE_MS, +): Boolean { + if (!isWebdavTempFileName(name)) return false + val at = parseHttpDate(lastModified) ?: return false + return nowMs - at >= maxAgeMs +} + +fun safeRelativePath(path: String): String { + val normalized = path.replace('\\', '/').trim().removePrefix("/") + val parts = normalized.split('/') + if (normalized.isEmpty() || parts.any { it.isEmpty() || it == "." || it == ".." }) { + throw WebdavException("bad_path") + } + return normalized +} + +fun isSyncedProfileFile(path: String): Boolean { + return path == "config.yaml" || path.startsWith("providers/") +} + +fun normalizeSubscriptionSource(source: String): String = source.trim().trimEnd('/') + +fun subscriptionIdentity(type: String, source: String, uuid: String): String { + val src = normalizeSubscriptionSource(source) + if ((type == "Url" || type == "External") && src.isNotEmpty()) return "src:$src" + return "id:$uuid" +} + +fun parsePropfindEntries(xml: String, baseUrl: String): List { + val base = URI(if (baseUrl.endsWith("/")) baseUrl else "$baseUrl/") + val basePath = base.path.trimEnd('/') + val blocks = xml.split(Regex("<(?:[\\w.-]+:)?response\\b", RegexOption.IGNORE_CASE)).drop(1) + return blocks.mapNotNull { block -> + val href = Regex("<(?:[\\w.-]+:)?href\\b[^>]*>\\s*([^<]+)", RegexOption.IGNORE_CASE) + .find(block)?.groupValues?.getOrNull(1)?.trim() ?: return@mapNotNull null + val abs = try { + base.resolve(href) + } catch (_: Exception) { + return@mapNotNull null + } + val itemPath = abs.path.trimEnd('/') + if (itemPath == basePath) return@mapNotNull null + val rawName = itemPath.substringAfterLast('/').ifEmpty { return@mapNotNull null } + val name = try { + URLDecoder.decode(rawName, Charsets.UTF_8.name()) + } catch (_: Exception) { + rawName + } + val etag = Regex("<(?:[\\w.-]+:)?getetag\\b[^>]*>\\s*([^<]*)", RegexOption.IGNORE_CASE) + .find(block)?.groupValues?.getOrNull(1)?.trim()?.ifEmpty { null } + val lastModified = Regex("<(?:[\\w.-]+:)?getlastmodified\\b[^>]*>\\s*([^<]*)", RegexOption.IGNORE_CASE) + .find(block)?.groupValues?.getOrNull(1)?.trim()?.ifEmpty { null } + val collection = Regex( + "<(?:[\\w.-]+:)?resourcetype\\b[^>]*>[\\s\\S]*?<(?:[\\w.-]+:)?collection\\b", + RegexOption.IGNORE_CASE, + ).containsMatchIn(block) + WebdavListEntry(abs.toString(), name, etag, collection, lastModified) + } +} + +fun extractLockTokens(xml: String): List { + return Regex( + "<(?:[\\w.-]+:)?locktoken\\b[^>]*>[\\s\\S]*?<(?:[\\w.-]+:)?href\\b[^>]*>\\s*([^<\\s]+)", + RegexOption.IGNORE_CASE, + ).findAll(xml) + .map { it.groupValues[1].trim().trim('<', '>') } + .filter { it.isNotEmpty() } + .distinct() + .toList() +} diff --git a/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavSettingsStore.kt b/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavSettingsStore.kt new file mode 100644 index 0000000000..7e58ac632a --- /dev/null +++ b/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavSettingsStore.kt @@ -0,0 +1,90 @@ +package com.github.kr328.clash.service.webdav + +import android.content.Context +import kotlinx.serialization.Serializable +import java.io.File + +@Serializable +private data class SettingsFile( + val url: String = "", + val username: String = "", + val passwordSealed: String = "", + val remotePath: String = WEBDAV_DEFAULT_REMOTE_PATH, + val autoSync: Boolean = false, +) + +class WebdavSettingsStore(context: Context) { + private val file = context.applicationContext.filesDir.resolve("webdav/settings.json") + + var url: String? + get() = read().url.ifBlank { null } + set(value) = update { it.copy(url = value?.trim().orEmpty()) } + + var username: String? + get() = read().username.ifBlank { null } + set(value) = update { it.copy(username = value?.trim().orEmpty()) } + + var password: String + get() = read().password + set(value) = update { it.copy(password = value) } + + var remotePath: String? + get() = read().remotePath.ifBlank { null } + set(value) = update { it.copy(remotePath = resolveWebdavRemoteFolder(value.orEmpty())) } + + var autoSync: Boolean + get() = read().autoSync + set(value) = update { it.copy(autoSync = value) } + + fun load(): StoredWebdavSettings = read() + + fun configured(): Boolean { + val current = read() + return current.url.isNotBlank() && current.username.isNotBlank() && current.password.isNotEmpty() + } + + private fun update(block: (StoredWebdavSettings) -> StoredWebdavSettings) { + write(block(read())) + } + + private fun read(): StoredWebdavSettings { + if (!file.exists()) return StoredWebdavSettings() + return try { + val stored = webdavJson.decodeFromString(SettingsFile.serializer(), file.readText()) + StoredWebdavSettings( + url = stored.url.trim(), + username = stored.username.trim(), + password = WebdavCipher.open(stored.passwordSealed), + remotePath = resolveWebdavRemoteFolder(stored.remotePath), + autoSync = stored.autoSync, + ) + } catch (_: Exception) { + StoredWebdavSettings() + } + } + + private fun write(settings: StoredWebdavSettings) { + file.parentFile?.mkdirs() + val payload = SettingsFile( + url = settings.url.trim(), + username = settings.username.trim(), + passwordSealed = WebdavCipher.seal(settings.password), + remotePath = resolveWebdavRemoteFolder(settings.remotePath), + autoSync = settings.autoSync, + ) + val tmp = File(file.parentFile, "settings.json.tmp") + tmp.writeText(webdavJson.encodeToString(SettingsFile.serializer(), payload)) + if (!tmp.renameTo(file)) { + file.writeText(tmp.readText()) + tmp.delete() + } + } +} + +data class StoredWebdavSettings( + val url: String = "", + val username: String = "", + val password: String = "", + val remotePath: String = WEBDAV_DEFAULT_REMOTE_PATH, + val autoSync: Boolean = false, +) diff --git a/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavSync.kt b/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavSync.kt new file mode 100644 index 0000000000..2c9c0128d6 --- /dev/null +++ b/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavSync.kt @@ -0,0 +1,230 @@ +package com.github.kr328.clash.service.webdav + +import android.content.Context +import com.github.kr328.clash.service.R +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext + +class WebdavSync( + private val context: Context, + private val client: WebdavClient = WebdavClient(), +) { + suspend fun perform(action: String, settings: StoredWebdavSettings): String { + return gate.withLock { + withContext(Dispatchers.IO) { + try { + val message = execute(action, settings) + WebdavLocalState(context).update { + it.copy(lastSyncAt = System.currentTimeMillis(), lastMessage = message) + } + message + } catch (err: WebdavException) { + val message = err.localize(context) + WebdavLocalState(context).update { it.copy(lastMessage = message) } + throw IllegalStateException(message) + } catch (err: IllegalStateException) { + throw err + } catch (err: Exception) { + val message = err.message?.ifBlank { null } ?: context.getString(R.string.webdav_error_generic, err.javaClass.simpleName) + WebdavLocalState(context).update { it.copy(lastMessage = message) } + throw IllegalStateException(message) + } + } + } + } + + private suspend fun execute(action: String, settings: StoredWebdavSettings): String { + val config = config(settings) + if (action == ACTION_TEST) { + client.ensureParents(config) + client.list(config) + return context.getString(R.string.webdav_connected) + } + var lastConflict: WebdavConflictException? = null + repeat(3) { attempt -> + try { + return runOnce(config, action) + } catch (err: WebdavConflictException) { + lastConflict = err + if (attempt == 2) throw err + Thread.sleep(50L * (attempt + 1)) + } + } + throw lastConflict ?: WebdavConflictException() + } + + private suspend fun runOnce(config: WebdavConfig, action: String): String { + val remote = download(config) + val profiles = WebdavProfiles(context) + val local = profiles.exportLocal() + val now = System.currentTimeMillis() + val merged = mergeWebdav( + localProfiles = local.profiles, + remoteProfiles = remote.profiles, + localTombstones = local.tombstones, + remoteTombstones = remote.meta.tombstones, + localAccess = local.access, + remoteAccess = remote.meta.access(), + now = now, + ) + val counts = profiles.apply(merged, local) + val uploaded = if (action == ACTION_PULL) 0 else upload(config, remote, merged) + WebdavLocalState(context).update { current -> + current.copy( + tombstones = mergeTombstones(merged.tombstones + current.tombstones, now), + ) + } + if (counts.downloaded == 0 && uploaded == 0 && counts.removed == 0) { + return context.getString(R.string.webdav_sync_idle) + } + return context.getString(R.string.webdav_sync_done, counts.downloaded, uploaded, counts.removed) + } + + private fun download(config: WebdavConfig): RemoteBundle { + client.ensureParents(config) + val now = System.currentTimeMillis() + val root = client.list(config) + val nested = client.list(config, WEBDAV_PROFILES_DIR) + sweep(config, root.map { it.name to it } + nested.map { "$WEBDAV_PROFILES_DIR/${it.name}" to it }, now) + val etags = linkedMapOf() + val metaGot = client.get(config, WEBDAV_META_FILE) + val meta = if (!metaGot.found || metaGot.body.isBlank()) { + SyncMetaPayload() + } else { + etags[WEBDAV_META_FILE] = metaGot.etag + parseMeta(metaGot.body) + } + val profiles = mutableListOf() + val unreadable = mutableSetOf() + for (entry in nested) { + if (entry.collection || isWebdavTempFileName(entry.name) || !entry.name.endsWith(".json")) continue + val name = "$WEBDAV_PROFILES_DIR/${entry.name}" + val got = client.get(config, name) + if (!got.found) continue + etags[name] = got.etag ?: entry.etag + if (got.body.isBlank()) continue + val parsed = parseProfile(got.body) + if (parsed == null) unreadable.add(name) else profiles.add(parsed) + } + return RemoteBundle(profiles, meta, etags, unreadable) + } + + private fun upload(config: WebdavConfig, remote: RemoteBundle, merged: MergeResult): Int { + val remoteByUuid = remote.profiles.associateBy { it.uuid } + var uploaded = 0 + val kept = mutableSetOf() + for (profile in merged.profiles) { + val name = profileFileName(profile.uuid) + kept.add(name) + if (name in remote.unreadable) continue + val existing = remoteByUuid[profile.uuid] + if (existing != null && sameRemote(existing, profile)) continue + client.put( + config, + name, + encodeProfile(profile), + ifMatch = remote.etags[name], + createOnly = name !in remote.etags, + ) + uploaded += 1 + } + var removedRemote = 0 + for (name in remote.etags.keys) { + if (!isProfileFileName(name) || name in kept || name in remote.unreadable) continue + client.delete(config, name) + removedRemote += 1 + } + val metaChanged = uploaded > 0 || removedRemote > 0 || + remote.meta.access() != merged.access || + remote.meta.tombstones != merged.tombstones + if (metaChanged) { + val meta = SyncMetaPayload( + updatedAt = System.currentTimeMillis(), + accessControlMode = merged.access.mode, + accessControlPackages = merged.access.packages, + accessControlUpdatedAt = merged.access.updatedAt, + tombstones = merged.tombstones, + ) + client.put( + config, + WEBDAV_META_FILE, + encodeMeta(meta), + ifMatch = remote.etags[WEBDAV_META_FILE], + createOnly = WEBDAV_META_FILE !in remote.etags, + ) + } + return uploaded + } + + private fun sweep(config: WebdavConfig, entries: List>, now: Long) { + for ((name, entry) in entries) { + if (!isStaleWebdavTemp(name, entry.lastModified, now)) continue + try { + client.delete(config, name) + } catch (_: Exception) { + } + } + } + + private fun config(settings: StoredWebdavSettings): WebdavConfig { + if (settings.url.isBlank()) throw WebdavException("empty_url") + if (settings.username.isBlank()) throw WebdavException("empty_user") + if (settings.password.isEmpty()) throw WebdavException("empty_password") + return WebdavConfig( + url = settings.url.trim(), + username = settings.username.trim(), + password = settings.password, + remotePath = resolveWebdavRemoteFolder(settings.remotePath), + clientId = WebdavLocalState(context).clientId(), + ) + } + + private data class RemoteBundle( + val profiles: List, + val meta: SyncMetaPayload, + val etags: Map, + val unreadable: Set, + ) + + companion object { + const val ACTION_TEST = "test" + const val ACTION_SYNC = "sync" + const val ACTION_PULL = "pull" + + private val gate = Mutex() + } +} + +private fun sameRemote(existing: ProfilePayload, profile: ProfilePayload): Boolean { + return existing.name == profile.name && + existing.type == profile.type && + existing.source == profile.source && + existing.interval == profile.interval && + existing.updatedAt == profile.updatedAt && + existing.ageSecretKey == profile.ageSecretKey && + fingerprint(existing.files) == fingerprint(profile.files) +} + +private fun WebdavException.localize(context: Context): String { + return when (code) { + "empty_url" -> context.getString(R.string.webdav_error_empty_url) + "bad_url" -> context.getString(R.string.webdav_error_bad_url) + "https" -> context.getString(R.string.webdav_error_https) + "empty_user" -> context.getString(R.string.webdav_error_empty_user) + "empty_password" -> context.getString(R.string.webdav_error_empty_password) + "auth" -> context.getString(R.string.webdav_error_auth) + "redirect" -> context.getString(R.string.webdav_error_redirect) + "timeout" -> context.getString(R.string.webdav_error_timeout) + "connect" -> context.getString(R.string.webdav_error_connect) + "locked" -> context.getString(R.string.webdav_error_locked) + "not_found", "conflict_parent" -> context.getString(R.string.webdav_error_not_found) + "conflict" -> context.getString(R.string.webdav_error_conflict) + "bad_version" -> context.getString(R.string.webdav_error_bad_version) + "broken_meta" -> context.getString(R.string.webdav_error_broken) + "too_large" -> context.getString(R.string.webdav_error_too_large, detail.ifBlank { "?" }) + "http" -> detail.ifBlank { context.getString(R.string.webdav_error_generic, status ?: 0) } + else -> detail.ifBlank { context.getString(R.string.webdav_error_generic, code) } + } +} diff --git a/service/src/main/res/values-zh/strings.xml b/service/src/main/res/values-zh/strings.xml index 77b87e9c16..2f96f79135 100644 --- a/service/src/main/res/values-zh/strings.xml +++ b/service/src/main/res/values-zh/strings.xml @@ -15,5 +15,24 @@ 配置更新服务 配置更新中 配置文件服务状态 + 连接成功 + 已经是最新 + 已同步:下载 %1$d,上传 %2$d,删除 %3$d + 请先填写 WebDAV 地址 + WebDAV 地址无效 + WebDAV 地址必须是 https + 请先填写 WebDAV 用户名 + 请先填写 WebDAV 密码 + WebDAV 认证失败,请检查用户名和密码 + WebDAV 重定向已被拒绝,请直接填写最终 https 地址 + WebDAV 请求超时 + 无法连接 WebDAV + 远端文件被锁定,请稍后重试 + 远端目录不存在。如果使用 Alist 等挂载,路径必须在已挂载目录下 + 远端文件已被其他设备修改,请重试 + 远端同步文件版本不受支持 + 远端同步文件不完整 + %1$s 太大,无法同步 + WebDAV 失败:%1$s 配置文件处理结果 \ No newline at end of file diff --git a/service/src/main/res/values/strings.xml b/service/src/main/res/values/strings.xml index 903b825a84..88cd232287 100644 --- a/service/src/main/res/values/strings.xml +++ b/service/src/main/res/values/strings.xml @@ -18,4 +18,23 @@ Provider Files Profile Updater Profile Updating + Connected + Already up to date + Synced. Downloaded %1$d, uploaded %2$d, removed %3$d. + Enter the WebDAV address + Invalid WebDAV address + WebDAV address must use https + Enter the WebDAV username + Enter the WebDAV password + WebDAV authentication failed. Check the username and password + WebDAV redirect was rejected. Use the final https address + WebDAV request timed out + Unable to connect to WebDAV + The remote file is locked. Try again later + The remote folder does not exist, or it is outside the mounted WebDAV path + The remote file was changed by another device. Try again + This WebDAV file was written by an unsupported sync version + The remote sync file is incomplete + %1$s is too large to sync + WebDAV failed: %1$s diff --git a/service/src/test/java/com/github/kr328/clash/service/webdav/WebdavSyncTest.kt b/service/src/test/java/com/github/kr328/clash/service/webdav/WebdavSyncTest.kt new file mode 100644 index 0000000000..6422f675e4 --- /dev/null +++ b/service/src/test/java/com/github/kr328/clash/service/webdav/WebdavSyncTest.kt @@ -0,0 +1,219 @@ +package com.github.kr328.clash.service.webdav + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class WebdavSyncTest { + @Test + fun httpsIsRequired() { + try { + joinWebdavUrl("http://example.com/dav", "/clash-meta/a.json") + throw AssertionError("http should be rejected") + } catch (err: WebdavException) { + assertEquals("https", err.code) + } + } + + @Test + fun joinsRemoteFolder() { + assertEquals( + "https://example.com/dav/clash-meta/profiles/a.json", + fileUrl("https://example.com/dav", "/clash-meta", "profiles/a.json"), + ) + assertEquals("/clash-meta", resolveWebdavRemoteFolder("/clash-meta/accounts.json")) + assertEquals("/clash-meta", resolveWebdavRemoteFolder("")) + } + + @Test + fun parsesPropfindAndSkipsItself() { + val xml = """ + + + /dav/clash-meta/ + + + + /dav/clash-meta/profiles/ + + + Thu, 18 Sep 2026 00:00:00 GMT + + + + /dav/clash-meta/_sync.json + "abc" + + + """.trimIndent() + val entries = parsePropfindEntries(xml, "https://example.com/dav/clash-meta/") + assertEquals(listOf("profiles", "_sync.json"), entries.map { it.name }) + assertEquals("\"abc\"", entries[1].etag) + assertTrue(entries[0].collection) + assertFalse(entries[1].collection) + } + + @Test + fun staleTempNeedsADate() { + assertFalse(isStaleWebdavTemp("a.json.abcd1234.tmp", null, 10_000)) + val old = "Thu, 01 Jan 2026 00:00:00 GMT" + assertTrue(isStaleWebdavTemp("a.json.abcd1234.tmp", old, parseHttpDate(old)!! + WEBDAV_TMP_MAX_AGE_MS)) + assertFalse(isStaleWebdavTemp("a.json", old, Long.MAX_VALUE)) + } + + @Test + fun keepsProfilesThatExistOnOnlyOneSide() { + val local = profile("local-only", source = "https://local.example/sub") + val remote = profile("remote-only", source = "https://remote.example/sub") + val merged = mergeWebdav( + localProfiles = listOf(local), + remoteProfiles = listOf(remote), + localTombstones = emptyList(), + remoteTombstones = emptyList(), + localAccess = access(), + remoteAccess = access(), + now = 1_000, + ) + assertEquals(setOf("local-only", "remote-only"), merged.profiles.map { it.uuid }.toSet()) + } + + @Test + fun sameSubscriptionKeepsLocalUuidAndNewerConfig() { + val local = profile( + uuid = "local-id", + source = "https://example.com/sub/", + updatedAt = 100, + name = "Local name", + ) + val remote = profile( + uuid = "remote-id", + source = "https://example.com/sub", + updatedAt = 200, + name = "Remote name", + ) + val merged = mergeWebdav( + listOf(local), + listOf(remote), + emptyList(), + emptyList(), + access(), + access(), + now = 1_000, + ) + assertEquals(1, merged.profiles.size) + val profile = merged.profiles.single() + assertEquals("local-id", profile.uuid) + assertEquals("Remote name", profile.name) + assertEquals(200, profile.updatedAt) + } + + @Test + fun tombstoneRemovesMatchingSubscriptionOnEitherUuid() { + val local = profile("local-id", source = "https://example.com/sub", updatedAt = 100) + val tombstone = TombstonePayload( + uuid = "other-id", + source = "https://example.com/sub", + type = "Url", + deletedAt = 150, + ) + val merged = mergeWebdav( + listOf(local), + emptyList(), + listOf(tombstone), + emptyList(), + access(), + access(), + now = 1_000, + ) + assertTrue(merged.profiles.isEmpty()) + assertEquals(1, merged.tombstones.size) + } + + @Test + fun newerProfileDropsTombstone() { + val remote = profile("id", source = "https://example.com/sub", updatedAt = 500) + val tombstone = TombstonePayload("id", "https://example.com/sub", "Url", 100) + val merged = mergeWebdav( + emptyList(), + listOf(remote), + emptyList(), + listOf(tombstone), + access(), + access(), + now = 1_000, + ) + assertEquals("id", merged.profiles.single().uuid) + assertTrue(merged.tombstones.isEmpty()) + } + + @Test + fun expiredTombstoneIsDropped() { + val tombstone = TombstonePayload("id", "", "File", 1) + val merged = mergeWebdav( + listOf(profile("id", type = "File", source = "", updatedAt = 0)), + emptyList(), + listOf(tombstone), + emptyList(), + access(), + access(), + now = tombstone.deletedAt + WEBDAV_TOMBSTONE_TTL_MS + 1, + ) + assertEquals("id", merged.profiles.single().uuid) + assertTrue(merged.tombstones.isEmpty()) + } + + @Test + fun newerAccessControlWins() { + val merged = mergeWebdav( + emptyList(), + emptyList(), + emptyList(), + emptyList(), + access(updatedAt = 10, packages = listOf("com.local")), + access(mode = "AcceptSelected", updatedAt = 20, packages = listOf("com.remote")), + now = 100, + ) + assertEquals("AcceptSelected", merged.access.mode) + assertEquals(listOf("com.remote"), merged.access.packages) + } + + @Test + fun rejectsUnsafePathsAndBrokenProfiles() { + try { + safeRelativePath("../config.yaml") + throw AssertionError("path should be rejected") + } catch (err: WebdavException) { + assertEquals("bad_path", err.code) + } + assertNull(parseProfile("""{"kind":"other","version":1,"uuid":"a","name":"n"}""")) + val encoded = encodeProfile(profile("abc")) + assertEquals("abc", parseProfile(encoded)?.uuid) + } + + private fun profile( + uuid: String, + source: String = "https://example.com/$uuid", + type: String = "Url", + name: String = uuid, + updatedAt: Long = 1, + ): ProfilePayload { + return ProfilePayload( + uuid = uuid, + name = name, + type = type, + source = source, + updatedAt = updatedAt, + files = listOf(ProfileFilePayload("config.yaml", "e30=")), + ) + } + + private fun access( + mode: String = "AcceptAll", + updatedAt: Long = 0, + packages: List = emptyList(), + ): AccessControlPayload { + return AccessControlPayload(mode, packages, updatedAt) + } +} From 5e298b78780c45aaf736187b09f26a74be98c602 Mon Sep 17 00:00:00 2001 From: clarence Date: Thu, 1 Oct 2026 15:42:59 +0800 Subject: [PATCH 03/10] Share a subscription URL as a QR code. Another device can import it with the existing QR import flow. Co-authored-by: Cursor --- design/build.gradle.kts | 1 + .../kr328/clash/design/ProfilesDesign.kt | 9 ++ .../kr328/clash/design/PropertiesDesign.kt | 8 ++ .../kr328/clash/design/util/SubscriptionQr.kt | 123 ++++++++++++++++++ design/src/main/res/drawable/bg_qr_code.xml | 5 + .../main/res/drawable/ic_baseline_qr_code.xml | 10 ++ .../src/main/res/layout/design_properties.xml | 10 ++ .../main/res/layout/dialog_profiles_menu.xml | 9 ++ .../res/layout/dialog_subscription_qr.xml | 61 +++++++++ design/src/main/res/values-ja-rJP/strings.xml | 5 + design/src/main/res/values-ko-rKR/strings.xml | 5 + design/src/main/res/values-ru/strings.xml | 5 + design/src/main/res/values-vi/strings.xml | 5 + design/src/main/res/values-zh-rHK/strings.xml | 5 + design/src/main/res/values-zh-rTW/strings.xml | 5 + design/src/main/res/values-zh/strings.xml | 5 + design/src/main/res/values/strings.xml | 5 + gradle/libs.versions.toml | 2 + 18 files changed, 278 insertions(+) create mode 100644 design/src/main/java/com/github/kr328/clash/design/util/SubscriptionQr.kt create mode 100644 design/src/main/res/drawable/bg_qr_code.xml create mode 100644 design/src/main/res/drawable/ic_baseline_qr_code.xml create mode 100644 design/src/main/res/layout/dialog_subscription_qr.xml diff --git a/design/build.gradle.kts b/design/build.gradle.kts index 6e4421e65a..6f0c4c141a 100644 --- a/design/build.gradle.kts +++ b/design/build.gradle.kts @@ -18,4 +18,5 @@ dependencies { implementation(libs.androidx.fragment) implementation(libs.androidx.viewpager) implementation(libs.google.material) + implementation(libs.zxing.core) } diff --git a/design/src/main/java/com/github/kr328/clash/design/ProfilesDesign.kt b/design/src/main/java/com/github/kr328/clash/design/ProfilesDesign.kt index 7d850af374..46e0826dfa 100644 --- a/design/src/main/java/com/github/kr328/clash/design/ProfilesDesign.kt +++ b/design/src/main/java/com/github/kr328/clash/design/ProfilesDesign.kt @@ -123,6 +123,15 @@ class ProfilesDesign(context: Context) : Design(context) dialog.dismiss() } + fun canShareSubscriptionQr(profile: Profile): Boolean { + return isShareableSubscriptionUrl(profile.source) + } + + fun requestShareQr(dialog: Dialog, profile: Profile) { + dialog.dismiss() + showSubscriptionQr(profile.name, profile.source) + } + fun requestDuplicate(dialog: Dialog, profile: Profile) { requests.trySend(Request.Duplicate(profile)) diff --git a/design/src/main/java/com/github/kr328/clash/design/PropertiesDesign.kt b/design/src/main/java/com/github/kr328/clash/design/PropertiesDesign.kt index b0c26eff65..6ffc4272fd 100644 --- a/design/src/main/java/com/github/kr328/clash/design/PropertiesDesign.kt +++ b/design/src/main/java/com/github/kr328/clash/design/PropertiesDesign.kt @@ -102,6 +102,14 @@ class PropertiesDesign(context: Context) : Design(cont } } + fun canShareSubscriptionQr(profile: Profile): Boolean { + return isShareableSubscriptionUrl(profile.source) + } + + fun shareSubscriptionQr() { + showSubscriptionQr(profile.name, profile.source) + } + fun inputUrl() { if (profile.type == Profile.Type.External) return diff --git a/design/src/main/java/com/github/kr328/clash/design/util/SubscriptionQr.kt b/design/src/main/java/com/github/kr328/clash/design/util/SubscriptionQr.kt new file mode 100644 index 0000000000..7f823059c2 --- /dev/null +++ b/design/src/main/java/com/github/kr328/clash/design/util/SubscriptionQr.kt @@ -0,0 +1,123 @@ +package com.github.kr328.clash.design.util + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.Color +import androidx.core.content.getSystemService +import com.github.kr328.clash.design.Design +import com.github.kr328.clash.design.R +import com.github.kr328.clash.design.databinding.DialogSubscriptionQrBinding +import com.github.kr328.clash.design.ui.ToastDuration +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.zxing.BarcodeFormat +import com.google.zxing.EncodeHintType +import com.google.zxing.WriterException +import com.google.zxing.qrcode.QRCodeWriter +import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +fun isShareableSubscriptionUrl(source: String): Boolean { + return ValidatorHttpUrl(source.trim()) +} + +fun Design<*>.showSubscriptionQr(name: String, url: String) { + val content = url.trim() + if (!isShareableSubscriptionUrl(content)) + return + + launch(Dispatchers.Default) { + val size = (context.resources.displayMetrics.density * 280) + .toInt() + .coerceIn(480, 1080) + val bitmap = try { + encodeSubscriptionQr(content, size) + } catch (_: WriterException) { + withContext(Dispatchers.Main) { + showToast(R.string.subscription_qr_too_long, ToastDuration.Long) + } + return@launch + } + + withContext(Dispatchers.Main) { + presentSubscriptionQr(name, content, bitmap) + } + } +} + +private fun Design<*>.presentSubscriptionQr(name: String, url: String, bitmap: Bitmap) { + val binding = DialogSubscriptionQrBinding + .inflate(context.layoutInflater, context.root, false) + + binding.qrView.setImageBitmap(bitmap) + binding.urlView.text = url + + val title = name.ifBlank { context.getString(R.string.share_subscription_qr) } + val dialog = MaterialAlertDialogBuilder(context) + .setTitle(title) + .setView(binding.root) + .setPositiveButton(R.string.ok) { _, _ -> } + .create() + + binding.copyView.setOnClickListener { + val data = ClipData.newPlainText(title, url) + context.getSystemService()?.setPrimaryClip(data) + launch { showToast(R.string.copied, ToastDuration.Short) } + } + + binding.shareView.setOnClickListener { + val send = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_SUBJECT, title) + putExtra(Intent.EXTRA_TEXT, url) + } + context.startActivity(Intent.createChooser(send, context.getString(R.string.share_link))) + } + + dialog.show() +} + +private fun encodeSubscriptionQr(contents: String, size: Int): Bitmap { + val levels = listOf(ErrorCorrectionLevel.M, ErrorCorrectionLevel.L) + var error: WriterException? = null + + for (level in levels) { + try { + return encodeSubscriptionQr(contents, size, level) + } catch (e: WriterException) { + error = e + } + } + + throw error ?: WriterException("QR encode failed") +} + +private fun encodeSubscriptionQr( + contents: String, + size: Int, + level: ErrorCorrectionLevel, +): Bitmap { + val hints = mapOf( + EncodeHintType.CHARACTER_SET to "UTF-8", + EncodeHintType.MARGIN to 1, + EncodeHintType.ERROR_CORRECTION to level, + ) + val matrix = QRCodeWriter().encode(contents, BarcodeFormat.QR_CODE, size, size, hints) + val width = matrix.width + val height = matrix.height + val pixels = IntArray(width * height) + + for (y in 0 until height) { + val offset = y * width + for (x in 0 until width) { + pixels[offset + x] = if (matrix.get(x, y)) Color.BLACK else Color.WHITE + } + } + + return Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888).also { + it.setPixels(pixels, 0, width, 0, 0, width, height) + } +} diff --git a/design/src/main/res/drawable/bg_qr_code.xml b/design/src/main/res/drawable/bg_qr_code.xml new file mode 100644 index 0000000000..86f46e5668 --- /dev/null +++ b/design/src/main/res/drawable/bg_qr_code.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/design/src/main/res/drawable/ic_baseline_qr_code.xml b/design/src/main/res/drawable/ic_baseline_qr_code.xml new file mode 100644 index 0000000000..52d4ec1e59 --- /dev/null +++ b/design/src/main/res/drawable/ic_baseline_qr_code.xml @@ -0,0 +1,10 @@ + + + diff --git a/design/src/main/res/layout/design_properties.xml b/design/src/main/res/layout/design_properties.xml index a234bba265..bb2b2eb003 100644 --- a/design/src/main/res/layout/design_properties.xml +++ b/design/src/main/res/layout/design_properties.xml @@ -77,6 +77,16 @@ app:text="@{profile.source}" app:title="@string/url" /> + + + + + + + + + + + + + + + + + + + + + + diff --git a/design/src/main/res/values-ja-rJP/strings.xml b/design/src/main/res/values-ja-rJP/strings.xml index e320075900..6972ddd290 100644 --- a/design/src/main/res/values-ja-rJP/strings.xml +++ b/design/src/main/res/values-ja-rJP/strings.xml @@ -31,6 +31,11 @@ URL URLからインポート QRコードからインポート + サブスクリプションQRを共有 + 別の端末で「QRコードからインポート」を使って読み取れます。 + リンクをコピー + リンクを共有 + サブスクリプションURLが長すぎるため、QRコードを作成できません。 外部入力 %s (未保存) アプリが破損しています diff --git a/design/src/main/res/values-ko-rKR/strings.xml b/design/src/main/res/values-ko-rKR/strings.xml index fd781cc3c4..fd58573b47 100644 --- a/design/src/main/res/values-ko-rKR/strings.xml +++ b/design/src/main/res/values-ko-rKR/strings.xml @@ -32,6 +32,11 @@ URL에서 가져오기 외부 QR코드에서 가져오기 + 구독 QR 코드 공유 + 다른 기기에서 QR코드에서 가져오기로 스캔할 수 있습니다. + 링크 복사 + 링크 공유 + 구독 주소가 너무 길어 QR 코드를 만들 수 없습니다. %s (저장되지 않음) 앱 오류 앱 중지 diff --git a/design/src/main/res/values-ru/strings.xml b/design/src/main/res/values-ru/strings.xml index 568f519b40..9e3b3c10a5 100644 --- a/design/src/main/res/values-ru/strings.xml +++ b/design/src/main/res/values-ru/strings.xml @@ -39,6 +39,11 @@ Импорт из URL Внешний Импорт из QR-кода + Поделиться QR-кодом подписки + Другое устройство может отсканировать его через импорт из QR-кода. + Копировать ссылку + Поделиться ссылкой + Ссылка подписки слишком длинная для QR-кода. %s (не сохранён) Приложение сломано diff --git a/design/src/main/res/values-vi/strings.xml b/design/src/main/res/values-vi/strings.xml index 38cbed0689..4b1e351d05 100644 --- a/design/src/main/res/values-vi/strings.xml +++ b/design/src/main/res/values-vi/strings.xml @@ -249,6 +249,11 @@ Tuỳ chọn VpnService Cảnh báo Nhập từ Mã QR + Chia sẻ mã QR đăng ký + Thiết bị khác có thể quét bằng Nhập từ Mã QR. + Sao chép liên kết + Chia sẻ liên kết + Liên kết đăng ký quá dài để tạo mã QR. Quyền truy cập camera bị hạn chế. Vui lòng bật trong Cài đặt. Đã xảy ra ngoại lệ hệ thống không xử lý được. Cấu hình mới diff --git a/design/src/main/res/values-zh-rHK/strings.xml b/design/src/main/res/values-zh-rHK/strings.xml index 77f20b2f63..c0161d56ae 100644 --- a/design/src/main/res/values-zh-rHK/strings.xml +++ b/design/src/main/res/values-zh-rHK/strings.xml @@ -42,6 +42,11 @@ 從 URL 導入 界面 從二維碼導入 + 分享訂閱二維碼 + 另一台裝置可用「從二維碼導入」掃描 + 複製連結 + 分享連結 + 訂閱連結過長,無法產生二維碼 無效的 URL Logcat 日誌 diff --git a/design/src/main/res/values-zh-rTW/strings.xml b/design/src/main/res/values-zh-rTW/strings.xml index 87bd8b6058..dca2ea7c7a 100644 --- a/design/src/main/res/values-zh-rTW/strings.xml +++ b/design/src/main/res/values-zh-rTW/strings.xml @@ -41,6 +41,11 @@ 從檔案匯入 從 URL 匯入 從二維碼導入 + 分享訂閱二維碼 + 另一台裝置可用「從二維碼導入」掃描 + 複製連結 + 分享連結 + 訂閱連結過長,無法產生二維碼 介面 無效 URL Logcat diff --git a/design/src/main/res/values-zh/strings.xml b/design/src/main/res/values-zh/strings.xml index 28881ef382..ffba97516c 100644 --- a/design/src/main/res/values-zh/strings.xml +++ b/design/src/main/res/values-zh/strings.xml @@ -45,6 +45,11 @@ 从文件导入 从 URL 导入 从二维码导入 + 分享订阅二维码 + 另一台设备可用「从二维码导入」扫描 + 复制链接 + 分享链接 + 订阅链接过长,无法生成二维码 界面 无效的 URL Logcat diff --git a/design/src/main/res/values/strings.xml b/design/src/main/res/values/strings.xml index 39dddba23e..30f85ab7d1 100644 --- a/design/src/main/res/values/strings.xml +++ b/design/src/main/res/values/strings.xml @@ -49,6 +49,11 @@ QR Code Import from URL Import from QR Code + Share Subscription QR + Another device can scan this with Import from QR Code. + Copy Link + Share Link + This subscription URL is too long to encode as a QR code. External %s (Unsaved) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9df8b80328..48fd9ac459 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -17,6 +17,7 @@ kaidl = "1.15" room = "2.4.2" multiprocess = "1.0.0" quickie = "1.11.0" +zxing = "3.5.3" androidx-activity-ktx = "1.9.0" okhttp = "4.12.0" desugar = "2.1.4" @@ -45,6 +46,7 @@ kaidl-compiler = { module = "com.github.kr328.kaidl:kaidl", version.ref = "kaidl kaidl-runtime = { module = "com.github.kr328.kaidl:kaidl-runtime", version.ref = "kaidl" } rikkax-multiprocess = { module = "dev.rikka.rikkax.preference:multiprocess", version.ref = "multiprocess" } quickie-bundled = { group = "io.github.g00fy2.quickie", name = "quickie-bundled", version.ref = "quickie" } +zxing-core = { module = "com.google.zxing:core", version.ref = "zxing" } androidx-activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "androidx-activity-ktx" } okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } desugar-jdk = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar" } From 9e905c99cdef7f9f30454bde241682a2f33ceadd Mon Sep 17 00:00:00 2001 From: clarence Date: Thu, 1 Oct 2026 15:47:21 +0800 Subject: [PATCH 04/10] Point the clash core at the CA certificate bundle. Co-authored-by: Cursor --- core/src/foss/golang/clash | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/foss/golang/clash b/core/src/foss/golang/clash index 88dcbf7f16..90a5afcc06 160000 --- a/core/src/foss/golang/clash +++ b/core/src/foss/golang/clash @@ -1 +1 @@ -Subproject commit 88dcbf7f1614a67c3b36b848ee3592dfa92ada36 +Subproject commit 90a5afcc06a174033538aa7c844917cf0933cade From 18b107d36c3f6e2c9196a21f2f9854796841dc25 Mon Sep 17 00:00:00 2001 From: clarence Date: Thu, 1 Oct 2026 15:47:21 +0800 Subject: [PATCH 05/10] Limit native builds to arm64-v8a. Co-authored-by: Cursor --- build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 27d3cd66d2..1cdf8b820f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -65,12 +65,12 @@ subprojects { resValue("integer", "release_code", "$versionCode") ndk { - abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64") + abiFilters += listOf("arm64-v8a"/*, "armeabi-v7a", "x86", "x86_64"*/) } externalNativeBuild { cmake { - abiFilters("arm64-v8a", "armeabi-v7a", "x86", "x86_64") + abiFilters("arm64-v8a"/*, "armeabi-v7a", "x86", "x86_64"*/) } } From fe9e84008e9a1e798e54d6324b9d0daab607dc95 Mon Sep 17 00:00:00 2001 From: clarence Date: Thu, 1 Oct 2026 15:53:25 +0800 Subject: [PATCH 06/10] Sync app strategy groups and their package lists over WebDAV. This fork routes apps by named groups, so those groups travel with the subscriptions. Co-authored-by: Cursor --- .../kr328/clash/AppsStrategyActivity.kt | 29 +++++ design/src/main/res/values-zh/strings.xml | 4 +- design/src/main/res/values/strings.xml | 4 +- .../clash/service/webdav/WebdavAutoSync.kt | 14 +++ .../clash/service/webdav/WebdavLocalState.kt | 1 + .../kr328/clash/service/webdav/WebdavMerge.kt | 60 ++++++++++ .../clash/service/webdav/WebdavModels.kt | 13 +++ .../clash/service/webdav/WebdavProfiles.kt | 104 +++++++++++++++++- .../kr328/clash/service/webdav/WebdavSync.kt | 21 +++- .../clash/service/webdav/WebdavSyncTest.kt | 10 ++ 10 files changed, 252 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/github/kr328/clash/AppsStrategyActivity.kt b/app/src/main/java/com/github/kr328/clash/AppsStrategyActivity.kt index 4ff2c679d0..ff12a2043f 100644 --- a/app/src/main/java/com/github/kr328/clash/AppsStrategyActivity.kt +++ b/app/src/main/java/com/github/kr328/clash/AppsStrategyActivity.kt @@ -8,6 +8,10 @@ import com.github.kr328.clash.design.requestAppListsConfigEdit import com.github.kr328.clash.service.model.AccessControlMode import com.github.kr328.clash.service.model.AppsStrategyConfig import com.github.kr328.clash.service.store.ServiceStore +import com.github.kr328.clash.service.webdav.WebdavAutoSync +import com.github.kr328.clash.service.webdav.WebdavSettingsStore +import com.github.kr328.clash.service.webdav.WebdavSync +import com.github.kr328.clash.util.withProfile import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.isActive import kotlinx.coroutines.selects.select @@ -102,7 +106,9 @@ class AppsStrategyActivity : BaseActivity() { withContext(Dispatchers.IO) { val service = ServiceStore(this@AppsStrategyActivity) service.activeAppsStrategyConfigUuid = uuid + WebdavAutoSync.noteActiveAppsStrategy(this@AppsStrategyActivity) design?.fetch() + syncIfEnabled() } } @@ -113,7 +119,10 @@ class AppsStrategyActivity : BaseActivity() { service.appsStrategyConfigs = configs if (service.activeAppsStrategyConfigUuid == uuid) { service.activeAppsStrategyConfigUuid = null + WebdavAutoSync.noteActiveAppsStrategy(this@AppsStrategyActivity) } + WebdavAutoSync.noteStrategyDeleted(this@AppsStrategyActivity, uuid) + syncIfEnabled() } } @@ -129,7 +138,9 @@ class AppsStrategyActivity : BaseActivity() { ) configs.add(newConfig) service.appsStrategyConfigs = configs + WebdavAutoSync.noteAppsStrategy(this@AppsStrategyActivity, newConfig.uuid) design?.fetch() + syncIfEnabled() } } @@ -149,6 +160,24 @@ class AppsStrategyActivity : BaseActivity() { } } service.appsStrategyConfigs = configs + WebdavAutoSync.noteAppsStrategy(this@AppsStrategyActivity, uuid) + syncIfEnabled() + } + } + + private suspend fun syncIfEnabled() { + val settings = WebdavSettingsStore(this).load() + if (!settings.autoSync || settings.url.isBlank() || settings.username.isBlank() || settings.password.isEmpty()) { + return + } + withProfile { + webdavPerform( + WebdavSync.ACTION_SYNC, + settings.url, + settings.username, + settings.password, + settings.remotePath, + ) } } } diff --git a/design/src/main/res/values-zh/strings.xml b/design/src/main/res/values-zh/strings.xml index ffba97516c..02a80cc387 100644 --- a/design/src/main/res/values-zh/strings.xml +++ b/design/src/main/res/values-zh/strings.xml @@ -289,7 +289,7 @@ 停止 Clash 服务 WebDAV 同步 - 同步已保存的订阅配置,以及访问控制里的应用包名。地址必须是 https。同步会先合并,不会盖掉其他设备独有的订阅。正在编辑、尚未保存的配置不会上传。 + 同步已保存的订阅配置,以及各分组里的应用包名。地址必须是 https。同步会先合并,不会盖掉其他设备独有的订阅或分组。正在编辑、尚未保存的配置不会上传。 尚未同步 服务器 地址 @@ -301,7 +301,7 @@ 远端目录 /clash-meta 自动同步 - 启动后,以及订阅或应用包名变化后自动同步 + 启动后,以及订阅或分组包名变化后自动同步 测试连接 立即同步 仅下载 diff --git a/design/src/main/res/values/strings.xml b/design/src/main/res/values/strings.xml index 30f85ab7d1..a553b1f121 100644 --- a/design/src/main/res/values/strings.xml +++ b/design/src/main/res/values/strings.xml @@ -385,7 +385,7 @@ Stop Clash service WebDAV Sync - Sync saved subscriptions and the app package names used by access control. The address must be https. A sync merges first and will not remove subscriptions that exist only on another device. Profiles that are still being edited are not uploaded. + Sync saved subscriptions and the package names in each app group. The address must be https. A sync merges first and will not remove subscriptions or groups that exist only on another device. Profiles that are still being edited are not uploaded. Not synced yet Server Address @@ -397,7 +397,7 @@ Remote folder /clash-meta Auto sync - Sync after startup and after subscriptions or package names change + Sync after startup and after subscriptions or app groups change Test connection Sync now Download only diff --git a/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavAutoSync.kt b/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavAutoSync.kt index b315e38824..9307777155 100644 --- a/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavAutoSync.kt +++ b/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavAutoSync.kt @@ -57,6 +57,20 @@ object WebdavAutoSync { WebdavLocalState(context).update { it.copy(activeStrategyUpdatedAt = now) } } + fun noteStrategyDeleted(context: Context, uuid: UUID) { + val deletedAt = System.currentTimeMillis() + WebdavLocalState(context).update { state -> + state.copy( + strategyTombstones = state.strategyTombstones + TombstonePayload( + uuid = uuid.toString(), + type = "AppsStrategy", + deletedAt = deletedAt, + ), + strategyUpdatedAt = state.strategyUpdatedAt - uuid.toString(), + ) + } + } + private suspend fun sync(context: Context, force: Boolean) { val settings = WebdavSettingsStore(context).load() if (!settings.autoSync || settings.url.isBlank() || settings.username.isBlank() || settings.password.isEmpty()) { diff --git a/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavLocalState.kt b/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavLocalState.kt index 0dc6b11505..73da6bfb4f 100644 --- a/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavLocalState.kt +++ b/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavLocalState.kt @@ -14,6 +14,7 @@ data class LocalState( val accessControlUpdatedAt: Long = 0, val strategyUpdatedAt: Map = emptyMap(), val activeStrategyUpdatedAt: Long = 0, + val strategyTombstones: List = emptyList(), val lastSyncAt: Long = 0, val lastMessage: String = "", ) diff --git a/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavMerge.kt b/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavMerge.kt index 2767580e7c..792863b169 100644 --- a/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavMerge.kt +++ b/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavMerge.kt @@ -4,6 +4,10 @@ data class MergeResult( val profiles: List, val tombstones: List, val access: AccessControlPayload, + val strategies: List = emptyList(), + val strategyTombstones: List = emptyList(), + val activeAppsStrategy: String? = null, + val activeAppsStrategyUpdatedAt: Long = 0, ) fun mergeWebdav( @@ -14,6 +18,14 @@ fun mergeWebdav( localAccess: AccessControlPayload, remoteAccess: AccessControlPayload, now: Long, + localStrategies: List = emptyList(), + remoteStrategies: List = emptyList(), + localStrategyTombstones: List = emptyList(), + remoteStrategyTombstones: List = emptyList(), + localActiveStrategy: String? = null, + localActiveStrategyUpdatedAt: Long = 0, + remoteActiveStrategy: String? = null, + remoteActiveStrategyUpdatedAt: Long = 0, ): MergeResult { val tombstones = mergeTombstones(localTombstones + remoteTombstones, now) val localByIdentity = localProfiles.associateBy { subscriptionIdentity(it.type, it.source, it.uuid) } @@ -41,13 +53,61 @@ fun mergeWebdav( profiles.add(merged) } + val strategyMerge = mergeStrategies( + localStrategies, + remoteStrategies, + localStrategyTombstones, + remoteStrategyTombstones, + now, + ) + val active = if (remoteActiveStrategyUpdatedAt > localActiveStrategyUpdatedAt) { + remoteActiveStrategy to remoteActiveStrategyUpdatedAt + } else { + localActiveStrategy to localActiveStrategyUpdatedAt + } + val activeUuid = active.first?.takeIf { id -> strategyMerge.strategies.any { it.uuid == id } } return MergeResult( profiles = profiles.sortedBy { it.uuid }, tombstones = keptTombstones.sortedBy { it.uuid }, access = if (remoteAccess.updatedAt > localAccess.updatedAt) remoteAccess else localAccess, + strategies = strategyMerge.strategies, + strategyTombstones = strategyMerge.tombstones, + activeAppsStrategy = activeUuid, + activeAppsStrategyUpdatedAt = if (activeUuid == null) active.second else active.second, ) } +data class StrategyMerge( + val strategies: List, + val tombstones: List, +) + +fun mergeStrategies( + local: List, + remote: List, + localTombstones: List, + remoteTombstones: List, + now: Long, +): StrategyMerge { + val tombstones = mergeTombstones(localTombstones + remoteTombstones, now).toMutableList() + val localById = local.associateBy { it.uuid } + val remoteById = remote.associateBy { it.uuid } + val kept = mutableListOf() + for (uuid in localById.keys + remoteById.keys) { + val merged = when { + localById[uuid] == null -> remoteById[uuid] + remoteById[uuid] == null -> localById[uuid] + remoteById.getValue(uuid).updatedAt > localById.getValue(uuid).updatedAt -> remoteById[uuid] + else -> localById[uuid] + } ?: continue + val tombstone = tombstones.filter { it.uuid == merged.uuid }.maxByOrNull { it.deletedAt } + if (tombstone != null && tombstone.deletedAt >= merged.updatedAt) continue + if (tombstone != null) tombstones.removeAll { it.uuid == merged.uuid } + kept.add(merged) + } + return StrategyMerge(kept.sortedBy { it.uuid }, tombstones.sortedBy { it.uuid }) +} + fun mergeTombstones(tombstones: List, now: Long): List { val kept = linkedMapOf() for (tombstone in tombstones) { diff --git a/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavModels.kt b/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavModels.kt index ef1b54ec74..fa52c84f19 100644 --- a/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavModels.kt +++ b/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavModels.kt @@ -48,6 +48,15 @@ data class AccessControlPayload( val updatedAt: Long = 0, ) +@Serializable +data class AppsStrategyPayload( + val uuid: String, + val name: String, + val mode: String = "AcceptAll", + val packages: List = emptyList(), + val updatedAt: Long = 0, +) + @Serializable data class SyncMetaPayload( val kind: String = WEBDAV_SYNC_KIND, @@ -56,6 +65,10 @@ data class SyncMetaPayload( val accessControlMode: String = "AcceptAll", val accessControlPackages: List = emptyList(), val accessControlUpdatedAt: Long = 0, + val appsStrategies: List = emptyList(), + val appsStrategyTombstones: List = emptyList(), + val activeAppsStrategy: String? = null, + val activeAppsStrategyUpdatedAt: Long = 0, val tombstones: List = emptyList(), ) { fun access(): AccessControlPayload { diff --git a/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavProfiles.kt b/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavProfiles.kt index 26aa254874..2f2d1fcb6e 100644 --- a/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavProfiles.kt +++ b/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavProfiles.kt @@ -8,6 +8,7 @@ import com.github.kr328.clash.service.data.Imported import com.github.kr328.clash.service.data.ImportedDao import com.github.kr328.clash.service.data.PendingDao import com.github.kr328.clash.service.model.AccessControlMode +import com.github.kr328.clash.service.model.AppsStrategyConfig import com.github.kr328.clash.service.model.Profile import com.github.kr328.clash.service.store.ServiceStore import com.github.kr328.clash.service.util.importedDir @@ -20,6 +21,10 @@ data class LocalSnapshot( val profiles: List, val access: AccessControlPayload, val tombstones: List, + val strategies: List = emptyList(), + val strategyTombstones: List = emptyList(), + val activeAppsStrategy: String? = null, + val activeAppsStrategyUpdatedAt: Long = 0, ) class WebdavProfiles(private val context: Context) { @@ -68,6 +73,8 @@ class WebdavProfiles(private val context: Context) { } accessUpdatedAt = state.read().accessControlUpdatedAt } + val strategies = exportStrategies(store, local) + val active = exportActiveStrategy(store, local) return LocalSnapshot( profiles = profiles, access = AccessControlPayload( @@ -76,10 +83,14 @@ class WebdavProfiles(private val context: Context) { updatedAt = accessUpdatedAt, ), tombstones = local.tombstones, + strategies = strategies, + strategyTombstones = local.strategyTombstones, + activeAppsStrategy = active.first, + activeAppsStrategyUpdatedAt = active.second, ) } - suspend fun apply(result: MergeResult, local: LocalSnapshot): ApplyCounts { + suspend fun apply(result: MergeResult, local: LocalSnapshot, startedAt: Long): ApplyCounts { var downloaded = 0 var removed = 0 var skipped = 0 @@ -114,7 +125,12 @@ class WebdavProfiles(private val context: Context) { } applyAccess(result.access, local.access) - return ApplyCounts(downloaded = downloaded, removed = removed, skipped = skipped) + val strategyCounts = applyStrategies(result, local, startedAt) + return ApplyCounts( + downloaded = downloaded + strategyCounts.first, + removed = removed + strategyCounts.second, + skipped = skipped, + ) } private suspend fun install(payload: ProfilePayload): Boolean { @@ -205,6 +221,90 @@ class WebdavProfiles(private val context: Context) { else current.copy(accessControlUpdatedAt = remote.updatedAt) } } + + private fun exportStrategies(store: ServiceStore, local: LocalState): List { + return store.appsStrategyConfigs.map { config -> + val id = config.uuid.toString() + var stamp = local.strategyUpdatedAt[id] ?: 0L + if (stamp == 0L) { + val stamped = System.currentTimeMillis() + state.update { current -> + if ((current.strategyUpdatedAt[id] ?: 0L) > 0) current + else current.copy(strategyUpdatedAt = current.strategyUpdatedAt + (id to stamped)) + } + stamp = state.read().strategyUpdatedAt[id] ?: stamped + } + AppsStrategyPayload( + uuid = id, + name = config.name, + mode = config.mode.name, + packages = config.packages.sorted(), + updatedAt = stamp, + ) + } + } + + private fun exportActiveStrategy(store: ServiceStore, local: LocalState): Pair { + val active = store.activeAppsStrategyConfigUuid?.toString() + var stamp = local.activeStrategyUpdatedAt + if (stamp == 0L && active != null) { + val stamped = System.currentTimeMillis() + state.update { current -> + if (current.activeStrategyUpdatedAt > 0) current + else current.copy(activeStrategyUpdatedAt = stamped) + } + stamp = state.read().activeStrategyUpdatedAt + } + return active to stamp + } + + private fun applyStrategies(result: MergeResult, local: LocalSnapshot, startedAt: Long): Pair { + val store = ServiceStore(context) + val fresh = state.read() + val current = store.appsStrategyConfigs.associateBy { it.uuid.toString() }.toMutableMap() + var downloaded = 0 + var removed = 0 + val keep = result.strategies.map { it.uuid }.toSet() + for (remote in result.strategies) { + val stamp = fresh.strategyUpdatedAt[remote.uuid] ?: 0L + if (stamp > remote.updatedAt) continue + val previous = local.strategies.find { it.uuid == remote.uuid } + if (previous != null && previous == remote && current.containsKey(remote.uuid)) continue + val mode = AccessControlMode.values().find { it.name == remote.mode } ?: AccessControlMode.AcceptAll + current[remote.uuid] = AppsStrategyConfig( + uuid = UUID.fromString(remote.uuid), + name = remote.name.ifBlank { remote.uuid }, + mode = mode, + packages = remote.packages, + ) + state.update { item -> + item.copy(strategyUpdatedAt = item.strategyUpdatedAt + (remote.uuid to remote.updatedAt)) + } + if (previous == null || remote.updatedAt > previous.updatedAt) downloaded += 1 + } + for (id in current.keys.toList()) { + if (id in keep) continue + val stamp = fresh.strategyUpdatedAt[id] ?: 0L + if (stamp > startedAt) continue + current.remove(id) + removed += 1 + state.update { item -> + item.copy(strategyUpdatedAt = item.strategyUpdatedAt - id) + } + } + store.appsStrategyConfigs = current.values.toList() + applyActiveStrategy(store, result, fresh.activeStrategyUpdatedAt) + return downloaded to removed + } + + private fun applyActiveStrategy(store: ServiceStore, result: MergeResult, freshUpdatedAt: Long) { + if (result.activeAppsStrategyUpdatedAt <= freshUpdatedAt) return + store.activeAppsStrategyConfigUuid = result.activeAppsStrategy?.let { runCatching { UUID.fromString(it) }.getOrNull() } + state.update { current -> + if (current.activeStrategyUpdatedAt > result.activeAppsStrategyUpdatedAt) current + else current.copy(activeStrategyUpdatedAt = result.activeAppsStrategyUpdatedAt) + } + } } data class ApplyCounts( diff --git a/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavSync.kt b/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavSync.kt index 2c9c0128d6..a674afc3f6 100644 --- a/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavSync.kt +++ b/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavSync.kt @@ -68,12 +68,21 @@ class WebdavSync( localAccess = local.access, remoteAccess = remote.meta.access(), now = now, + localStrategies = local.strategies, + remoteStrategies = remote.meta.appsStrategies, + localStrategyTombstones = local.strategyTombstones, + remoteStrategyTombstones = remote.meta.appsStrategyTombstones, + localActiveStrategy = local.activeAppsStrategy, + localActiveStrategyUpdatedAt = local.activeAppsStrategyUpdatedAt, + remoteActiveStrategy = remote.meta.activeAppsStrategy, + remoteActiveStrategyUpdatedAt = remote.meta.activeAppsStrategyUpdatedAt, ) - val counts = profiles.apply(merged, local) + val counts = profiles.apply(merged, local, now) val uploaded = if (action == ACTION_PULL) 0 else upload(config, remote, merged) WebdavLocalState(context).update { current -> current.copy( tombstones = mergeTombstones(merged.tombstones + current.tombstones, now), + strategyTombstones = mergeTombstones(merged.strategyTombstones + current.strategyTombstones, now), ) } if (counts.downloaded == 0 && uploaded == 0 && counts.removed == 0) { @@ -138,13 +147,21 @@ class WebdavSync( } val metaChanged = uploaded > 0 || removedRemote > 0 || remote.meta.access() != merged.access || - remote.meta.tombstones != merged.tombstones + remote.meta.tombstones != merged.tombstones || + remote.meta.appsStrategies != merged.strategies || + remote.meta.appsStrategyTombstones != merged.strategyTombstones || + remote.meta.activeAppsStrategy != merged.activeAppsStrategy || + remote.meta.activeAppsStrategyUpdatedAt != merged.activeAppsStrategyUpdatedAt if (metaChanged) { val meta = SyncMetaPayload( updatedAt = System.currentTimeMillis(), accessControlMode = merged.access.mode, accessControlPackages = merged.access.packages, accessControlUpdatedAt = merged.access.updatedAt, + appsStrategies = merged.strategies, + appsStrategyTombstones = merged.strategyTombstones, + activeAppsStrategy = merged.activeAppsStrategy, + activeAppsStrategyUpdatedAt = merged.activeAppsStrategyUpdatedAt, tombstones = merged.tombstones, ) client.put( diff --git a/service/src/test/java/com/github/kr328/clash/service/webdav/WebdavSyncTest.kt b/service/src/test/java/com/github/kr328/clash/service/webdav/WebdavSyncTest.kt index 6422f675e4..2e83bf43c4 100644 --- a/service/src/test/java/com/github/kr328/clash/service/webdav/WebdavSyncTest.kt +++ b/service/src/test/java/com/github/kr328/clash/service/webdav/WebdavSyncTest.kt @@ -179,6 +179,16 @@ class WebdavSyncTest { assertEquals(listOf("com.remote"), merged.access.packages) } + @Test + fun newerAppGroupReplacesPackagesWithoutRemovingTheOtherGroup() { + val local = AppsStrategyPayload("a", "Home", "AcceptSelected", listOf("com.a"), 10) + val other = AppsStrategyPayload("b", "Work", "DenySelected", listOf("com.b"), 10) + val remote = AppsStrategyPayload("a", "Home", "AcceptSelected", listOf("com.a", "com.c"), 20) + val merged = mergeStrategies(listOf(local, other), listOf(remote), emptyList(), emptyList(), 100) + assertEquals(setOf("a", "b"), merged.strategies.map { it.uuid }.toSet()) + assertEquals(listOf("com.a", "com.c"), merged.strategies.first { it.uuid == "a" }.packages) + } + @Test fun rejectsUnsafePathsAndBrokenProfiles() { try { From b07cdf38cde8cd5c07f833b3e462bb6fa193e657 Mon Sep 17 00:00:00 2001 From: clarence Date: Thu, 15 Oct 2026 20:22:40 +0800 Subject: [PATCH 07/10] Add selectable access-control app sets with a live VPN reload, and use LAN WebDAV only after an unauthenticated probe. --- .../kr328/clash/AccessControlActivity.kt | 210 +- .../github/kr328/clash/AccessControlApps.kt | 55 + .../kr328/clash/AppsStrategyActivity.kt | 3 +- .../github/kr328/clash/DomesticAppCatalog.kt | 30 + .../kr328/clash/NetworkSettingsActivity.kt | 3 +- .../kr328/clash/WebdavSettingsActivity.kt | 24 +- .../github/kr328/clash/remote/Broadcasts.kt | 11 + .../java/com/github/kr328/clash/util/Clash.kt | 4 + app/src/main/res/raw/domestic_packages.txt | 1761 +++++++++++++++++ .../main/res/xml/network_security_config.xml | 5 +- .../kr328/clash/common/constants/Intents.kt | 2 + design/build.gradle.kts | 2 + .../kr328/clash/design/AccessControlDesign.kt | 76 +- .../clash/design/WebdavSettingsDesign.kt | 66 +- .../kr328/clash/design/adapter/AppAdapter.kt | 2 + .../clash/design/adapter/AppSetAdapter.kt | 116 ++ .../github/kr328/clash/design/model/AppSet.kt | 48 + .../res/drawable/ic_app_set_indeterminate.xml | 11 + .../res/drawable/ic_baseline_cloud_upload.xml | 10 + .../src/main/res/layout/adapter_app_set.xml | 77 + .../res/layout/adapter_app_set_divider.xml | 7 + .../res/layout/adapter_app_set_header.xml | 17 + design/src/main/res/values-ja-rJP/strings.xml | 36 + design/src/main/res/values-ko-rKR/strings.xml | 36 + design/src/main/res/values-ru/strings.xml | 36 + design/src/main/res/values-vi/strings.xml | 36 + design/src/main/res/values-zh-rHK/strings.xml | 36 + design/src/main/res/values-zh-rTW/strings.xml | 36 + design/src/main/res/values-zh/strings.xml | 24 +- design/src/main/res/values/strings.xml | 24 +- .../design/model/AppSetMembershipTest.kt | 43 + .../kr328/clash/service/ProfileManager.kt | 11 +- .../github/kr328/clash/service/TunService.kt | 50 +- .../service/clash/module/ReloadModule.kt | 21 + .../clash/service/remote/IProfileManager.kt | 10 +- .../clash/service/remote/IWebdavObserver.kt | 8 + .../kr328/clash/service/util/Broadcast.kt | 4 + .../clash/service/webdav/WebdavAutoSync.kt | 2 +- .../clash/service/webdav/WebdavClient.kt | 162 +- .../kr328/clash/service/webdav/WebdavMerge.kt | 55 +- .../clash/service/webdav/WebdavPrivateHttp.kt | 229 +++ .../clash/service/webdav/WebdavProfiles.kt | 11 +- .../clash/service/webdav/WebdavProtocol.kt | 200 +- .../service/webdav/WebdavSettingsStore.kt | 66 +- .../kr328/clash/service/webdav/WebdavSync.kt | 122 +- service/src/main/res/values-zh/strings.xml | 3 + service/src/main/res/values/strings.xml | 3 + .../clash/service/webdav/WebdavClientTest.kt | 210 ++ .../clash/service/webdav/WebdavSyncTest.kt | 205 +- 49 files changed, 4020 insertions(+), 199 deletions(-) create mode 100644 app/src/main/java/com/github/kr328/clash/AccessControlApps.kt create mode 100644 app/src/main/java/com/github/kr328/clash/DomesticAppCatalog.kt create mode 100644 app/src/main/res/raw/domestic_packages.txt create mode 100644 design/src/main/java/com/github/kr328/clash/design/adapter/AppSetAdapter.kt create mode 100644 design/src/main/java/com/github/kr328/clash/design/model/AppSet.kt create mode 100644 design/src/main/res/drawable/ic_app_set_indeterminate.xml create mode 100644 design/src/main/res/drawable/ic_baseline_cloud_upload.xml create mode 100644 design/src/main/res/layout/adapter_app_set.xml create mode 100644 design/src/main/res/layout/adapter_app_set_divider.xml create mode 100644 design/src/main/res/layout/adapter_app_set_header.xml create mode 100644 design/src/test/java/com/github/kr328/clash/design/model/AppSetMembershipTest.kt create mode 100644 service/src/main/java/com/github/kr328/clash/service/clash/module/ReloadModule.kt create mode 100644 service/src/main/java/com/github/kr328/clash/service/remote/IWebdavObserver.kt create mode 100644 service/src/main/java/com/github/kr328/clash/service/webdav/WebdavPrivateHttp.kt create mode 100644 service/src/test/java/com/github/kr328/clash/service/webdav/WebdavClientTest.kt diff --git a/app/src/main/java/com/github/kr328/clash/AccessControlActivity.kt b/app/src/main/java/com/github/kr328/clash/AccessControlActivity.kt index 1ff96faebe..e471e2f593 100644 --- a/app/src/main/java/com/github/kr328/clash/AccessControlActivity.kt +++ b/app/src/main/java/com/github/kr328/clash/AccessControlActivity.kt @@ -1,31 +1,39 @@ package com.github.kr328.clash -import android.Manifest.permission.INTERNET import android.content.ClipData import android.content.ClipboardManager -import android.content.pm.ApplicationInfo -import android.content.pm.PackageInfo -import android.content.pm.PackageManager +import android.content.Context +import android.os.Bundle +import androidx.activity.addCallback import androidx.core.content.getSystemService +import com.github.kr328.clash.common.Global +import com.github.kr328.clash.common.log.Log import com.github.kr328.clash.common.util.uuid import com.github.kr328.clash.design.AccessControlDesign import com.github.kr328.clash.design.model.AppInfo -import com.github.kr328.clash.design.util.toAppInfo import com.github.kr328.clash.service.model.AppsStrategyConfig import com.github.kr328.clash.service.store.ServiceStore import com.github.kr328.clash.service.webdav.WebdavAutoSync import com.github.kr328.clash.service.webdav.WebdavSettingsStore import com.github.kr328.clash.service.webdav.WebdavSync -import com.github.kr328.clash.util.startClashService +import com.github.kr328.clash.util.requestClashReload import com.github.kr328.clash.util.withProfile -import com.github.kr328.clash.util.stopClashService +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch import kotlinx.coroutines.selects.select import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull class AccessControlActivity : BaseActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + onBackPressedDispatcher.addCallback(this) { + finish() + } + } + override suspend fun main() { val configUuid = intent.uuid val service = ServiceStore(this) @@ -44,52 +52,13 @@ class AccessControlActivity : BaseActivity() { } defer { - withContext(Dispatchers.IO) { - val changed = if (config != null) { - val packagesChanged = selected != config.packages.toSet() - if (packagesChanged) { - val updatedConfig = AppsStrategyConfig( - uuid = config.uuid, - name = config.name, - mode = config.mode, - packages = selected.toList() - ) - val configs = service.appsStrategyConfigs.map { - if (it.uuid == config.uuid) updatedConfig else it - } - service.appsStrategyConfigs = configs - WebdavAutoSync.noteAppsStrategy(this@AccessControlActivity, config.uuid) - } - packagesChanged - } else { - val packagesChanged = selected != service.accessControlPackages - service.accessControlPackages = selected - if (packagesChanged) { - WebdavAutoSync.noteAccessControl(this@AccessControlActivity) - } - if (clashRunning && packagesChanged) { - stopClashService() - while (clashRunning) { - delay(200) - } - startClashService() - } - packagesChanged - } - if (changed) { - val settings = WebdavSettingsStore(this@AccessControlActivity).load() - if (settings.autoSync && settings.url.isNotBlank() && settings.username.isNotBlank() && settings.password.isNotEmpty()) { - withProfile { - webdavPerform( - WebdavSync.ACTION_SYNC, - settings.url, - settings.username, - settings.password, - settings.remotePath, - ) - } - } - } + val running = clashRunning + val packages = selected.toSet() + val action = withContext(Dispatchers.IO) { + persistPackages(service, config, packages, running) + } + if (action.restartVpn || action.sync) { + scheduleExitWork(action) } } @@ -107,7 +76,9 @@ class AccessControlActivity : BaseActivity() { design.requests.onReceive { when (it) { AccessControlDesign.Request.ReloadApps -> { - design.patchApps(loadApps(selected)) + val loaded = loadApps(selected) + design.patchApps(loaded.apps) + design.patchBuiltinSets(loaded.systemPackages, loaded.domesticPackages) } AccessControlDesign.Request.SelectAll -> { @@ -169,40 +140,109 @@ class AccessControlActivity : BaseActivity() { } } - private suspend fun loadApps(selected: Set): List = - withContext(Dispatchers.IO) { - val reverse = uiStore.accessControlReverse - val sort = uiStore.accessControlSort - val systemApp = uiStore.accessControlSystemApp - - val base = compareByDescending { it.packageName in selected } - val comparator = if (reverse) base.thenDescending(sort) else base.then(sort) - - val pm = packageManager - val packages = pm.getInstalledPackages(PackageManager.GET_PERMISSIONS) - - packages.asSequence() - .filter { - it.packageName != packageName - } - .filter { - it.applicationInfo != null + private fun persistPackages( + service: ServiceStore, + config: AppsStrategyConfig?, + packages: Set, + clashRunning: Boolean, + ): ExitAction { + val packagesChanged = if (config != null) { + val changed = packages != config.packages.toSet() + if (changed) { + val updatedConfig = AppsStrategyConfig( + uuid = config.uuid, + name = config.name, + mode = config.mode, + packages = packages.toList(), + ) + service.appsStrategyConfigs = service.appsStrategyConfigs.map { + if (it.uuid == config.uuid) updatedConfig else it } - .filter { - it.requestedPermissions?.contains(INTERNET) == true || it.applicationInfo!!.uid < android.os.Process.FIRST_APPLICATION_UID - } - .filter { - systemApp || !it.isSystemApp + WebdavAutoSync.noteAppsStrategy(this, config.uuid) + } + changed + } else { + val changed = packages != service.accessControlPackages + if (changed) { + service.accessControlPackages = packages + WebdavAutoSync.noteAccessControl(this) + } + changed + } + if (!packagesChanged) return ExitAction(restartVpn = false, sync = false) + val activeId = service.activeAppsStrategyConfigUuid + val active = activeId?.let { id -> service.appsStrategyConfigs.find { it.uuid == id } } + val drivesVpn = if (config != null) active?.uuid == config.uuid else active == null + return ExitAction(restartVpn = clashRunning && drivesVpn, sync = true) + } + + private fun scheduleExitWork(action: ExitAction) { + val app = applicationContext + Global.launch(Dispatchers.Main) { + if (action.restartVpn) { + try { + restartClash(app) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w("Restart after access control change: ${e.message}", e) } - .map { - it.toAppInfo(pm) + } + if (action.sync) { + try { + syncAccessPackages(app) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w("WebDAV after access control change: ${e.message}", e) } - .sortedWith(comparator) - .toList() + } + } + } + + private fun restartClash(app: Context) { + Log.i("Reload tun after access control change") + app.requestClashReload() + } + + private suspend fun syncAccessPackages(app: Context) { + val settings = WebdavSettingsStore(app).load() + if (!settings.autoSync || !settings.hasEndpoint() || settings.username.isBlank() || settings.password.isEmpty()) { + return + } + withTimeoutOrNull(SYNC_TIMEOUT_MS) { + withProfile { + webdavPerform( + WebdavSync.ACTION_SYNC, + settings.url, + settings.lanUrl, + settings.username, + settings.password, + settings.remotePath, + ) + } } + } + + private data class ExitAction( + val restartVpn: Boolean, + val sync: Boolean, + ) - private val PackageInfo.isSystemApp: Boolean - get() { - return applicationInfo?.flags?.and(ApplicationInfo.FLAG_SYSTEM) != 0 + private suspend fun loadApps(selected: Set): AccessControlApps = + withContext(Dispatchers.IO) { + loadAccessControlApps( + pm = packageManager, + selfPackage = packageName, + showSystem = uiStore.accessControlSystemApp, + selected = selected, + sort = uiStore.accessControlSort, + reverse = uiStore.accessControlReverse, + domesticCatalog = DomesticAppCatalog.packages(this@AccessControlActivity), + ) } + + private companion object { + const val SYNC_TIMEOUT_MS = 60_000L + } } \ No newline at end of file diff --git a/app/src/main/java/com/github/kr328/clash/AccessControlApps.kt b/app/src/main/java/com/github/kr328/clash/AccessControlApps.kt new file mode 100644 index 0000000000..8201e38541 --- /dev/null +++ b/app/src/main/java/com/github/kr328/clash/AccessControlApps.kt @@ -0,0 +1,55 @@ +package com.github.kr328.clash + +import android.Manifest +import android.content.pm.ApplicationInfo +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.os.Process +import com.github.kr328.clash.design.model.AppInfo +import com.github.kr328.clash.design.model.AppInfoSort +import com.github.kr328.clash.design.util.toAppInfo + +internal data class AccessControlApps( + val apps: List, + val systemPackages: Set, + val domesticPackages: Set, +) + +/** + * System and domestic sets ignore the "hide system apps" filter. + * That filter only changes which rows are visible. + */ +internal fun loadAccessControlApps( + pm: PackageManager, + selfPackage: String, + showSystem: Boolean, + selected: Set, + sort: AppInfoSort, + reverse: Boolean, + domesticCatalog: Set, +): AccessControlApps { + val base = compareByDescending { it.packageName in selected } + val comparator = if (reverse) base.thenDescending(sort) else base.then(sort) + + val systemPackages = mutableSetOf() + val domesticPackages = mutableSetOf() + val visible = mutableListOf() + + for (pkg in pm.getInstalledPackages(PackageManager.GET_PERMISSIONS)) { + if (pkg.packageName == selfPackage) continue + val info = pkg.applicationInfo ?: continue + val hasInternet = pkg.requestedPermissions?.contains(Manifest.permission.INTERNET) == true + if (!hasInternet && info.uid >= Process.FIRST_APPLICATION_UID) continue + + val system = info.flags.and(ApplicationInfo.FLAG_SYSTEM) != 0 + if (system) systemPackages.add(pkg.packageName) + if (pkg.packageName in domesticCatalog) domesticPackages.add(pkg.packageName) + if (showSystem || !system) visible.add(pkg) + } + + return AccessControlApps( + apps = visible.map { it.toAppInfo(pm) }.sortedWith(comparator), + systemPackages = systemPackages, + domesticPackages = domesticPackages, + ) +} diff --git a/app/src/main/java/com/github/kr328/clash/AppsStrategyActivity.kt b/app/src/main/java/com/github/kr328/clash/AppsStrategyActivity.kt index ff12a2043f..dc6ffd46e1 100644 --- a/app/src/main/java/com/github/kr328/clash/AppsStrategyActivity.kt +++ b/app/src/main/java/com/github/kr328/clash/AppsStrategyActivity.kt @@ -167,13 +167,14 @@ class AppsStrategyActivity : BaseActivity() { private suspend fun syncIfEnabled() { val settings = WebdavSettingsStore(this).load() - if (!settings.autoSync || settings.url.isBlank() || settings.username.isBlank() || settings.password.isEmpty()) { + if (!settings.autoSync || !settings.hasEndpoint() || settings.username.isBlank() || settings.password.isEmpty()) { return } withProfile { webdavPerform( WebdavSync.ACTION_SYNC, settings.url, + settings.lanUrl, settings.username, settings.password, settings.remotePath, diff --git a/app/src/main/java/com/github/kr328/clash/DomesticAppCatalog.kt b/app/src/main/java/com/github/kr328/clash/DomesticAppCatalog.kt new file mode 100644 index 0000000000..757dfc56b6 --- /dev/null +++ b/app/src/main/java/com/github/kr328/clash/DomesticAppCatalog.kt @@ -0,0 +1,30 @@ +package com.github.kr328.clash + +import android.content.Context + +/** + * Package names of apps commonly distributed on Chinese app stores. + * The built-in set is dynamic: only names that are installed on this device are members. + */ +internal object DomesticAppCatalog { + private val packageName = Regex("""^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z0-9_]+)+$""") + + @Volatile + private var cached: Set? = null + + fun packages(context: Context): Set { + cached?.let { return it } + return synchronized(this) { + cached ?: load(context).also { cached = it } + } + } + + private fun load(context: Context): Set { + return context.resources.openRawResource(R.raw.domestic_packages).bufferedReader().use { reader -> + reader.lineSequence().mapNotNull { line -> + val pkg = line.substringBefore('#').trim() + pkg.takeIf { packageName.matches(it) } + }.toSet() + } + } +} diff --git a/app/src/main/java/com/github/kr328/clash/NetworkSettingsActivity.kt b/app/src/main/java/com/github/kr328/clash/NetworkSettingsActivity.kt index 6503c11a8e..304a5d0150 100644 --- a/app/src/main/java/com/github/kr328/clash/NetworkSettingsActivity.kt +++ b/app/src/main/java/com/github/kr328/clash/NetworkSettingsActivity.kt @@ -47,7 +47,7 @@ class NetworkSettingsActivity : BaseActivity() { private suspend fun syncAccessControl(design: NetworkSettingsDesign) { WebdavAutoSync.noteAccessControl(this) val settings = WebdavSettingsStore(this).load() - if (!settings.autoSync || settings.url.isBlank() || settings.username.isBlank() || settings.password.isEmpty()) { + if (!settings.autoSync || !settings.hasEndpoint() || settings.username.isBlank() || settings.password.isEmpty()) { return } try { @@ -55,6 +55,7 @@ class NetworkSettingsActivity : BaseActivity() { webdavPerform( WebdavSync.ACTION_SYNC, settings.url, + settings.lanUrl, settings.username, settings.password, settings.remotePath, diff --git a/app/src/main/java/com/github/kr328/clash/WebdavSettingsActivity.kt b/app/src/main/java/com/github/kr328/clash/WebdavSettingsActivity.kt index 346e76f3e5..43359f22c7 100644 --- a/app/src/main/java/com/github/kr328/clash/WebdavSettingsActivity.kt +++ b/app/src/main/java/com/github/kr328/clash/WebdavSettingsActivity.kt @@ -9,6 +9,7 @@ import com.github.kr328.clash.service.webdav.WebdavSettingsStore import com.github.kr328.clash.service.webdav.WebdavSync import com.github.kr328.clash.util.withProfile import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch import kotlinx.coroutines.selects.select class WebdavSettingsActivity : BaseActivity() { @@ -33,6 +34,7 @@ class WebdavSettingsActivity : BaseActivity() { WebdavSettingsDesign.Request.Test -> WebdavSync.ACTION_TEST WebdavSettingsDesign.Request.Sync -> WebdavSync.ACTION_SYNC WebdavSettingsDesign.Request.Pull -> WebdavSync.ACTION_PULL + WebdavSettingsDesign.Request.Push -> WebdavSync.ACTION_PUSH } runAction(design, store, action) } @@ -49,15 +51,19 @@ class WebdavSettingsActivity : BaseActivity() { busy = true try { val settings = store.load() - design.showToast(DesignR.string.webdav_working, ToastDuration.Short) - val message = withProfile { - webdavPerform( - action, - settings.url, - settings.username, - settings.password, - settings.remotePath, - ) + val message = design.withProgress { update -> + withProfile { + webdavPerform( + action, + settings.url, + settings.lanUrl, + settings.username, + settings.password, + settings.remotePath, + ) { phase, current, total, label -> + design.launch { update(phase, current, total, label) } + } + } } design.setStatus(message) design.showToast(message, ToastDuration.Long) diff --git a/app/src/main/java/com/github/kr328/clash/remote/Broadcasts.kt b/app/src/main/java/com/github/kr328/clash/remote/Broadcasts.kt index 9be7026bf5..94fac4000b 100644 --- a/app/src/main/java/com/github/kr328/clash/remote/Broadcasts.kt +++ b/app/src/main/java/com/github/kr328/clash/remote/Broadcasts.kt @@ -5,9 +5,11 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter +import android.widget.Toast import com.github.kr328.clash.common.compat.registerReceiverCompat import com.github.kr328.clash.common.constants.Intents import com.github.kr328.clash.common.log.Log +import com.github.kr328.clash.design.R as DesignR import java.util.* class Broadcasts(private val context: Application) { @@ -72,6 +74,14 @@ class Broadcasts(private val context: Application) { it.onProfileLoaded() } } + Intents.ACTION_CLASH_RELOAD_REJECTED -> { + val app = this@Broadcasts.context + Toast.makeText( + app, + app.getString(DesignR.string.access_control_reload_rejected), + Toast.LENGTH_LONG, + ).show() + } } } } @@ -97,6 +107,7 @@ class Broadcasts(private val context: Application) { addAction(Intents.ACTION_PROFILE_UPDATE_COMPLETED) addAction(Intents.ACTION_PROFILE_UPDATE_FAILED) addAction(Intents.ACTION_PROFILE_LOADED) + addAction(Intents.ACTION_CLASH_RELOAD_REJECTED) }) clashRunning = StatusClient(context).currentProfile() != null diff --git a/app/src/main/java/com/github/kr328/clash/util/Clash.kt b/app/src/main/java/com/github/kr328/clash/util/Clash.kt index 777fb12d99..b700febf03 100644 --- a/app/src/main/java/com/github/kr328/clash/util/Clash.kt +++ b/app/src/main/java/com/github/kr328/clash/util/Clash.kt @@ -29,4 +29,8 @@ fun Context.startClashService(): Intent? { fun Context.stopClashService() { sendBroadcastSelf(Intent(Intents.ACTION_CLASH_REQUEST_STOP)) +} + +fun Context.requestClashReload() { + sendBroadcastSelf(Intent(Intents.ACTION_CLASH_REQUEST_RELOAD)) } \ No newline at end of file diff --git a/app/src/main/res/raw/domestic_packages.txt b/app/src/main/res/raw/domestic_packages.txt new file mode 100644 index 0000000000..32acf8a957 --- /dev/null +++ b/app/src/main/res/raw/domestic_packages.txt @@ -0,0 +1,1761 @@ +# Domestic apps commonly distributed on Chinese app stores. +# Package names are a snapshot of mnixry/direct-android-ruleset +# rules/@Merged/APP on 2026-10-01, plus a few widely used packages +# that snapshot did not include. Lines are `package # name`. +# The app set matches these names against apps installed on the device. +ai.zuoye.app # 爱作业 +aihuishou.aihuishouapp # 爱回收 +air.com.bbfriend.ddsz # 哆哆识字 +air.com.gongfubb.wksz # 悟空识字 +air.tv.douyu.android # 斗鱼 +all.parttimeguidesystem # 兼职猫-同城找工作求职招聘平台 +android.translate.xuedianba # 同声翻译超级版 +android.zhibo8 # 直播吧 +app.Artronauction # 雅昌拍卖图录 +az.studio.gkztc # 高考直通车 +battymole.trainticket # 买火车票 +bin.mt.plus # MT管理器 +bj.wanquan.hanfeislidecompact # 汉飞滑动输入法大屏手机版 +bubei.tingshu # 懒人听书-QQ音乐听书版 +bzjh.three.com # 神图壁纸 +cc.rs.gc # GG租号 +cc.rs.rrzh # 人人租号 +ch999.app.UI # 九机网 +cmb.pb # 招商银行 +cmccwm.mobilemusic # 咪咕音乐 +cn.banshenggua.aichang # 爱唱 +cn.bdqn.pictureview # 手机图库 +cn.bluecrane.album # 相册管家 +cn.bluecrane.calendar # 万年历黄历 +cn.buding.martin # 微车 +cn.caocaokeji.user # 曹操出行 +cn.cbct.seefm # 听见广播 +cn.cisc # 中金财富 +cn.cj.pe # 139邮箱 +cn.cntv # 央视影音 +cn.cntvhd # 央视影音HD +cn.cntvnews # 央视新闻 +cn.colorv # 彩视 +cn.com.bmac.nfc +cn.com.cmbc.newmbank # 民生银行 +cn.com.enorth.jinyun # 津云 +cn.com.gjzq.yjb.kh # 国金证券开户 +cn.com.gxrb.client # 广西云 +cn.com.htjc.pay # 电e宝 +cn.com.modernmedia.modernlady # InStyle iLady +cn.com.nbd.nbdmobile # 每日经济新闻 +cn.com.njxmxbank.mbank # 南银法巴消金 +cn.com.open.mooc # 慕课网 +cn.com.pansky.sxzgyl # 陕西社会保险 +cn.com.pcauto.android.browser # 太平洋汽车 +cn.com.pcgroup.magazine # PChouse太平洋家居 +cn.com.pconline.android.browser # 太平洋科技 +cn.com.qlwb.qiluyidian # 齐鲁壹点 +cn.com.shbank.mper # 上海银行 +cn.com.sina.finance # 新浪财经 +cn.com.sina.sports # 新浪体育 +cn.com.spdb.mobilebank.per # 浦发银行 +cn.com.tx.chat.android # 天下网 +cn.com.wiisoft.glass # 灵动液态玻璃 +cn.com.wiisoft.skin # 灵动主题皮肤 +cn.com.zjol # 潮新闻 +cn.dahebao # 顶端新闻 +cn.damai # 大麦 +cn.danatech.xingseapp # 形色 +cn.dict.android.pro # 海词词典 +cn.dictcn.android.digitize.wys_korean # 外研社韩语 +cn.dxy.android.aspirin # 丁香医生 +cn.dxy.idxyer # 丁香园 +cn.dxy.medicinehelper # 用药助手 +cn.eclicks.drivingtest # 车轮驾考通 +cn.eclicks.wzsearch # 车轮 +cn.ecook # 网上厨房 +cn.edaijia.android.client # e代驾 +cn.edu.zjicm.wordsnet_d # 知米背单词 +cn.egame.terminal.cloud5g # 天翼云游戏 +cn.emagsoftware.gamehall # 咪咕游戏 +cn.emoney.level2 # 益盟操盘手经典版 +cn.etouch.ecalendar # 中华万年历 +cn.gov.pbc.dcep # 数字人民币 +cn.gov.tax.its # 个人所得税 +cn.guancha.app # 观察者网 +cn.gzmovement # 动静新闻 +cn.hsa.app # 国家医保服务平台 +cn.huntlaw.android # 好律师 +cn.ibabyzone.music # 胎教盒子 +cn.idashi.app # 大师一百 +cn.ishansong # 闪送 +cn.itheme.tech # 手机主题 +cn.izdax.ime # izdax输入法 +cn.jingling.motu.photowonder # 魔图精灵 +cn.jjpengcheng.wallpaper # 动态壁纸 +cn.jsb.china # 江苏银行 +cn.kaoshi100.view # 考试100 +cn.knet.eqxiu # 易企秀 +cn.kuwo.kwmusichd # 酷我音乐HD +cn.kuwo.player # 酷我音乐 +cn.ledongli.ldl # 乐动力 +cn.longmaster.pengpeng # 语玩 +cn.lrapps.changliaocall # 畅聊网络电话 +cn.mama.activity # 妈妈网轻聊 +cn.microants.merchants # 义采宝义乌小商品批发网 +cn.mimilive.xianyu # 爆聊 +cn.mopon.film.xflh # 幸福看 +cn.mucang.android.jiakao.uygur # 驾考宝典维语版 +cn.mucang.android.jkxt # 驾考宝典多语言版 +cn.mucang.android.parallelvehicle # 平行之家 +cn.mucang.android.parallelvehicle.manager # 平行之家商家版 +cn.mucang.android.qichetoutiao # 车友头条 +cn.mucang.drunkremind.android # 小猪二手车 +cn.myhug.baobao # 抱抱直播 +cn.ninegame.gamemanager # 九游 +cn.nokia.speedtest5g # speedtest5g +cn.opda.a.phonoalbumshoushou # 百度手机卫士 +cn.poco.jane # 简拼 +cn.poco.pMix # 图片合成器 +cn.rainbow.westore # 天虹 +cn.samsclub.app # 山姆会员商店 +cn.shengmimi.callshow # 视频铃声 +cn.shuangshuangfei # 找对象 +cn.soulapp.android # Soul +cn.szjxgs.szjob # 工友通 +cn.tianya.light # 天涯社区 +cn.vcinema.cinema # 南瓜电影 +cn.wangxiao.cpazhuntiku # 注册会计师准题库 +cn.wangxiao.gwyzhuntiku # 公务员准题库 +cn.weli.sweet # CUCU +cn.wenyu.bodian # 波点音乐 +cn.wiz.note # 为知笔记 +cn.wondershare.filmorago # 万兴喵影 +cn.wps.moffice_eng # WPS Office +cn.wsds.gamemaster # 迅游手游加速器 +cn.xiaochuankeji.tieba # 最右 +cn.xiaofengkj.fitpro # FitPro +cn.xuexi.android # 学习强国 +cn.yonghui.hyd # 永辉线上超市 +cn.youth.news # 中青看点 +cn.zepeto.main # 崽崽ZEPETO +cn.zymk.comic # 知音漫客 +co.runner.app # 悦跑圈 +co.welab.wolaidai # 我来数科 +com.A17zuoye.mobile.homework # 一起作业 +com.able.wisdomtree # 知到 +com.acadsoc.talkshow # 阿卡索英语 +com.achievo.vipshop # 唯品会 +com.actionsmicro.ezcast # EZCast +com.ada.desks # 安卓桌面 +com.admin.dingdongphone # 叮咚网络电话 +com.adnonstop.mancamera2017 # 型影 +com.adobe.reader # PDF阅读器 Adobe reader +com.advasoft.touchretouch # TouchRetouch +com.aheading.news.lanzhourb # 奔流新闻 +com.ahnews.newsclient # 中安在线 +com.ai.marki # 马克水印相机 +com.aikan # 免费小说大全 +com.aiwu.market # 爱吾游戏宝盒 +com.alarmclock # 闹钟 +com.alibaba.ailabs.tg # 天猫精灵 +com.alibaba.android.rimet # 钉钉 +com.alibaba.intl.android.apps.poseidon # Alibaba.com +com.alibaba.wireless # 阿里巴巴 +com.alicloud.databox # 阿里云盘 +com.alicom.smartdial +com.alimama.moon # 淘宝联盟 +com.aliyun.tongyi # 千问 +com.ancda.parents # 掌心宝贝 +com.android.abustory # 阿布睡前故事 +com.android.app.quanmama # 券妈妈优惠券 +com.android.autoAirPlaneMode # 自动飞行模式 +com.android.bankabc # 农行掌上银行 +com.android.benlailife.activity # 本来生活 +com.android.clock.sd # 山东农信 +com.android.dazhihui # 大智慧 +com.android.haitong # 国泰海通通财 +com.android.icredit # 企查查 +com.android.tutuerge # 兔兔儿歌 +com.android36kr.app # 36氪 +com.Android56 # 56视频 +com.androidesk # 安卓壁纸 +com.androidesk.livewallpaper # 安卓动态壁纸 +com.anhuioss.simpleime # Simple IME +com.anjiu.buff.yingyongbao # BUFF手游 +com.anjuke.android.app # 安居客 +com.ants360.yicamera # 小蚁摄像机 +com.antutu.ABenchMark # 安兔兔评测 +com.antutu.benchmark.full # 安兔兔评测3D +com.anwei.meivliao # 美V聊天 +com.app.dzy.btchat # 蓝牙闲聊 +com.app.hero.ui # K歌达人 +com.app.ymqzy # 动态壁纸秀场 +com.appbyme.app71974 # 编织人生论坛 +com.appchina.anpai.2019071601 # 鸟类壁纸 +com.appchina.anpai.20210203003 # 什么时间 - 翻转时钟桌面小组件Widget +com.appcina.anpai.2019080818 # 克拉壁纸 +com.appcina.anpai.2019092902 # 落格输入法 +com.appcina.anpai.2019112101 # Artpaper:有 1,300 多张艺术画壁纸可每日更换 +com.appcina.anpai.2019112901 # Hola Px - 我的专属精选壁纸专家 +com.appcina.anpai.2019122013 # WLPPR - background wallpapers +com.appcina.anpai.2020011202 # Berkanan Messenger +com.apprush.game.chinesechar # 汉语字典 +com.appshare.android.ilisten # 口袋故事 +com.appshare.android.ilisten.hd # 口袋故事HD +com.appsino.bingluo.fycz # 移动公证 +com.appsys.goodname # 易经起名取名 +com.Apricotforest_epocket # 医口袋 +com.artron.toutiao # 艺术头条 +com.autohome.usedcar # 二手车之家-看车选车买车卖车 +com.autonavi.minimap # 高德地图 +com.babycloud.hanju # 韩小圈 +com.babytree.apps.lama # 宝宝树小时光 +com.babytree.apps.pregnancy # 宝宝树孕育 +com.baidu.appsearch # 百度手机助手 +com.baidu.BaiduMap # 百度地图 +com.baidu.baidutranslate # 百度翻译 +com.baidu.browser.apps # 百度浏览器 +com.baidu.haokan # 好看视频 +com.baidu.hi # 如流 +com.baidu.homework # 作业帮 +com.baidu.iknow # 百度知道 +com.baidu.input # 百度输入法 +com.baidu.input_mi # 百度输入法小米版 +com.baidu.lbs.crowdapp # 地图淘金 +com.baidu.netdisk # 百度网盘 +com.baidu.newapp # 文心一言 +com.baidu.searchbox # 百度APP +com.baidu.searchbox.lite # 百度极速版 +com.baidu.tieba # 百度贴吧 +com.baidu.video # 百搜视频 +com.baidu.video.pad # 百搜视频HD +com.baidu.wenku # 百度文库 +com.baidu.youavideo # 一刻相册 +com.baidu.yuedu # 百度阅读 +com.baihe # 百合网 +com.baijiu.bizhi.sjbixiaz # Wallpaper +com.baile.shanduo # 闪聊闪约 +com.bankcomm.Bankcomm # 交通银行 +com.bankcomm.maidanba # 买单吧 +com.baojiazhijia.qichebaojia # 买车宝典 +com.baoruan.theme.mhczpffedYqofhovJAD # 古剑奇谭之风晴雪-3D桌面主题 +com.baoruan.vmiic # 主题商店 +com.baoruan.wallpaper.lmaczcgcdjTJGknirRd # 绚丽水晶-桌面壁纸 +com.baoyun.babycloud # 口袋宝宝 +com.barmak.client.fast # BARMAK维语输入法 +com.bbbao.shop.client.android.activity # 比比宝 +com.bbk.appstore # vivo应用商店 +com.bbqk.quietlycall # 免费电话宝 +com.bdatu.geography # 每日精选 +com.bench.yylc # 盈盈 +com.benqu.wuta # 无他相机 +com.bertadata.qxb # 启信宝 +com.bfire.da.nui # 微分身双开 +com.bilibili.app.blue # 哔哩哔哩概念 +com.bilibili.app.in +com.bilibili.comic # 哔哩哔哩漫画 +com.bilibili.studio # 必剪 +com.biligyar.izdax # izdax翻译 +com.bingo.yeliao # 十点聊天 +com.bjbyhd.voiceback # 保益悦听 +com.bjut.cyl.findnurse # 金牌护士 +com.black.unique # 全球购骑士特权 +com.blink.academy.nomo # NOMO CAM +com.bly.dkplat # 小X分身 +com.boc.bocsoft.phone.enterprise # 中行企业银行 +com.bokecc.dance # 糖豆 +com.boniu.saomiaoquannengwang # 扫描转文字王 +com.boohee.one # 薄荷健康 +com.booking # Booking.com缤客 +com.bowerswilkins.splice # Music Bowers and Wilkins +com.brains.byzmnew # 益智脑筋急转弯大全 +com.browser2345 # 2345浏览器 +com.btime.bjtime # 北京时间 +com.budejie.www # 百思漂流瓶 +com.businessvalue.android.app # 钛媒体 +com.by.butter.camera # 黄油相机 +com.bytedance.android.doubaoime # 豆包输入法 +com.bytedance.lark +com.bz.xiu # 壁纸秀秀 +com.bz.ziti.diy # paper壁纸大全 +com.c2vl.kgamebox # 狼人杀 +com.caijing # 财经杂志 +com.caing.news # 财新 +com.cainiao.wireless # 菜鸟 +com.caishi.cronus # 唔哩头条 +com.call.myyb # 满意网络电话 +com.campmobile.snowcamera # B612咔叽 +com.capelabs.leyou # 乐友 +com.cat.readall # 悟空浏览器 +com.cbnweekly # 第一财经杂志 +com.ccb.longjiLife # 建行生活 +com.ccgs.sms # 短信群发助手 +com.cctir.huinongbao # 惠农网 +com.cctv.caijing # 央视财经 +com.cctv.cctv5ultimate # 央视体育 +com.cctv.yangshipin.app.androidp # 央视频 +com.cdb.app # 充电加速器 +com.cdcd.milv # 觅侣 +com.cdsb.newsreader # 红星新闻 +com.cdtv.app # 看度新闻 +com.cdv.video360 # 云美摄 +com.cebbank.mobile.cemb # 光大银行 +com.ceic.app # 地震速报 +com.cffex.htqh # 海通期货期海通行 +com.cfy.xqwxt # 象棋微学堂 +com.cgbchina.xpt # 广发银行 +com.chan.cwallpaper # 图凌 +com.changba # 唱吧 +com.changshuo.ui # 畅说108 +com.chaoxing.mobile # 学习通 +com.chaozh.iReader.dj # 得间免费小说 +com.chaozh.iReaderFree # 掌阅 +com.chaozh.iReaderFree15 # 油梨 +com.chebada # 巴士管家 +com.chenai.eyes # 夜间模式 +com.chenai.ieyes # 夜间护眼 +com.chenyang.wzzyy # 文字转语音 +com.china3s.android # 春秋航空 +com.chinaamc.MainActivityAMC # 华夏基金管家 +com.chinaamc.mfbh.amcActivity # 华夏活期通 +com.chinablue.tv # Z视介 +com.chinaideal.bkclient.tabmain # 你我嘉选 +com.chinalife.ebz # 中国人寿寿险 +com.chinamobile.mcloud # 中国移动云盘 +com.chinamworld.bocmbci # 中国银行 +com.chinamworld.main # 中国建设银行 +com.chinaso.toutiao # 中国搜索 +com.chinasoft.mall # 好享购物 +com.chinatelecom.bestpayclient # 翼支付 +com.chineseall.singlebook # 中文书城 +com.chipsea.btcontrol # OKOK +com.ci123.pregnancywap # 妈妈社区 +com.cib.cibmb # 兴业银行 +com.cib.xyk # 兴业生活 +com.cio.project # 通信助手 +com.citiccard.mobilebank # 动卡空间 +com.cj.yun.esz # 云上恩施 +com.cleanmaster.mguard_cn # 猎豹清理大师 +com.cliff # 藏书馆 +com.clipzz.media # 视频剪辑精灵 +com.cloudpower.netsale.activity # 中国人保 +com.clov4r.android.nil # MoboPlayer +com.clover.daysmatter # 倒数日 +com.cmbc.cc.mbank # 全民生活 +com.cmbchina.ccd.pluto.cmbActivity # 掌上生活 +com.cmcc.cmvideo # 咪咕视频 +com.cmcc.hebao # 和包 +com.cmcm.cfwallpaper # 元气桌面壁纸 +com.cmi.jegotrip # 无忧行 +com.cmri.universalapp # 移动爱家 +com.cmschina.stock # 招商证券 +com.cmstop.zswz # 掌上温州 +com.cns.mc.activity # 中国新闻网 +com.codoon.gps # 咕咚 +com.color.by.coloring.wallpaper.android # 涂个壁纸 +com.comic.isaman # 飒漫画 +com.comic.manhuatai # 漫画台 +com.community.cape # 海角社区 +com.cooby.news # 华人头条 +com.coolapk.market # 酷安 +com.cootek.crazyreader # 疯读小说 +com.cpic.maker # 太享贷 +com.cpic.sxbxxe # 太平洋保险 +com.creditwealth.client # 宜人优选 +com.crv.wanjia # 华润万家 +com.csair.mbp # 南方航空 +com.csii.changjiang.mobilebank # 江苏长江商业银行 +com.csii.gdnx.mobilebank # 广东农信 +com.csii_qzbank # 泉州银行 +com.csztv.yyk # 看苏州 +com.ct.client # 中国电信 +com.cubic.autohome # 汽车之家 +com.cubic.choosecar # 汽车报价 +com.cutv.mobile.zsntclient # 掌上南通 +com.cxzapp.yidianling # 壹点灵 +com.cyjh.ddyun # 多多云手机 +com.dachen.medicalcircle # 医生圈 +com.dada.kdb123 # 快递短信宝 +com.dada.wallpaper # 壁纸神器 +com.dailyyoga.cn # 每日瑜伽 +com.dalongtech.cloud # 云电脑 +com.dangdang.buy2 # 当当 +com.dangdang.reader # 当当云阅读 +com.danielstudio.app.wowtu # 煎蛋 +com.daofeng.zuhaowan # 租号玩 +com.daqo.wallpaper # 多多壁纸铃声 +com.datayes.irr # 萝卜投研 +com.dazhuanjia.dcloud # 大专家 +com.ddmh.livewallpaper # 动态壁纸秀 +com.ddnapalon.calculator.gp # 计算器语音大师 +com.dealmoon.android # 北美省钱快报 +com.dedao.juvenile # 少年得到 +com.deepseek.chat # DeepSeek +com.desktop.pendant # 银河壁纸多多 +com.devuni.flashlight # 超级手电筒 +com.dewmobile.kuaiya # 快牙 +com.deyi.client # 得意生活 +com.dianping.v1 # 大众点评 +com.dida.help # 滴答滴 +com.didago.dida # 滴答滴顺风车 +com.didago.travel # 滴答滴出行 +com.didapinche.booking # 嘀嗒出行 +com.didikee.gifparser # GIF助手 +com.diershoubing.erbing # 二柄 +com.digitalgd.dgyss # 粤省事 +com.diguayouxi # 当乐 +com.dingchebao # 订车宝 +com.dingtai.wxhn.activity # 新湖南 +com.dingYZ.dingyzwater # 微脉水印相机 +com.dinoenglish.yyb # E英语宝 +com.dipiapp.pcall # 网络电话 +com.diyidan # 第一弹 +com.djbox.product # DJ音乐盒 +com.docin.zlibrary.ui.android # 豆丁书房 +com.dongfanghn.com # Only婚恋 +com.dongqiudi.news # 懂球帝 +com.dotamax.app # Max+ +com.dotools.nightcamera # 夜视相机 +com.douban.book.reader # 豆瓣阅读 +com.douban.frodo # 豆瓣 +com.douban.radio # 豆瓣FM +com.doudou.ysvideo # 影视大全韩剧 +com.douguo.recipe # 豆果美食 +com.doumi.jianzhi # 斗米 +com.dp.android.elong # 艺龙旅行 +com.dragon.read # 番茄免费小说 +com.droid.credit # 移动手机贷 +com.droidpiggy.weiqi # 围棋宝典 +com.duapps.recorder # 小熊录屏 +com.duitang.main # 堆糖 +com.duoduo.cailing # 彩铃多多 +com.duoduo.child.story # 儿歌多多 +com.duoduo.child.storyhd # 儿歌多多HD +com.duokan.phone.remotecontroller # 万能遥控 +com.duokan.reader # 多看 +com.duorong.smarttool # 时光序 +com.duowan.kiwi # 虎牙直播 +com.duowan.mobile # YY +com.dute.dutenews # 读特 +com.duwo.reading # 伴鱼绘本 +com.duxiaoman.umoney # 有钱花 +com.duxiaoman.wallet # 度小满 +com.dw.btime # 亲宝宝 +com.dx.x1myapplication # 电销外呼电话 +com.dz.hmjc # 河马剧场 +com.dzqh.online11 # 东方证券期货 +com.dzwww.sd.android # 海报新闻 +com.eastday.listen_news # 翱翔 +com.eastedge.zhuzhounews # 掌上株洲 +com.eastmoney.android.berlin # 东方财富 +com.eastmoney.android.fund # 天天基金 +com.eastmoney.android.tokyo # 妙想炒股 +com.easy.wallpaper # 番茄动态壁纸 +com.ebank.creditcard # 阳光惠生活 +com.ebnews # 亿邦 +com.edujia.weimai # 微脉圈 +com.eduyun.app.factory.ykt # 智慧中小学 +com.eg.android.AlipayGphone # 支付宝 +com.ejeas.ejeasapp # Ejeas +com.eno.android # 国海金探号 +com.eno.android.cj.page # 长江e号 +com.eno.xyzq.page # 兴业证券优理宝 +com.enotary.cloud # 公证云 +com.esbook.reader # 宜搜小说 +com.essdcbz.nyxx # 多彩壁纸 +com.estrongs.android.pop # ES文件浏览器 +com.eusoft.dehelper # 德语助手 +com.eusoft.eudic # 欧路词典 +com.eusoft.frhelper # 法语助手 +com.eusoft.ting.en # 每日英语听力 +com.eusoft.ting.fr # 每日法语听力 +com.evaair.android # 长荣航空 +com.everimaging.photoeffectstudio # Fotor +com.exam8.yijian # 一建万题库 +com.example.raccoon.dialogwidget # 万象小组件 +com.excean.gspace # OurPlay +com.excean.masaid # 微分身版 +com.excelliance.dualaid # 双开助手微分身版 +com.ezen.ehshig # egshig +com.ezg.camper # 约你游 +com.faloo.BookReader4Android # 飞卢小说 +com.fanhuan # 返还网 +com.fanli.android.apps # 返利 +com.fanlwgaoylmengw # 返利高佣联盟 +com.fbx.handwriteime # 手写输入法 +com.fcbox.hivebox # 丰巢管家 +com.fcbox.hiveconsumer # 丰巢 +com.feheadline.news # 财经头条 +com.feiteng.lieyou # 猎游 +com.fenbi.android.gaozhong # 猿题库 +com.fenbi.android.leo # 小猿AI +com.fenbi.android.servant # 粉笔 +com.fenbi.android.solar # 小猿搜题 +com.fenbi.android.zenglish # 斑马AI学 +com.fengbo.live # 幻梦直播 +com.fengnanwlkj.gongxiangApp # 共享货源 +com.fenqile.fenqile # 分期乐 +com.fiberhome.exmobi.client.gaeaclientandroidedn7262 # 天然工坊 +com.fimo.camera # FIMO-复古胶片相机 +com.financial.quantgroup # 羊小咩 +com.fivehundredpx.viewer.main # 500px中国版 +com.flapp.wanzi # 代练丸子 +com.flash.zhongcao # 种草生活 +com.flightmanager.view # 航班管家 +com.flybbj.remotecontrol # 空调手机遥控器 +com.flyersoft.seekbooks # 搜书大师 +com.focusgene.findme # 真我 +com.font # 写字先生 +com.fosung.lighthouse # 灯塔党建在线 +com.foundao.bjnews # 新京报 +com.founder.qujing # 掌上曲靖 +com.founder.shihuabao # 新石化 +com.founder.zhanjiang # 湛江云媒 +com.foundersc.app.xf # 小方 +com.foxit.mobile.pdf.lite # 福昕PDF阅读器 +com.fq.wallpaper # 飞火动态壁纸 +com.fqapps.fdsh # 福袋生活 +com.free_ride.yiwei # 顺风车 +com.free_ride_pin.yiwei # 汽车票 +com.freebird.zsp.DailyAccounting # 日常记账 +com.freeme.lockscreen.levect # 杂志锁屏 +com.frozenape.tempo # 移动节拍器 Metronome Tempo +com.fullgoal.android # 富国富钱包 +com.funcamerastudio.videomaker # Filmigo视频剪辑制作软件 +com.funinput.digit # 数字尾巴 +com.funshion.video.mobile # 风行视频 +com.fx678.finace # 汇通财经 +com.galaxy.metawp # 元壁纸 +com.galaxy.stock # 中国银河证券 +com.gameabc.zhanqiAndroid # 战旗直播 +com.gamestar.perfectpiano # 完美钢琴 +com.gamestar.pianoperfect # 随身乐队 +com.ganji.android # 赶集招聘 +com.ganji.android.haoche_c # 瓜子二手车 +com.gaoping.makewallpaper # 动态壁纸制作 +com.gaotu100.superclass # 高途 +com.gator.mm # 美女图集大全 +com.gau.go.launcherex.theme.droidglass # 玻璃主题包 +com.gbcom.gwifi.school # GiWiFi校园助手 +com.gdwx.cnwest # 陕西头条 +com.geihui # 给惠网 +com.gelonghui.glhapp # 格隆汇 +com.gem.tastyfood # 食行生鲜 +com.gewara # 格瓦拉生活 +com.gf.client # 广发易淘金 +com.gfqh.guangfa # 广发期货小添财-投资交易软件 +com.ggeye.jiakao.api # 驾考通驾照考试 +com.gh.gamecenter # 光环助手 +com.gift.android # 驴妈妈旅游 +com.giiso.jinantimes # 新黄河 +com.gioct.weather # 天气预报 +com.glodon.drawingexplorer # CAD快速看图 +com.gome.eshopnew # 国美 +com.gomemyc.mylc.android # 国美金融 +com.gongyinruixin # 工银瑞信基金 +com.gonlan.iplaymtg # 旅法师营地 +com.goodtech.tq # 更云天气预报 +com.gorgeous.lite # 轻颜 +com.goseet.VidTrim # 视频剪辑 +com.gotokeep.keep # Keep - AI 运动教练 +com.gozap.chouti # 抽屉 +com.greenline.guahao # 微医 +com.greenpoint.android.mc10086.activity # 中国移动 +com.greentree.android # 格林 +com.growing.topwidgets # 万能小组件 Top Widgets +com.grunkr.autowallpaper # 自动壁纸 +com.gstarmc.android # CAD看图王 +com.gtgj.view # 高铁管家 +com.guagua.finance # 呱呱财经 +com.guagua.qiqi # 齐齐直播 +com.guangdaqihuo.mhdxh # 光大期货 +com.guosen.android # 国信金太阳 +com.guotai.dazhihui # 国泰海通君弘 +com.gwchina.lssw.parent # 青葱守护家长 +com.gwdang.app # 购物党 +com.gwsoft.imusic.controller # 爱音乐 +com.gzfns.ecar # 车e估企业版 +com.gzrcb.mobilebank # 广州农商银行 +com.haidilao # 海底捞 +com.haier.uhome.uplus # 海尔智家 +com.haiqiu.jihai # 即嗨体育 +com.haitao # 55海淘 +com.haixiu.ui # 嗨秀直播 +com.haizhengqihuo.mhdxh # 上海证券期货 +com.handsgo.jiakao.android # 驾考宝典 +com.handsgo.jiakao.android.kehuo # 教练宝典 +com.hangzhoucaimi.financial # 挖财宝 +com.hanweb.android.sdzwfw.activity # 爱山东 +com.hanweb.android.zhejiang.activity # 浙里办 +com.haodf.android # 好大夫在线 +com.happigo.activity # 快乐购 +com.happylife.multimedia.image # 相片集 +com.happyteam.dubbingshow # 配音秀 +com.hazq.huiying.android # 华安证券 +com.hbrb.jiyun # 冀云 +com.hcrs # 华彩人生 +com.hcyh.scanner # 扫描仪全能王 +com.heiyan.reader # 黑岩阅读 +com.hexin.android.bank # 同花顺爱基金 +com.hexin.android.stockassistant # 同花顺i问财 +com.hexin.ifind.android # 同花顺iFinD +com.hexin.plat.android # 同花顺 +com.hexin.plat.android.CaitongSecurity # 财通证券 +com.hexin.plat.android.GuoJinZXGSecurity # 全能行 +com.hexin.plat.android.WanlianSecurity # 万联e万通 +com.hexin.plat.android.ZhongyuanSecurity # 中原证券 +com.hexun.news # 和讯财经 +com.heytap.market # OPPO软件商店 +com.hf # 中国天气 +com.hh.wallpaper.a # 全民免费壁纸 +com.hichip # CamHi +com.hicorenational.antifraud # 国家反诈中心 +com.hihonor.appmarket # 荣耀应用市场 +com.hipu.yidian # 一点资讯 +com.hisense.ms.fly2tv # 海信爱家 +com.hjkj66.fakecall # 虚拟电话 +com.hjwordgames # 沪江开心词场 +com.hkairlines.apps # 香港航空 +com.hkx.youandme # 情侣我和你 +com.hlg.daydaytobusiness # 稿定设计 +com.hmkx.zgjkj # 健康界 +com.hnzx.hnrb # 河南日报 +com.hnzycfc.zyxj # 中原消费金融 +com.hoge.android.chinablue # 中国蓝新闻 +com.hoge.android.wuxiwireless # 无锡博报 +com.hoge.android.yancwireless # 智慧盐城 +com.homelink.android # 链家 +com.honeymoon.stone.jean.poweredit # 图片编辑工具 +com.honghesoft.calligrapher # 大书法家 +com.hongju.aishang # 情趣店 +com.HotelZhiDing.zhiding365 # 直订 +com.hp.android.printservice # HP 打印服务插件 +com.hp.marykay # 玫琳凯之窗 +com.hpbr.bosszhipin # BOSS直聘 +com.hpplay.happycast # 乐播投屏 +com.hqf.app.yuanqi # 趣玩桌面壁纸 +com.hrz.quick # 红人装极速版 +com.htffund.mobile.ec.ui # 汇添富现金宝 +com.htinns # 华住会 +com.huaban.android # 花瓣 +com.huajiao # 花椒直播 +com.hualongxiang # 化龙巷 +com.huankaifa.tpjwz # 图片加文字 +com.huanqiu.news # 环球时报 +com.huati # 葫芦侠3楼 +com.huawei.appmarket # 华为应用市场 +com.huawei.hwid # HMS Core +com.huawei.intelligent # 智慧助手·今天 +com.huawei.search # 智慧搜索 +com.huawei.smarthome # 智慧生活 +com.huicao.stbg # 主题秀秀 +com.huimai365 # 惠买 +com.huitouche.android.app # 省省货主 +com.hujiang.dict # 沪江小D词典 +com.hujiang.hjclass # 沪江网校 +com.huluxia.gametools # 葫芦侠 +com.hunantv.imgo.activity # 芒果TV +com.hundsun.stockwinner.cyqh # 创元期货 +com.hundsun.stockwinner.gdzq # 光大证券金阳光 +com.hundsun.stockwinner.paqh # 平安期货 +com.hundsun.stockwinner.yhqh # 银河期货专业版 +com.hundsun.winner.pazq # 平安证券 +com.huodao.hdphone # 找靓机 +com.hupu.games # 虎扑 +com.hupu.shihuo # 识货 +com.huxiu # 虎嗅 +com.huxiupro # 妙投 +com.huyun.ybb # 运帮帮 +com.hw.okm # 二三里 +com.hx.mall # 华西证券益理财 +com.hxak.liangongbao # 链工宝 +com.hybunion.miaodao # 会员宝秒到版 +com.hyhy.view # 掌上天津 +com.hzqj.callshow.yuedong # 悦动来电 +com.i61.draw.live # 画啦啦美术课堂 +com.ibike.publicbicycle.activity # 永安行 +com.ibox.calculators # 多多计算器 +com.ibox.flashlight # 多多手电筒 +com.iboxpay.minicashbox # 钱盒商户通 +com.icbc # 中国工商银行 +com.icbc.elife # 工银e生活 +com.icbc.emallmobile # 融e购 +com.ichano.athome.avs # 掌上看家采集端 +com.ichano.athome.camera # 掌上看家观看端 +com.ichinait.gbpassenger # 首汽约车 +com.iCitySuzhou.suzhou001 # 引力播 +com.icoolme.android.weather # 最美天气 +com.id10000 # 智企 +com.idaodan.clean.master # 雪豹速清 +com.idotools.rings # 多多铃声 +com.ifanr.activitys # 爱范儿 +com.ifeimo.audiorecord # 录音大师 +com.ifeimo.videoeditor # 视频剪辑大师 +com.ifeng.android # 翻阅小说 +com.ifeng.fhdt # 凤凰FM +com.ifeng.news2 # 凤凰新闻 +com.ifeng.newvideo # 凤凰视频 +com.ifext.news # 凤凰新闻极速版 +com.iflytek.elpmobile.smartlearning # 智学网 +com.iflytek.inputmethod # 讯飞输入法 +com.iflytek.ringdiyclient # 酷音铃声 +com.iflytek.womusicclient # 联通视频彩铃 +com.ifreetalk.ftalk # 派派 +com.ihaozhuo.youjiankang # 优健康 +com.ihg.apps.android # 洲际优悦会 +com.ihope.hbdt # 冀时新闻 +com.ihuman.recite # 万词王 +com.imaginer.yunji # 云集 +com.imendon.cococam # 可可修图 +com.imendon.fomz # Fomz +com.imendon.lovelycolor # 少女与猫 +com.imendon.painterspace # 画师之境 +com.imgo.pad # 芒果TV HD +com.immomo.momo # MOMO陌陌 +com.imusic.iting # 爱听 +com.inditex.zara # ZARA +com.infinitus.bupm # 无限极 +com.infothinker.gzmetro # 广州地铁 +com.infzm.ireader # 南方周末 +com.insurance.agency # 亲亲小保 +com.intsig.BizCardReader # 名片全能王 +com.intsig.camscanner # 扫描全能王 +com.iodkols.onekeylockscreen # 一键锁屏 +com.iqilu.ksd # 闪电新闻 +com.iqinbao.android.songs # 亲宝儿歌 +com.iqiyi.acg # 爱奇艺叭嗒 +com.iqiyi.ivrcinema.cb # 爱奇艺VR +com.iqiyi.qixiu # 奇秀 +com.irongroup # 电话时间统计 +com.ishansong # 闪送员 +com.ishowedu.child.peiyin # 少儿趣配音 +com.ishowedu.peiyin # 英语趣配音 +com.ishugui # 点众快看小说 +com.istone.activity # 邦购商城 +com.itcode.reader # 漫漫漫画 +com.iyuba.AE # 美语怎么说 +com.iyuba.cet4 # 英语四级听力 +com.iyuba.CSvoa # VOA常速英语 +com.iyuba.music # 听歌学英语 +com.iyuba.voa # VOA慢速英语 +com.iznet.thailandtong # 三毛游 +com.jd.healthy.daily # 人民日报健康 +com.jd.jdlite # 京东特价版 +com.jd.jrapp # 京东金融 +com.jeejen.family # 极简桌面 +com.jesson.meishi # 美食杰家常菜谱大全 +com.jf.lkrj # 花生日记 +com.jhdapp.xhbycm # 交汇点新闻 +com.jhss.youguu # 优顾炒股 +com.jiadi.quannengsaomiaowang # 合同扫描王 +com.jianyou.swatch # 智能守护5 +com.jianzhiku.zhongrenbang # 众人帮 +com.jiaoyinbrother.monkeyking # 悟空租车 +com.jiayuan # 世纪佳缘 +com.jie.lockscreen # 锁屏大全 +com.jiemian.news # 界面新闻 +com.jifen.qukan # 趣头条 +com.jiliguala.niuwa # 叽里呱啦 +com.jingdian.tianxiameishi.android # 美食天下 +com.jingdong.app.mall # 京东 +com.jingdong.pdj # 京东到家 +com.jingyao.easybike # 哈啰 +com.jingzhou # 荆州社区 +com.jinxin.namibox # 纳米盒 +com.jiongji.andriod.card # 百词斩 +com.jiuxianapk.ui # 酒仙 +com.jiuyan.infashion # in +com.jjwxc.reader # 晋江小说阅读 +com.jm.fxw # 发型屋 +com.job.android # 前程无忧51Job +com.joey.fui # 一幅相框 +com.join.android.app.mgsim.wufun # 悟饭游戏厅 +com.jojoread.huiben # 叫叫绘本 +com.jooan.qiaoanzhilian # 乔安智联 +com.jovetech.CloudSee.temp # 云视通 +com.joygo # 忘忧围棋 +com.joyintech.wise.seller # 智慧商贸进销存 +com.jsbc.lznews # 荔枝新闻 +com.jsmcc # 中国移动江苏 +com.jtjr99.jiayoubao # 加油宝 +com.juejinchain.android # 掘金宝 +com.julanling.app # 安心记加班 +com.juronghm.wallpaper # 神图壁纸主题大全 +com.jxedt # 驾校一点通 +com.jy.recorder # 录屏精灵 +com.jyyx.fuli # 九妖游戏 +com.jz.androidacts.superiscreen # xScreen超级桌面 +com.jzyd.coupon # 省钱快报 +com.jzzs.ParentsHelper # 安全教育平台 +com.kankanews.kankanxinwen # 看看新闻 +com.kekeclient_ # 可可英语 +com.kevinstudio.test # 转盘做决定 +com.keyue.widget # 百变桌面小组件 +com.Kingdee.Express # 快递100 +com.kingsoft # 金山词霸 +com.kmxs.reader # 七猫免费小说 +com.knowbox.rc.student.pk # 小盒AI学 +com.komoxo.octopusime # 章鱼输入法 +com.koudai.weidian.buyer # 微店 +com.koudai.weishop # 微店店长版 +com.ktls.fileinfo # 存储空间清理 +com.kuaiduizuoye.scan # 快对AI +com.kuaikan.comic # 快看漫画 +com.kuaishou.nebula # 快手极速版 +com.kuangxiang.novel # 欢乐书客 +com.kuangxiangciweimao.novel # 刺猬猫阅读 +com.kugou.android # 酷狗音乐 +com.kugou.android.elder # 酷狗免费版 +com.kugou.android.ktvapp # 酷狗唱唱 +com.kugou.android.lite # 酷狗概念版 +com.kugou.android.ringtone # 酷狗铃声 +com.kugou.fanxing # 酷狗直播 +com.kugou.playerHD # 酷狗音乐HD +com.kunpeng.babyting # 宝贝听听 +com.kunpeng.bluefont # 字体下载极速版 +com.kunpeng.zitice # 字体大全 +com.kwai.hisense # 回森 +com.kwai.livepartner # 快手直播伴侣 +com.kwai.m2u # 一甜相机 +com.kwai.thanos # 噗叽 +com.kwai.videoeditor # 快影 +com.lakala.android # 拉卡拉易分期 +com.lalamove.huolala.client # 货拉拉 +com.lanjinger.choiassociatedpress # 财联社 +com.lanjingren.ivwen # 美篇 +com.laoyouzhibo.app # 老柚 +com.laoyuegou.android # 捞月狗 +com.larus.nova # 豆包 +com.lchr.diaoyu # 钓鱼人 +com.lcworld.haiwainet # 海客新闻 +com.ld.dianquan # 雷电云手机 +com.le123.ysdq # 影视大全 +com.lechuan.mdwz # 米读极速版 +com.lechuan.midunovel # 米读小说 +com.lehai.ui # 乐嗨直播 +com.leisu.sports # 雷速体育 +com.leixun.taofen8 # 淘粉吧 +com.lemon.faceu # Faceu激萌 +com.lemon.lv # 剪映 +com.leqi.idpicture # 智能证件照 +com.leqi.ProfessionalIDPhoto # 专业证件照 +com.letv.android.client # 乐视视频 +com.letv.android.client.pad # 乐视视频Pad +com.lezhi.mythcall # 北瓜电话 +com.lft.turn # 导学号 +com.lgzgykc.xddt # 熊多多 +com.lianaibiji.dev # 恋爱记 +com.lianjia.beike # 贝壳找房 +com.lianmengsylm.app # 联盟生态 +com.liba.android # 篱笆社区 +com.lietou.mishu # 猎聘 +com.linecorp.foodcamcn.android # Foodie +com.lingan.seeyou # 美柚 +com.lingan.yunqi # 宝宝记 +com.lingcreate.speechtotext # 音频转文字 +com.lingyue.zebraloan # 洋钱罐借款 +com.linkiing.show # 联客show +com.lion.market # 虫虫助手 +com.liulishuo.engzo # 流利说-英语 +com.ljmobile.zlj.rom.release # 授权管理软件搬家 +com.llspace.pupu # 平行世界 +com.lltskb.lltskb # 路路通 +com.lofter.android # 网易LOFTER +com.lolaage.tbulu.tools # 两步路 +com.lovebizhi.wallpaper # 爱壁纸 +com.lphtsccft # 涨乐财富通 +com.lqh.love.comic # 爱动漫壁纸 +com.ls.sjdtbz # 手机动态壁纸 +com.lt.lighting # 来电闪光 +com.lu.ashionweather # 天气预报 +com.ludashi.benchmark # 鲁大师 +com.luna.music # 汽水音乐 +com.luojilab.player # 得到 +com.luoyu.gangqin # 真实钢琴 +com.luoyu.shichanglianer # 视唱练耳 +com.luyun.diary # 每日记 +com.luyun.lightnote # 轻记事本 +com.luyun.pinkdiary # 粉萌日记 +com.lvshiguan.app # 律师馆法律咨询 +com.lxkj.dmhw # 呆萌价 +com.lyh.talkphoto # 让照片会说话 +com.lyx.piano # 钢琴节奏键盘大师 +com.lz.mycall # 千千电话 +com.m4399.gamecenter # 4399游戏盒 +com.macrovideo.v380 # V380 +com.magicpoint.mobile.bank # 重庆农商行 +com.maihan.tredian # 淘最热点 +com.maijun.inputmethod # 语音输入法 +com.maipu.tourist # 小鹿导游 +com.maka.app # MAKA设计 +com.manmanbuy.bijia # 慢慢买 +com.market2345 # 2345手机助手 +com.marqio.widget.keeplighting # 屏幕常亮 +com.marryu # MarryU相亲交友 +com.max.xiaoheihe # 小黑盒 +com.maxxipoint.android # 集享联盟 +com.mayi.android.shortrent # 蚂蚁短租 +com.mcdonalds.gma.cn # 麦当劳 +com.mediabox.voicechanger # 安卓变声器 +com.meelive.ingkee # 映客直播 +com.meet.pianolearn # 钢琴教练 +com.meetstudio.app.Zither # 魔幻古筝 +com.meijialove.activity # 美甲帮 +com.meitianmeihuimtmh.app # 喵小盒 +com.meitu.makeup # 美妆相机 +com.meitu.meipaimv # 美拍 +com.meitu.meiyancamera # 美颜相机 +com.meitu.poster # 美图设计室 +com.meitu.wheecam # 潮自拍 +com.meitu.wink # Wink +com.melot.meshow # KK +com.memory.me # 英语魔方秀 +com.mendon.riza # 日杂相机 +com.messi.cantonese.study # 粤语说 +com.messi.languagehelper # 中英互译 +com.meta.box # 233乐园 +com.mfw.roadbook # 马蜂窝 +com.mgyun.rootmaster # ROOT大师 +com.mi.health # 小米运动健康 +com.mianfeia.book # 爱看书免费小说 +com.midea.ai.appliances # 美的美居 +com.midea.vm.washer # U净 +com.mifeng.ui # 蜜疯直播 +com.mihoyo.hyperion # 米游社 +com.mimikko.mimikkoui # 兽耳助手 +com.minitools.miniwidget # 心动主题壁纸 +com.mishangwo # 迷上我 +com.mistong.ewt360 # 升学e网通 +com.mm.android.lc # 乐橙 +com.mmbox.xbrowser.pro # X浏览器 +com.mmote.play # 买萌陪玩 +com.mobile.myeye # 监控眼 +com.MobileTicket # 铁路12306 +com.mobivans.onestrokecharge # 预记智能账本 +com.mobvoi.baiding # 出门问问 +com.mogujie # 蘑菇街 +com.moji.mjweather # 墨迹天气 +com.moneywise.mhmoney # 财智快账 +com.moonlight.huatu # 画画图 +com.moonshot.kimichat # Kimi +com.mosheng # 爱聊 +com.motoband # 摩托邦 +com.moutian.moutianshuiyinwang # 水印王 +com.mp.qcoperate # 红旗e岗通 +com.mrkj.sm # 知命 +com.msg.schedule # 定时信息 +com.msxf.ayh # 安逸花 +com.mt.mtxx.mtxx # 美图秀秀-视频/图片/Live人像精修工具 +com.mtime # 时光网 +com.music.qishui # 汽水铃声 +com.mx.browser # 傲游浏览器 +com.my.peiyin # 配音文字转语音 +com.mybank.android.phone # 网商银行 +com.mygolbs.mybus # 掌上公交 +com.mygolbs.mybuswz # 我看行 +com.mymoney # 随手记 +com.mymoney.sms # 卡牛信用管家 +com.mysteel.android.steelphone # 我的钢铁 +com.myzaker.ZAKER_Phone # ZAKER +com.n_add.android # 粉象生活 +com.nacai.bocai # 花花直播 +com.nade.ownerride # 顺风车司机版 +com.nade.tickowner # 滴答滴出行司机端 +com.naiyoubz.main # 奶油桌面 +com.nandu # 南都N视频 +com.nanyibang.nomi # 男衣邦穿衣搭配 +com.nanyibang.rm # 穿衣有品 +com.NationalPhotograpy.weishoot # 微摄 +com.naver.linewebtoon.cn # 咚漫漫画 +com.nbbank # 宁波银行 +com.ncpaclassic # 大剧院·古典 +com.nd.android.pandareader # 熊猫看书 +com.neil # 返利助手 +com.net263.ecm.display # 263移动会议 +com.netease.a13.avg # 易次元 +com.netease.buff # 网易BUFF +com.netease.cloudmusic # 网易云音乐 +com.netease.edu.ucmooc # 中国大学MOOC +com.netease.epay # 网易支付 +com.netease.gl # 网易大神 +com.netease.mail # 网易邮箱大师 +com.netease.mkey # 网易将军令 +com.netease.mobimail # 网易邮箱 +com.netease.newsreader.activity # 网易新闻 +com.netease.pris # 网易云阅读 +com.netease.uu # UU加速器 +com.netease.vopen # 网易公开课 +com.netease.xyqcbg # 梦幻藏宝阁 +com.netease.yanxuan # 网易严选 +com.neusoft.app.beijingdaily.phone # 北京日报 +com.newcapec.mobile.ncp # 完美校园 +com.news.shangyounews # 上游新闻 +com.nfdaily.nfplus # 南方Plus +com.nice.main # nice +com.nillu.kuaiqu.ui # 快去水印 +com.nineteenlou.nineteenlou # 19楼 +com.nineton.handwriteime # 友友输入法 +com.ninexiu.sixninexiu # 九秀直播 +com.niuguwang.stock # 牛股王股票 +com.niwodai.universityloan # 你我贷借款 +com.njh.biubiu # biubiu加速器 +com.njmizhilan.wallpaper # 神图主题 +com.njwry.zhuochong # 桌面宠物多多 +com.novv.resshare # 彩蛋视频壁纸 +com.nowcasting.activity +com.nxy.sc # 四川农信手机银行 +com.ocj.oms.mobile # 东方购物 +com.octinn.birthdayplus # 生日管家 +com.oda_cad # CAD迷你看图 +com.oeiskd.easysoftkey # 小白点虚拟按键 +com.offcn.android.offcn # 中公教育 +com.okcam.ookcam # 小鹰看看 +com.okmyapp.liuying # 留影音乐相册 +com.One.WoodenLetter # 一个木函 +com.onesoft.app.Tiiku.Duia.KJZ # 会计随身学 +com.onlytongchengzh.com # 两性同城聊 +com.ophone.reader.ui # 咪咕悦看 +com.oppo.market # OPPO应用商店 +com.oppo.store # OPPO商城 +com.oray.sunlogin # 向日葵远程控制 +com.oupeng.browser # 欧朋浏览器极速版 +com.oupeng.mini.android # 欧朋浏览器 +com.ovital.ovitalMap # 奥维互动地图 +com.owqsic.dwtihfwexq # pubg tool +com.p1.mobile.putong # 探探 +com.pa.health # 平安乐健康 +com.paem # 平安融易 +com.paic.yl.health # 好福利 +com.paic.zhifu.wallet.activity # 壹钱包 +com.parallel.odyssey # 猫箱 +com.pcncn.jj # 作业精灵 +com.pdmi.guangzhou.media # 广州日报新花城 +com.pdmi.studio.newmedia.people.video # 人民视频 +com.people.health.doctor # 人民好医生 +com.peopledailychina.activity # 人民日报 +com.perracolabs.tcc # Tiny Call Confirm +com.phoenix.read # 红果免费短剧 +com.phone.show # 秀来电 +com.piaopiao.idphoto # 证件照随拍 +com.picooc # PICOOC +com.pingan.carowner # 平安好车主 +com.pingan.lifeinsurance # 平安金管家 +com.pingan.paces.ccms # 平安口袋银行 +com.pingdiguo.tuner # 乐器调音器 +com.pinguo.edit.sdk # MIX滤镜大师 +com.pixelorange.filmroll # 最后一卷胶片 +com.pla.daily # 解放军报 +com.popalm.duizhuang # 对庄翡翠 +com.ppdai.loan # 拍拍贷借款 +com.pplive.androidphone # PP视频 +com.pplive.androidphone.sport # PP体育 +com.prinics.kodak.photoprinter # KODAK Photo Printer +com.prompt.lines # 提词器大师 +com.pupumall.customer # 朴朴超市 +com.pzds.pzds # 盼之账号 +com.qb.qtranslator # 腾讯翻译君 +com.qclianai.com # 全城恋爱 +com.qdnews.qd # 青岛新闻 +com.qdruotu.app # 若途旅行 +com.qianyouyu # 钱有余 +com.qiaola.desktop # 主题壁纸美化 +com.qicai.contacts # 西瓜通讯录 +com.qicai.translate # 出国翻译官 +com.qidian.QDReader # 起点读书 +com.qihoo.appstore # 360手机助手 +com.qihoo.browser # 360浏览器 +com.qihoo.camera # 360摄像机 +com.qihoo.cleandroid_cn # 360清理大师 +com.qihoo.dr # 360行车助手 +com.qihoo.freewifi # 360免费WiFi +com.qihoo.loan # 奇富借条 +com.qihoo.qeditor # 快剪辑 +com.qihoo360.antilostwatch # 360儿童卫士 +com.qihoo360.mobilesafe # 360手机卫士 +com.qihuo.changjiang # 长江期货开户交易 +com.qihuo.guangda # 光大期货e开户 +com.qihuo.yinhe # 银河期货开户交易 +com.qiku.os.show # 欧思奇主题 +com.qilesoft.en.eights # 英语口语8000句 +com.qima.kdt # 有赞微商城 +com.qimingzz.qm # 宝宝起名取名 +com.qinhe.ispeak # IS语音 +com.qinqinxiong.apps.dj99 # DJ99 +com.qiouou.app # 七鸥鸥 +com.qisi.freepaper # 壁纸桌面大全 +com.qisi.wallpaper # 壁纸主题大全 +com.qiyi.video # 爱奇艺 +com.qiyi.video.child # 爱奇艺奇巴布 +com.qiyi.video.lite # 爱奇艺极速版 +com.qiyi.video.pad # 爱奇艺PAD +com.qiyi.video.reader # 爱奇艺小说 +com.qlscupgrade # 中泰齐富通 +com.qnap.qremote # Qremote +com.qq.ac.android # 腾讯动漫 +com.qq.qcloud # 腾讯微云 +com.qq.reader # QQ阅读 +com.qsmy.walkmonkey # 步多多 +com.qtx.qtxty # 球天下体育 +com.quanleimu.activity # 百姓网 +com.quark.browser # 夸克 +com.Qunar # 去哪儿旅行 +com.quvideo.slideplus # 小影记 +com.quvideo.xiaoying # 小影 +com.qyer.android.jinnang # 穷游 +com.qzone # QQ空间 +com.raiyi.wxcs # 流量掌厅 +com.rakutec.android.iweekly # iWeekly都市潮力 +com.rarlab.rar # RAR +com.redfinger.app # 红手指云手机 +com.redrcd.ylxf # 云岭先锋 +com.renren.mobile.android # 人人直播 +com.renrendai.haohuan # 好分期 +com.renxing.xys # 性用社 +com.rhythm.dance.gz # 古筝世界 +com.roamingsoft.manager # WiFi连接管理器 +com.rong.fastloan # 时光分期 +com.rong360.app # 融360 +com.ruanmei.ithome # IT之家 +com.rumtel.mobiletv # 影评大全看看 +com.runbey.ybjk # 元贝驾考 +com.sainti.eduread # 出口成章 +com.samoyed.credit # 省呗 +com.samsung.android.app.watchmanager # 三星智能穿戴 +com.sand.airdroid # AirDroid +com.sangame.jjhcps.android # 金投网 +com.sangdh # 3G网络电话 +com.sankuai.meituan # 美团 +com.sankuai.meituan.dispatch.crowdsource # 美团众包 +com.sankuai.meituan.meituanwaimaibusiness # 美团外卖商家版 +com.sankuai.meituan.merchant # 美团经营宝 +com.sankuai.meituan.takeoutnew # 美团外卖 +com.sankuai.movie # 猫眼 +com.sanmu.liaoliaoba # 聊聊吧 +com.sanya.xinxiulive # Bei贝 +com.sanya.xinxiulive.lite # bei world +com.sbds.miaoyin # 秒音 +com.scanning # 二维码与条形码 +com.screeclibinvoke # 录屏大师 +com.sdu.didi.gsui # 滴滴车主 +com.sdu.didi.psnger # 滴滴 +com.sec.android.app.shealth # 三星健康 +com.sec.android.easyMover # S 换机助手 +com.secoo # 寺库奢侈品 +com.seebaby # 掌通家园 +com.seetong.app.seetong # Seetong +com.seewo.easicare.pro # 班级优化大师 +com.sender.yiwei # 一喂宠物托运 +com.seven.two.zero.yun # 720云 +com.sevenmmobile # 7M足球实时比分 +com.sevenplus.chinascience # 科普中国 +com.sf.activity # 顺丰速运 +com.sfacg # 菠萝包轻小说 +com.sgcc.wsgw.cn # 网上国网 +com.shabro.android.activity # 沙师弟司机 +com.shanbay.sentence # 扇贝单词英语版 +com.shangbiao.activity # 尚标商标转让 +com.shangbiao.searchsb86 # 商标注册查询 +com.shangfang.gylm # 高佣联盟 +com.shangshaban.zhaopin.activity # 上啥班 +com.shanxidaily.activity # 山西日报 +com.shark.jizhang # 鲨鱼记账 +com.shenghuoquan # 生活圈 +com.shengmi.photo # 私密Key应用助手 +com.shensu.jmb # 金苗宝 +com.shenyuan.militarynews # 环球新军事 +com.shfy.voice # 和平游戏变声器 +com.shilv.yuejiao # 越她 +com.shilv.yueliao # 越聊 +com.shinyv.cnr # 云听 +com.shiyue.fensigou # 粉丝福利购 +com.shizhuang.duapp # 得物 +com.shoujiduoduo.dj # DJ多多 +com.shoujiduoduo.ringtone # 铃声多多 +com.shoujiduoduo.wallpaper # 壁纸多多 +com.shouxiu.tcjy # 乡遇 +com.show.piccollagemaker # 相片拼图 +com.showself.ui # 秀色直播 +com.shuangyingh.qpyyefeu # 汽泡音乐 +com.shuhekeji # 还呗 +com.shuidihuzhu.aixinchou # 水滴筹 +com.shuiyinbao.android # 水印宝 +com.shulantech.video # 视频转换压缩 +com.shunfengche.ride # 拼客顺风车 +com.shunlian.app # 顺联动力 +com.shuqi.controller # 书旗小说 +com.shuqu.banyan # 榕树贷款 +com.shuyi.csdj # 熊猫DJ +com.shyl.train # 全国火车票实时查 +com.shyltts # 读短信来电报姓名 +com.shyz.toutiao # 清理大师 +com.sichuanol.cbgc # 川观新闻 +com.sina.news # 新浪新闻 +com.sina.VDisk # 微盘 +com.sina.weibo # 微博 +com.sina.weibog3 # 新浪微博4G版 +com.sina.weibolite # 微博极速版 +com.sing.client # 5sing音乐 +com.sinosoft.mobilebiz.chinalife # 中国人寿综合金融 +com.sinovatech.gxmobile.ui # 中国移动广西 +com.sinovatech.unicom.ui # 中国联通 +com.sinyee.babybus.abc # 宝宝巴士英语 +com.sinyee.babybus.amusement # 奇妙科普馆 +com.sinyee.babybus.babydrawing # 宝宝画妈妈猜 +com.sinyee.babybus.babyhospital # 宝宝医院 +com.sinyee.babybus.chants # 宝宝巴士儿歌 +com.sinyee.babybus.chef # 宝宝小厨房 +com.sinyee.babybus.dailycommodities # 宝宝认日用品 +com.sinyee.babybus.dining # 宝宝爱吃饭 +com.sinyee.babybus.drinks # 宝宝甜品店 +com.sinyee.babybus.duibi # 有趣的对比 +com.sinyee.babybus.farm # 奇妙数字农场 +com.sinyee.babybus.findfriendsII # 宝宝玩配对 +com.sinyee.babybus.food # 中华美食 +com.sinyee.babybus.foodstreet # 宝宝美食街 +com.sinyee.babybus.kindergarten # 宝宝欢乐教室 +com.sinyee.babybus.lovehealthy # 宝宝爱卫生 +com.sinyee.babybus.magichouse # 宝宝调色屋 +com.sinyee.babybus.number # 宝宝玩数字 +com.sinyee.babybus.organized # 宝宝大扫除 +com.sinyee.babybus.puzzle # 宝宝认知大全 +com.sinyee.babybus.recommendapp # 宝宝巴士 +com.sinyee.babybus.season # 宝宝缤纷四季 +com.sinyee.babybus.seaworld # 宝宝钓鱼 +com.sinyee.babybus.shopping # 宝宝超市 +com.sinyee.babybus.show # 宝宝服装秀 +com.sinyee.babybus.talk2kiki # 宝宝巴士奇妙屋 +com.sinyee.babybus.toilet # 宝宝上厕所 +com.sinyee.babybus.travelsafety # 宝宝出行安全 +com.sinyee.babybus.vegetable # 宝宝爱蔬菜 +com.sinyee.education.shape # 宝宝玩形状 +com.site.maigoo # 买购十大品牌排行榜 +com.sitech.rhtx # 易信 +com.sixrpg.opalyer # 橙光阅读器 +com.sjz.cl.smspopup # 短信弹窗 +com.skp.adf.photopunch # 抠图神手 +com.sktq.weather # 实况天气预报 +com.skyplatanus.crucio # 快点阅读 +com.slanissue.apps.mobile.erge # 贝瓦儿歌 +com.sloth.removelogo # 视频去水印全能王 +com.smallyin.musiceditor # 音乐剪辑助手 +com.smile.gifmaker # 快手 +com.smsf.iwallpaper # 壁纸自定义 +com.smzdm.client.android # 什么值得买 +com.snda.lantern.wifilocating # WiFi万能钥匙极速版 +com.snda.wifilocating # WiFi万能钥匙 +com.sohu.infonews # 搜狐资讯 +com.sohu.inputmethod.sogou # 搜狗输入法 +com.sohu.mail.client.cordova # 搜狐邮箱 +com.sohu.newsclient # 搜狐新闻 +com.sohu.sohuvideo # 搜狐视频 +com.sohu.tv # 搜狐视频Pad +com.songguoyhqw # 赚钱联盟 +com.sony.store.china # 索尼中国 +com.soohoot.ptest # 普通话发音测试 +com.soufun.app # 房天下 +com.souk.zuh # kk助手 +com.spdbccc.app # 浦大喜奔 +com.speedsoftware.rootexplorer # RE文件管理器 +com.ss.android.article.lite # 今日头条极速版 +com.ss.android.article.news # 今日头条 +com.ss.android.article.video # 西瓜视频 +com.ss.android.auto # 懂车帝 +com.ss.android.lark +com.ss.android.tuchong # 图虫 +com.ss.android.ugc.aweme # 抖音 +com.ss.android.ugc.aweme.lite # 抖音极速版 +com.ss.android.ugc.live # 抖音火山版 +com.ss.android.ugc.livelite # 抖音商城 +com.ss.readpoem # 为你诵读 +com.sskj.flashlight # 随手电筒 +com.ssports.mobile.video # 爱奇艺体育 +com.ssxg.cheers # 悦享家生活 +com.starsidea.wallpaper # 星辰壁纸 +com.storm.smart # 暴风影音 +com.student.yuwen.yimilan # 一米阅读 +com.suddenfix.customer # 闪电修 +com.sun.quickpay # 神奇小部件 +com.sunboxsoft.charge.institute # 中油好客e站 +com.suning.mobile.ebuy # 苏宁易购 +com.suning.mobile.epa # 星图金融 +com.sunying.booster # 光影 +com.sup.android.superb # 皮皮虾 +com.superlib # 移动图书馆 +com.suteng.zzss480 # 趣拿 +com.swlvrm.anwsi # 蜜桃主题壁纸 +com.sxm.farpoolcar # 拼车 +com.sxm.yiwei # 一喂顺风车 +com.synology.DSfile # DS file +com.sywg.stock # 申万宏源高端 +com.szeureka.camerasrc # 联趣拍摄遥控器 +com.szqd.dialer # 神指拨号 +com.szyy2106.pdfscanner # 扫描王PDF +com.szzc # 神州租车 +com.szzc.ucar.pilot # 神州专车 +com.tadu.read # 塔读小说 +com.taihai.app2 # 看厦门 +com.tal.kaoyan # 考研帮 +com.talicai.timiclient # Timi记账 +com.taobao.etao # 一淘 +com.taobao.idlefish # 闲鱼 +com.taobao.litetao # 淘宝特价版 +com.taobao.live # 点淘 +com.taobao.movie.android # 淘票票 +com.taobao.qianniu # 千牛 +com.taobao.taobao # 淘宝 +com.taobao.trip # 飞猪旅行 +com.taoche.qctt # 汽车头条 +com.taoguba.app # 淘股吧 +com.taomibaw # 返利赚钱联盟 +com.taou.maimai # 脉脉 +com.taowuyou.tbk # 淘无忧 +com.tdx.AndroidNew # 通达信 +com.teamnet.hula # 粤听 +com.telecom.video # 天翼超高清 +com.tencent.android.qqdownloader # 应用宝 +com.tencent.androidqqmail # QQ邮箱 +com.tencent.cmocmna # 腾讯手游加速器 +com.tencent.djcity # 掌上道聚城 +com.tencent.gallerymanager # 腾讯相册管家 +com.tencent.gamehelper # 腾讯游戏助手 +com.tencent.gamehelper.dnf # DNF助手 +com.tencent.gamehelper.nz # 逆战助手 +com.tencent.gamehelper.pg # 和平营地 +com.tencent.gamehelper.smoba # 王者营地 +com.tencent.gamereva # 腾讯先锋 +com.tencent.hunyuan.app.chat # 元宝-腾讯旗下AI助手 +com.tencent.igame # 王者人生 +com.tencent.karaoke # 全民K歌 +com.tencent.map # 腾讯地图 +com.tencent.mm # 微信 +com.tencent.mobileqq # QQ +com.tencent.mtt # QQ浏览器 +com.tencent.news # 腾讯新闻 +com.tencent.portfolio # 腾讯自选股 +com.tencent.qqgame # QQ游戏 +com.tencent.qqlive # 腾讯视频 +com.tencent.qqlivekid # 小企鹅乐园 +com.tencent.qqmusic # QQ音乐 +com.tencent.qqmusicpad # QQ音乐HD +com.tencent.qqpim # QQ同步助手 +com.tencent.qqpimsecure # 腾讯手机管家 +com.tencent.qqpinyin # QQ输入法 +com.tencent.qqsports # 腾讯体育 +com.tencent.qt.qtl # 掌上英雄联盟 +com.tencent.qt.sns # 掌上穿越火线 +com.tencent.tgclub # 心悦俱乐部 +com.tencent.tim # TIM +com.tencent.token # QQ安全中心 +com.tencent.transfer # 换机助手 +com.tencent.ttpic # 天天P图 +com.tencent.weishi # 腾讯微视 +com.tencent.wemeet.app # 腾讯会议 +com.tencent.weread # 微信读书 +com.tencent.wework # 企业微信 +com.tencent.xriver # 腾讯加速器 +com.tencent.zebra # 水印相机 +com.tgelec.aqsh # 安全守护2 +com.tgry.j7yr.rqtko # 壁纸素材库 +com.themewallpaper.douping # 主题动态壁纸 +com.theotino.chinadaily # China Daily +com.theotino.sztv # 无线苏州 +com.thestore.main # 1号会员店 +com.thinkive.mobile.open.account.huaan # 华安证券手机开户 +com.thinkwu.live # 千聊 +com.thinkyeah.galleryvault # 照片保险箱 +com.threegene.yeemiao # 小豆苗 +com.tianqi2345 # 2345天气王 +com.tianqiyubao2345 # 15日天气预报 +com.tianwen.jjrb # 经济日报 +com.tianxingjian.screenshot # 超级截图录屏大师 +com.tianyancha.skyeye # 天眼查 +com.tieyou.train.ark # 铁友火车票 +com.ting.mp3.android # 千千音乐 +com.tingniu.audioclip # 音频剪辑大师 +com.tinmanarts.JoJoSherlock # 叫叫识字 +com.tiqiaa.icontrol # 遥控精灵 +com.tjhxyq.show.xuan # 炫来电 +com.tmall.wireless # 天猫 +com.tmri.app.main # 交管12123 +com.tom.ule.ui # 邮乐 +com.tongcheng.android # 同程旅行 +com.tongchengpzhl.com # 同城品质婚恋 +com.tonghuashun.stocktrade.gtjaqh # 国泰君安掌上期货 +com.tongzhuo.tongzhuogame # 同桌派对 +com.topgether.sixfoot # 六只脚 +com.toutiao.yangtse # 紫牛新闻 +com.towords # 拓词 +com.tpv.familylink # 菲家 +com.trantor.genesis # 维迈通多多 +com.tripadvisor.tripadvisor.daodao # Tripadvisor猫途鹰 +com.truckhome.bbs # 卡车之家 +com.truedian.dragon # 微购相册 +com.tsf.shell.widget.message # TSF 信息小部件 +com.tst.tinsecret # TST庭秘密 +com.tttci.tik # 提客AI提词器 +com.ttxg.fruitday # 天天果园 +com.tudou.android # 土豆 +com.tujia.hotel # 途家民宿 +com.tuniu.app.ui # 途牛旅游 +com.tuoniu.pingmuezuo # 屏幕恶作剧 +com.twentyfirstcbh.epaper # 21财经 +com.ubestkid.beilehu.android # 贝乐虎儿歌 +com.ubestkid.collection.a # 贝乐虎启蒙 +com.ucar.app # 淘车车 +com.UCMobile # UC浏览器 +com.ucmobile.lite # UC浏览器极速版 +com.ujigu.ytb # 优题宝 +com.umaman.laiyifen # 来伊份社区购 +com.ume.browser # 微米浏览器 +com.umetrip.android.msky.app # 航旅纵横 +com.unicom.wopay # 沃钱包 +com.unicom.zworeader.ui # 联通爱看 +com.unionpay # 云闪付 +com.unionpay.tsmservice +com.uqwe.xmjs # 兼小宝兼职 +com.ushaqi.zhuishushenqi # 追书神器 +com.ushaqi.zhuishushenqi.adfree # 追书神器免费版 +com.uxin.usedcar # 优信二手车 +com.v2gogo.project # 百姓关注 +com.vbill.shoushua.biz # 随行付Plus +com.vcredit.ddcash # 豆豆钱 +com.vcredit.kkcredit # 维信卡卡贷 +com.versa # 马卡龙玩图 +com.video.link.heart # 心心相印 +com.videogo # 萤石云视频 +com.vistastory.news # 看天下 +com.vivo.appstore # vivo应用商店 +com.vivo.browser # vivo浏览器 +com.vivo.easyshare # 互传 +com.vivo.health # vivo健康 +com.vivo.space # vivo 官网 +com.vivo.symmetry # vivo摄影 +com.vmall.client # 华为商城 +com.vodone.o2o.health_care.demander # 医护到家-快天使医护到家用户版 +com.vphonegaga.titan # 光速虚拟机 +com.wacai365 # 挖财记账 +com.wakeup.howear # Wearfit Pro +com.wallpaper.movephsy # 桌面主题精美 +com.wallstreetcn.news # 华尔街见闻 +com.wandafilm.app # 万达影城 +com.wander.android.wallpaper # 元气壁纸 +com.wandoujia.phoenix2 # 豌豆荚 +com.wangdou.prettygirls.dress # 装扮次元手帐 +com.wangyin.payment # 京东钱包 +com.wanmeizhensuo.zhensuo # 更美 +com.waterfall.metronome # 节拍器 +com.wbxm.icartoon # 看漫 +com.we.keyuan # 微商码头 +com.webank.wemoney # 微众银行 +com.weface.kksocialsecurity # 看看生活 +com.weico.international # 微博轻享版 +com.welove520.welove # 微爱 +com.wemall.onlineshopping # WEMALL +com.wenhua.bamboo # 随身行 +com.wenhui.ebook # 文汇 +com.when.coco # 365日历 +com.whizen.iptv.activity # 万视达 +com.whty.wicity.china # 和生活 +com.wifi.reader # 连尚读书 +com.wifi.reader.free # 连尚免费读书 +com.winbaoxian.wybx # 保险师 +com.winderinfo.yashanghui # 雅商汇荟 +com.wisentsoft.chinapost.android # 中国邮政 +com.wizeyes.colorcapture # 色采 +com.wkjyqh.wukuangqihuo # 五矿期货 +com.wkscuiphone # 五矿证券 +com.wlqq # 货车帮司机 +com.wlqq4consignor # 货车帮货主 +com.wlstock.fund # 持股帮 +com.wm.dmall # 多点 +com.wodi.who # 玩吧 +com.wole56.ishow # 我秀 +com.womai # 中粮我买网 +com.wondertek.cj_yun # 长江云 +com.wondertek.miguaikan # 咪咕视频爱看版 +com.wondertek.paper # 澎湃新闻 +com.woqu.attendance # 喔趣考勤 +com.wsecar.wsjc # 万顺叫车 +com.wshifu.app.jiedanyi # 万师傅师傅版 +com.wuba # 58同城 +com.wuba.huoyun # 快狗打车 +com.wuba.town.client # 58本地 +com.wuba.zhuanzhuan # 转转 +com.wudaokou.hippo # 盒马 +com.wuhquantuo.ringtonedaquanfree # 铃声大全免费版 +com.www.ccoocity.ui # 城市通 +com.xcar.activity # 爱卡汽车 +com.xdf.recite # 新东方乐词 +com.xdtech.news.greatriver # 江西新闻 +com.xfplay.play # 影音先锋 +com.xhey.xcamera # 今日水印相机 +com.xhqb.app # 小花钱包 +com.xiachufang # 下厨房 +com.xiangha # 香哈菜谱 +com.xiangjiaosp.bject # 香蕉视频 +com.xiangrikui.sixapp # 向日葵保险人 +com.xianqing.driving3d # 驾考模拟3D +com.xiaobanlong.main # 小伴龙 +com.xiaodao.jiemeng # 复真书法 +com.xiaoenai.app # 小恩爱 +com.xiaoji.emulator # 小鸡模拟器 +com.xiaomi.gamecenter.sdk.service # 游戏服务 +com.xiaomi.hm.health # Zepp Life +com.xiaomi.market # 小米应用商店 +com.xiaomi.mico # 小爱音箱 +com.xiaomi.mitv.phone.tvassistant # 小米电视助手 +com.xiaomi.router # 小米WiFi +com.xiaomi.shop # 小米商城 +com.xiaomi.smarthome # 米家 +com.xiaomi.youpin # 小米有品 +com.xiaoshijie.sqb # 好省 +com.xiaoying.cardloan # 小赢卡贷 +com.xiaoyusan.android # 小雨伞 +com.xiaozhu.xzdz # 小猪民宿 +com.xiguakeji.bddh # 北斗侠导航 +com.ximalaya.ting.android # 喜马拉雅 +com.xingin.xhs # 小红书 +com.xingluo.mpa # 魔力相册 +com.xinhang.mobileclient # 中国移动河南 +com.xinhu.futures # 新湖期货开户交易 +com.xinli.fm # 心理Fm +com.xinshuru.inputmethod # 手心输入法 +com.xinyongfei.xyf # 信用飞 +com.xinyu210.enchdictionary # 英汉词典 +com.xiongmaoqiming.quming # 真熊猫起名 +com.xiuyuan.phonelive # 秀员 +com.xiwei.logistics.consignor # 运满满货主 +com.xk.span.zutuan # 美逛 +com.xmcy.hykb # 好游快爆 +com.xmtj.mkz # 漫客栈 +com.xqw369.ttsz # 天天识字 +com.xs.fm # 番茄畅听 +com.xs.fm.lite # 番茄音乐(原畅听音乐) +com.xt.retouch # 醒图 +com.xtc.watch # 小天才 +com.xtuone.android.syllabus # 超级课程表 +com.xuanyou2022.bizhi # 好看手机壁纸 +com.xueersi.parentsmeeting # 学而思网校 +com.xueqiu.android # 雪球 +com.xuexiang.myring # 炫铃声 +com.xunlei.downloadprovider # 迅雷 +com.xunmeng.merchant # 拼多多商家版 +com.xunmeng.pinduoduo # 拼多多 +com.xunyou.rb # 次元姬小说 +com.xvideostudio.videoeditor # 乐秀视频剪辑视频编辑 +com.xyhui # PU口袋校园 +com.yangcong345.android.phone # 洋葱学园 +com.yaoo.qlauncher # 如意老人桌面 +com.yaoyu.wanzhou # 看万州 +com.yaya.zone # 叮咚买菜 +com.ybws.ucast # Ucast +com.ycwb.android.ycpai # 羊城派 +com.ydtx.camera # 元道经纬相机 +com.yeahka.mach.android.openpos # 乐刷商务版 +com.yek.android.game5173 # 5173 +com.yek.android.kfc.activitys # 肯德基 +com.yek.android.uniqlo # 优衣库 +com.ygkj.chelaile.standard # 车来了 +com.ygxc.student # 阳光学车 +com.yiban.app # 易班 +com.yiban1413.yiban # 一伴婚恋专业版 +com.yibanlu.luck # 知趣聊天 +com.yibasan.lizhifm # 荔枝 +com.yibo.xiaoguan.toc # 销冠输入法 +com.yicai.news # 第一财经 +com.yiche.autoeasy # 易车 +com.yiche.price # 易车汽车报价 +com.yidian.xiaomi # 一点新闻资讯 +com.yigequanquanqq.app # 一个圈圈 +com.yigusjgh.daoshuri # 倒计时DDD +com.yilesoft.app.beautifulimageshow # 图片文字美化 +com.yingsoft.ksbao.Activity # 考试宝典 +com.yingyonghui.market # 应用汇 +com.yipiao # 智行火车票 +com.yiruike.sodacn.android # 甜盐相机 +com.yitong.fjnx.mbank.android # 福建农信 +com.yitong.mbank # 江苏·农商行 +com.yitong.mbank.psbc # 邮储银行 +com.yiwang # 1药网 +com.yiwugou.yiwukan # 义乌购 +com.yixia.videoeditor # 秒拍 +com.yixianghua # 宜享花 +com.yiyou.ga # TT语音 +com.ylmf.androidclient # 115生活 +com.ymatou.seller # 洋码头扫货神器买手版 +com.ymatou.shop # 洋码头 +com.ymt360.app.mass # 一亩田 +com.yoloho.dayima # 大姨妈 +com.youba.barcode # 二维码扫描 +com.youcheyihou.iyoursuv # 有车以后 +com.youdao.dict # 网易有道词典 +com.youdao.note # 有道云笔记 +com.youdao.translator # 有道翻译官 +com.yougou # 优购时尚商城 +com.youku.crazytogether # 来疯 +com.youku.phone # 优酷视频 +com.youloft.calendar # 万年历 +com.youxiang.soyoungapp # 新氧医美 +com.ysy.ayy # 木鸟民宿 +com.yuanli.recordingSoftware # 录音软件 +com.yuanli.wallpapermaster # 手机壁纸大师 +com.yuanlue.chongwu # 互动桌面宠物 +com.yuantiku.tutor # 猿辅导 +com.yuedong.sport # 悦动圈 +com.yueme.itv # 小翼管家 +com.yuewen.cooperate.reader.free # 飞读小说 +com.yuexiang.wallpaper # 悦享动态壁纸 +com.yumc.phsuperapp # 必胜客 +com.yumei.game.engine.ui.client # 玩股成金 +com.yunlan.unlock.dewdropunlock # 3D水滴-随变锁屏 +com.yunlian.wewe # 微微网络电话 +com.yunxiao.haofenshu # 好分数 +com.yuyuesports.yuyue.android # 羽约运动 +com.ywallpaper.ddztbz # 多多主题壁纸 +com.yx # 有信 +com.yxcps.trainning # 益学堂 +com.yy.android.udbsec # YY安全中心 +com.yy.dreamer # 知偶 +com.yy.huanju # Hello +com.yy.yomi # Yo语音 +com.yyyfs.wallpaper # 元宇宙壁纸 +com.zaxd.loan # 众安贷 +com.zcbl.bjjj_driving # 北京交警 +com.zenmen.palmchat # 连信 +com.zerone.hidesktop # 我的桌面iScreen +com.zhangdan.app # 51信用卡管家 +com.zhangxiong.art # 张雄艺术 +com.zhanlang.dailyscreen # 录屏君 +com.zhaopin.social # 智联招聘 +com.zhenai.android # 珍爱 +com.zhibo.mm # 秘密直播 +com.zhichan.msmds # 买什么都省 +com.zhihu.android # 知乎 +com.zhihu.daily.android # 知乎日报 +com.zhinengremote.mohan # 空调智能遥控器+ +com.zhiyaolai.playschool # 锦鲤宇宙 +com.zhongan.insurance # 众安保险 +com.zhubajie.client # 猪八戒 +com.zhutisp.sdbzcm # 主题商店免费 +com.ziipin.homeinn # 首旅如家 +com.ziipin.softkeyboard.kazakh # Badam哈语输入法 +com.zlj.picture.recover.restore.master # 图片恢复 +com.zmlearn.course.am # 掌门1对1辅导 +com.zol.android # 中关村在线 +com.zongheng.reader # 纵横小说 +com.zstv # 中山手机台 +com.zsyj.trans # 全局透明壁纸 +com.zsyj.wallpaper # 熊猫动态壁纸 +com.zuinengsheng.io # 奶萌 +com.zxscnew # 中信证券 +com.zybang.parent # 作业帮口算 +com.zydsoft.acuherb # 中医辨证云 +com.zyhd.voice # 语音包变声器 +com.zz.voip # 小鹿网络电话 +com.zzu.sxm.chemical # 化学方程式 +com.zzw.october # 志愿汇 +consumer.ttpc.com.consumer # 天天拍车 +cszy.wdtk.dntkbz # 我的图库 +ctrip.android.view # 携程旅行 +dji.go.v5 # DJI Fly +dongzheng.szkingdom.android.phone # 掌证宝 +enfc.metro # 亿通行 +epson.print # Epson iPrint +fkg.client.side.activity # 蜂狂购 +flipboard.cn # 红板报 +fm.qingting.qtradio # 蜻蜓FM +gov.pianzong.androidnga # NGA玩家社区 +gyjf.alfdcyj.ooo # 囧次元 +gz.lifesense.weidong # 乐心健康 +gzry.mxbz.ihauh # 魔秀桌面壁纸 +hbyc.china.medical.view # 壹生 +hg.zp.ui # 天眼新闻 +howbuy.android.palmfund # 好买基金 +howbuy.android.piggy # 储蓄罐 +hugh.android.app.zidianadd # 新华字典补丁 +im.weshine.keyboard # KK键盘 +im.xingzhe # 行者 +im.yixin # 易信 +io.dcloud.H576E6CC7 # 鱼泡直聘 +io.dcloud.H5999961F # 宝宝云 +io.dcloud.Pintui # 品推资讯 +io.iftech.android.box # 小组件盒子 +jiakaokeer.app.good # 驾考宝典科目二 +jiakaokesi.app.good # 驾考宝典极速版 +jiakaotong.app.good # 考试全能王 +jiakaozhushou.app.good # 驾考助手 +jiaxiao.app.good # 驾照宝典 +jp.ne.neko.freewing.JapaneseKeyboard # 简易的日语输入 日文输入法 五十音图 虚拟键盘方式 +jyeoo.app.ystudy # 菁优网 +kehuo.app.good # 驾考宝典客货车 +llbt.ccb.lnga # 辽事通 +long.xian.wallpaper # 免费主题商店 +longbin.helloworld # 计算管家 +mall.dfs168.com.mall # 大丰收农服 +mark.via # Via +me.chunyu.ChunyuDoctor # 春雨医生 +me.ele # 饿了么 +me.ele.napos # 淘宝闪购商家版 +me.gfuil.bmap +me.iweek.rili # 生活日历 +me.niuke.app.gupiaozhitou # 阿牛智投 +me.piebridge.brevent # 黑阈 +me.suncloud.marrymemo # 婚礼纪 +me.yidui # 伊对 +music.f2bcutter.mp3editor # 音乐音频剪辑 +my.beautyCamera # 美人相机 +my.maya.android # 多闪 +net.aituyue.app # 图曰 +net.ask123.mima96 # 密码酷 +net.bontec.wxqd.activity # 爱青岛 +net.cmoaciopm.sgsdict # 三国杀大典 +net.joydao.radio # 听听广播 +net.joydao.spring2011 # 祝福短信 +net.kdnet.club # 凯迪网 +net.qihoo.launcher.widget.clockweather # 360天气 +net.whty.app.eyu # 人人通空间 +net.xinhuamm.mainclient # 新华社 +net.zdsoft.zerobook_android # 智人播客 +news.cnr.cn # 央广网 +oms.mmc.app.almanac_inland # 老黄历万年历顺历 +oms.mmc.fortunetelling # 灵机 +one.hh.oneclient # 一个 +org.fungo.fungolive # 天天电视直播 +org.moegirl.moegirlview # 萌娘百科 +org.mozilla.fennec_mylinux # 蚂蚁浏览器 +org.sojex.finance # 口袋贵金属 +org.thinkinbunny.autoanswer # 自动应答 +org.zywx.wbpalmstar.widgetone.uex10075364 # 经管之家 +org.zywx.wbpalmstar.widgetone.uexaaagg10001 # 安全守护 +org.zywx.wbpalmstar.widgetone.uexaaagg10013 # 关爱守护 +paint.by.number.pixel.art.coloring.drawing.puzzle # 乐涂数字填色 +pet.desktop.elf # 图图宠物 +photo.editor.polarr # 泼辣修图 +pinkdiary.xiaoxiaotu.com # 粉粉日记 +protect.eye # 护眼宝 +qianlong.qlmobile # 钱龙 +qnahj.water.xianx # 水仙 +sg.bigo.shrimp # 皮皮蟹 +shengang.szkingdom.android.phone # 申港证券 +sina.mobile.tianqitong # 天气通 +sl.mySms # Q信 +smskb.com # 盛名时刻表 +software.ecenter.study # 状元共享课堂 +sogou.mobile.explorer # 搜狗浏览器极速版 +sr.daiv.alls.ja # 基础日语口语 +sr.daiv.alls.ko # 基础韩语口语 +ts.NetTraffic # 网速流量监测 +tt.lt.tt # 聊天漂流瓶 +tv.acfundanmaku.video # AcFun +tv.danmaku.bili # 哔哩哔哩 +tv.danmaku.bilibilihd # 哔哩哔哩HD +tv.pps.mobile # 爱奇艺随刻 +tv.yilan.howto.app # 好兔视频 +tv.yixia.bobo # 波波视频 +us.pinguo.april_collage # 拼图酱 +vStudio.Android.Camera360 # 相机360 +vz.com # 飞常准 +weicom.yi.wallpaper # 免费主题壁纸 +wind.deposit # 万得基金 +wshifu.app.android # 万师傅企业版 +yiqi.bazi # 易奇文化 +ysykzheng.hotlauncher # 热门启动器 +yyc.xk # 星空视频壁纸 +zhongxinjiantou.szkingdom.android.newphone # 蜻蜓点金 diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml index efbcacffcc..dff9194d6f 100644 --- a/app/src/main/res/xml/network_security_config.xml +++ b/app/src/main/res/xml/network_security_config.xml @@ -1,10 +1,11 @@ - + + - \ No newline at end of file + diff --git a/common/src/main/java/com/github/kr328/clash/common/constants/Intents.kt b/common/src/main/java/com/github/kr328/clash/common/constants/Intents.kt index b754dc701a..ae86290a7b 100644 --- a/common/src/main/java/com/github/kr328/clash/common/constants/Intents.kt +++ b/common/src/main/java/com/github/kr328/clash/common/constants/Intents.kt @@ -16,6 +16,8 @@ object Intents { val ACTION_CLASH_STARTED = "$packageName.intent.action.CLASH_STARTED" val ACTION_CLASH_STOPPED = "$packageName.intent.action.CLASH_STOPPED" val ACTION_CLASH_REQUEST_STOP = "$packageName.intent.action.CLASH_REQUEST_STOP" + val ACTION_CLASH_REQUEST_RELOAD = "$packageName.intent.action.CLASH_REQUEST_RELOAD" + val ACTION_CLASH_RELOAD_REJECTED = "$packageName.intent.action.CLASH_RELOAD_REJECTED" val ACTION_PROFILE_CHANGED = "$packageName.intent.action.PROFILE_CHANGED" val ACTION_PROFILE_UPDATE_COMPLETED = "$packageName.intent.action.PROFILE_UPDATE_COMPLETED" val ACTION_PROFILE_UPDATE_FAILED = "$packageName.intent.action.PROFILE_UPDATE_FAILED" diff --git a/design/build.gradle.kts b/design/build.gradle.kts index 6f0c4c141a..afe0796e13 100644 --- a/design/build.gradle.kts +++ b/design/build.gradle.kts @@ -19,4 +19,6 @@ dependencies { implementation(libs.androidx.viewpager) implementation(libs.google.material) implementation(libs.zxing.core) + + testImplementation(libs.junit) } diff --git a/design/src/main/java/com/github/kr328/clash/design/AccessControlDesign.kt b/design/src/main/java/com/github/kr328/clash/design/AccessControlDesign.kt index cc1d2c8000..061fa9bc0d 100644 --- a/design/src/main/java/com/github/kr328/clash/design/AccessControlDesign.kt +++ b/design/src/main/java/com/github/kr328/clash/design/AccessControlDesign.kt @@ -3,12 +3,18 @@ package com.github.kr328.clash.design import android.content.Context import android.view.View import androidx.core.widget.addTextChangedListener +import androidx.recyclerview.widget.ConcatAdapter import com.github.kr328.clash.design.adapter.AppAdapter +import com.github.kr328.clash.design.adapter.AppSetAdapter +import com.github.kr328.clash.design.adapter.AppSetRow 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.model.AppSetIds +import com.github.kr328.clash.design.model.appSetMembership +import com.github.kr328.clash.design.model.applyAppSetToggle import com.github.kr328.clash.design.store.UiStore import com.github.kr328.clash.design.util.* import kotlinx.coroutines.* @@ -31,7 +37,13 @@ class AccessControlDesign( private val binding = DesignAccessControlBinding .inflate(context.layoutInflater, context.root, false) - private val adapter = AppAdapter(context, selected) + private val setAdapter = AppSetAdapter(context) { id -> toggleAppSet(id) } + + private val adapter = AppAdapter(context, selected) { refreshSetRows() } + + private var systemPackages: Set = emptySet() + + private var domesticPackages: Set = emptySet() private val menu: AccessControlMenu by lazy { AccessControlMenu(context, binding.menuView, uiStore, requests) @@ -47,8 +59,17 @@ class AccessControlDesign( adapter.swapDataSet(adapter::apps, apps, false) } + suspend fun patchBuiltinSets(systemPackages: Set, domesticPackages: Set) { + this.systemPackages = systemPackages + this.domesticPackages = domesticPackages + withContext(Dispatchers.Main) { + setAdapter.submit(buildRows()) + } + } + suspend fun rebindAll() { withContext(Dispatchers.Main) { + refreshSetRows() adapter.rebindAll() } } @@ -60,7 +81,7 @@ class AccessControlDesign( binding.mainList.recyclerList.also { it.bindAppBarElevation(binding.activityBarLayout) - it.applyLinearAdapter(context, adapter) + it.applyLinearAdapter(context, ConcatAdapter(setAdapter, adapter)) } binding.menuView.setOnClickListener { @@ -84,7 +105,7 @@ class AccessControlDesign( coroutineScope { val binding = DialogSearchBinding .inflate(context.layoutInflater, context.root, false) - val adapter = AppAdapter(context, selected) + val adapter = AppAdapter(context, selected) { refreshSetRows() } val dialog = FullScreenDialog(context) val filter = Channel(Channel.CONFLATED) @@ -131,4 +152,53 @@ class AccessControlDesign( } } } + + private fun refreshSetRows() { + setAdapter.submit(buildRows()) + } + + private fun toggleAppSet(id: String) { + val members = when (id) { + AppSetIds.SYSTEM -> systemPackages + AppSetIds.DOMESTIC -> domesticPackages + else -> return + } + applyAppSetToggle(members, selected) + refreshSetRows() + adapter.rebindAll() + } + + private fun buildRows(): List { + return listOf( + row( + AppSetIds.SYSTEM, + context.getString(R.string.system_apps), + R.drawable.ic_baseline_apps, + systemPackages, + ), + row( + AppSetIds.DOMESTIC, + context.getString(R.string.app_set_domestic), + R.drawable.ic_baseline_domain, + domesticPackages, + ), + ) + } + + private fun row(id: String, title: String, iconRes: Int, members: Set): AppSetRow { + val membership = appSetMembership(members, selected) + val summary = if (members.isEmpty()) { + context.getString(R.string.app_set_summary_empty) + } else { + context.getString(R.string.app_set_summary, membership.total, membership.selectedCount) + } + return AppSetRow( + id = id, + title = title, + summary = summary, + iconRes = iconRes, + checkState = membership.state, + enabled = members.isNotEmpty(), + ) + } } \ No newline at end of file diff --git a/design/src/main/java/com/github/kr328/clash/design/WebdavSettingsDesign.kt b/design/src/main/java/com/github/kr328/clash/design/WebdavSettingsDesign.kt index 5210472552..6a43ea4d56 100644 --- a/design/src/main/java/com/github/kr328/clash/design/WebdavSettingsDesign.kt +++ b/design/src/main/java/com/github/kr328/clash/design/WebdavSettingsDesign.kt @@ -3,6 +3,7 @@ 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.dialog.withModelProgressBar import com.github.kr328.clash.design.dialog.requestModelTextInput import com.github.kr328.clash.design.preference.ClickablePreference import com.github.kr328.clash.design.preference.NullableTextAdapter @@ -29,7 +30,7 @@ class WebdavSettingsDesign( status: String, ) : Design(context) { enum class Request { - Test, Sync, Pull, + Test, Sync, Pull, Push, } private val binding = DesignSettingsCommonBinding @@ -61,6 +62,16 @@ class WebdavSettingsDesign( empty = R.string.webdav_url_placeholder, ) + editableText( + value = store::lanUrl, + adapter = NullableTextAdapter.String, + title = R.string.webdav_lan_url, + placeholder = R.string.webdav_unset, + empty = R.string.webdav_unset, + ) + + tips(R.string.webdav_lan_hint) + editableText( value = store::username, adapter = NullableTextAdapter.String, @@ -127,12 +138,65 @@ class WebdavSettingsDesign( ) { clicked { requests.trySend(Request.Pull) } } + + clickable( + title = R.string.webdav_push, + icon = R.drawable.ic_baseline_cloud_upload, + ) { + clicked { requests.trySend(Request.Push) } + } } binding.content.addView(screen.root) setStatus(status) } + suspend fun withProgress( + block: suspend (suspend (phase: String, current: Int, total: Int, label: String) -> Unit) -> String, + ): String { + var result = "" + context.withModelProgressBar { + configure { + isIndeterminate = true + text = context.getString(R.string.webdav_progress_connect) + } + result = block { phase, current, total, label -> + configure { + text = formatWebdavProgress(phase, current, total, label) + if (total <= 0) { + isIndeterminate = true + } else { + isIndeterminate = false + max = total + progress = current.coerceIn(0, total) + } + } + } + } + return result + } + + private fun formatWebdavProgress(phase: String, current: Int, total: Int, label: String): String { + val name = label.substringAfterLast('/').removeSuffix(".json") + return when (phase) { + "list" -> context.getString(R.string.webdav_progress_list) + "download" -> namedProgress(R.string.webdav_progress_download, R.string.webdav_progress_download_named, current, total, name) + "apply" -> namedProgress(R.string.webdav_progress_apply, R.string.webdav_progress_apply_named, current, total, name) + "upload" -> namedProgress(R.string.webdav_progress_upload, R.string.webdav_progress_upload_named, current, total, name) + "delete" -> namedProgress(R.string.webdav_progress_delete, R.string.webdav_progress_delete_named, current, total, name) + else -> context.getString(R.string.webdav_progress_connect) + } + } + + private fun namedProgress(plain: Int, named: Int, current: Int, total: Int, name: String): String { + val count = total.coerceAtLeast(current) + return if (name.isBlank()) { + context.getString(plain, current, count) + } else { + context.getString(named, current, count, name) + } + } + fun setStatus(text: String) { statusPreference?.text = text.ifBlank { context.getString(R.string.webdav_status_empty) } } diff --git a/design/src/main/java/com/github/kr328/clash/design/adapter/AppAdapter.kt b/design/src/main/java/com/github/kr328/clash/design/adapter/AppAdapter.kt index 762c59d08b..1d44221d63 100644 --- a/design/src/main/java/com/github/kr328/clash/design/adapter/AppAdapter.kt +++ b/design/src/main/java/com/github/kr328/clash/design/adapter/AppAdapter.kt @@ -11,6 +11,7 @@ import com.github.kr328.clash.design.util.root class AppAdapter( private val context: Context, private val selected: MutableSet, + private val onSelectionChanged: () -> Unit = {}, ) : RecyclerView.Adapter() { class Holder(val binding: AdapterAppBinding) : RecyclerView.ViewHolder(binding.root) @@ -40,6 +41,7 @@ class AppAdapter( selected.add(current.packageName) holder.binding.selected = true } + onSelectionChanged() } } diff --git a/design/src/main/java/com/github/kr328/clash/design/adapter/AppSetAdapter.kt b/design/src/main/java/com/github/kr328/clash/design/adapter/AppSetAdapter.kt new file mode 100644 index 0000000000..50b89e569d --- /dev/null +++ b/design/src/main/java/com/github/kr328/clash/design/adapter/AppSetAdapter.kt @@ -0,0 +1,116 @@ +package com.github.kr328.clash.design.adapter + +import android.content.Context +import android.graphics.drawable.Drawable +import android.view.View +import android.view.ViewGroup +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.RecyclerView +import com.github.kr328.clash.design.R +import com.github.kr328.clash.design.databinding.AdapterAppSetBinding +import com.github.kr328.clash.design.databinding.AdapterAppSetHeaderBinding +import com.github.kr328.clash.design.model.AppSetCheckState +import com.github.kr328.clash.design.util.layoutInflater +import com.github.kr328.clash.design.util.root + +internal data class AppSetRow( + val id: String, + val title: String, + val summary: String, + val iconRes: Int, + val checkState: AppSetCheckState, + val enabled: Boolean, +) + +internal class AppSetAdapter( + private val context: Context, + private val onToggle: (String) -> Unit, +) : RecyclerView.Adapter() { + var rows: List = emptyList() + private set + + fun submit(rows: List) { + val oldCount = itemCount + this.rows = rows + val newCount = itemCount + if (oldCount == newCount) { + if (rows.isNotEmpty()) notifyItemRangeChanged(1, rows.size) + } else { + if (oldCount > 0) notifyItemRangeRemoved(0, oldCount) + if (newCount > 0) notifyItemRangeInserted(0, newCount) + } + } + + override fun getItemCount(): Int { + return if (rows.isEmpty()) 0 else rows.size + 2 + } + + override fun getItemViewType(position: Int): Int { + return when (position) { + 0 -> TYPE_HEADER + itemCount - 1 -> TYPE_DIVIDER + else -> TYPE_SET + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + return when (viewType) { + TYPE_HEADER -> HeaderHolder( + AdapterAppSetHeaderBinding.inflate(context.layoutInflater, context.root, false) + ) + TYPE_DIVIDER -> DividerHolder( + context.layoutInflater.inflate(R.layout.adapter_app_set_divider, context.root, false) + ) + else -> SetHolder( + AdapterAppSetBinding.inflate(context.layoutInflater, context.root, false), + ContextCompat.getDrawable(context, R.drawable.ic_app_set_indeterminate)?.mutate(), + ) + } + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + when (holder) { + is HeaderHolder -> holder.binding.title = context.getString(R.string.app_sets) + is SetHolder -> bindSet(holder, rows[position - 1]) + } + } + + private fun bindSet(holder: SetHolder, row: AppSetRow) { + holder.binding.title = row.title + holder.binding.summary = row.summary + holder.binding.enabled = row.enabled + holder.binding.executePendingBindings() + holder.binding.iconView.setImageResource(row.iconRes) + val checked = row.checkState == AppSetCheckState.Checked + holder.binding.switchView.buttonDrawable = if (row.checkState == AppSetCheckState.Indeterminate) { + holder.indeterminateDrawable + } else { + holder.checkboxDrawable + } + holder.binding.switchView.isChecked = checked + holder.binding.root.setOnClickListener { + if (row.enabled) onToggle(row.id) + } + } + + private class HeaderHolder( + val binding: AdapterAppSetHeaderBinding, + ) : RecyclerView.ViewHolder(binding.root) + + private class SetHolder( + val binding: AdapterAppSetBinding, + val indeterminateDrawable: Drawable?, + ) : RecyclerView.ViewHolder(binding.root) { + val checkboxDrawable: Drawable? = + binding.switchView.buttonDrawable?.constantState?.newDrawable()?.mutate() + ?: binding.switchView.buttonDrawable + } + + private class DividerHolder(view: View) : RecyclerView.ViewHolder(view) + + private companion object { + const val TYPE_HEADER = 0 + const val TYPE_SET = 1 + const val TYPE_DIVIDER = 2 + } +} diff --git a/design/src/main/java/com/github/kr328/clash/design/model/AppSet.kt b/design/src/main/java/com/github/kr328/clash/design/model/AppSet.kt new file mode 100644 index 0000000000..aac8ef2c13 --- /dev/null +++ b/design/src/main/java/com/github/kr328/clash/design/model/AppSet.kt @@ -0,0 +1,48 @@ +package com.github.kr328.clash.design.model + +enum class AppSetCheckState { + Checked, + Unchecked, + Indeterminate, +} + +data class AppSetMembership( + val state: AppSetCheckState, + val selectedCount: Int, + val total: Int, +) + +object AppSetIds { + const val SYSTEM = "system" + const val DOMESTIC = "domestic" +} + +/** + * A set is checked only when every current member is selected. + * An empty set stays unchecked and is not actionable. + */ +fun appSetMembership(members: Set, selected: Set): AppSetMembership { + var selectedCount = 0 + for (member in members) { + if (member in selected) selectedCount++ + } + val state = when { + members.isEmpty() || selectedCount == 0 -> AppSetCheckState.Unchecked + selectedCount == members.size -> AppSetCheckState.Checked + else -> AppSetCheckState.Indeterminate + } + return AppSetMembership(state, selectedCount, members.size) +} + +/** + * Checking a partial or empty selection selects every current member. + * Checking a fully selected set clears only those members. + */ +fun applyAppSetToggle(members: Set, selected: MutableSet) { + if (members.isEmpty()) return + if (appSetMembership(members, selected).state == AppSetCheckState.Checked) { + selected.removeAll(members) + } else { + selected.addAll(members) + } +} diff --git a/design/src/main/res/drawable/ic_app_set_indeterminate.xml b/design/src/main/res/drawable/ic_app_set_indeterminate.xml new file mode 100644 index 0000000000..da80c902df --- /dev/null +++ b/design/src/main/res/drawable/ic_app_set_indeterminate.xml @@ -0,0 +1,11 @@ + + + + diff --git a/design/src/main/res/drawable/ic_baseline_cloud_upload.xml b/design/src/main/res/drawable/ic_baseline_cloud_upload.xml new file mode 100644 index 0000000000..006b014c64 --- /dev/null +++ b/design/src/main/res/drawable/ic_baseline_cloud_upload.xml @@ -0,0 +1,10 @@ + + + diff --git a/design/src/main/res/layout/adapter_app_set.xml b/design/src/main/res/layout/adapter_app_set.xml new file mode 100644 index 0000000000..d5cd9dcaab --- /dev/null +++ b/design/src/main/res/layout/adapter_app_set.xml @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/design/src/main/res/layout/adapter_app_set_divider.xml b/design/src/main/res/layout/adapter_app_set_divider.xml new file mode 100644 index 0000000000..0cb8336495 --- /dev/null +++ b/design/src/main/res/layout/adapter_app_set_divider.xml @@ -0,0 +1,7 @@ + + diff --git a/design/src/main/res/layout/adapter_app_set_header.xml b/design/src/main/res/layout/adapter_app_set_header.xml new file mode 100644 index 0000000000..7a709890ea --- /dev/null +++ b/design/src/main/res/layout/adapter_app_set_header.xml @@ -0,0 +1,17 @@ + + + + + + + + diff --git a/design/src/main/res/values-ja-rJP/strings.xml b/design/src/main/res/values-ja-rJP/strings.xml index 6972ddd290..99300a87d1 100644 --- a/design/src/main/res/values-ja-rJP/strings.xml +++ b/design/src/main/res/values-ja-rJP/strings.xml @@ -118,6 +118,10 @@ アクセスコントロールモード アクセスコントロールリスト アプリプロファイルへのアクセスを許可 + アプリセット + 中国でよく使うアプリ + インストール済み %1$d · 選択済み %2$d + 未インストール Clash実行時にこのオプションは利用できません 一般 DNS @@ -272,4 +276,36 @@ 新規構成 構成名 構成名の編集 + WebDAV 同期 + 保存済みのサブスクリプションと、各グループのパッケージ名を同期します。既定のアドレスは https です。LAN アドレスがあるときは Wi-Fi(インターネット未接続を含む)で先に試します。LAN アドレスが空なら使いません。モバイルデータは既定のアドレスだけを使います。同期は先にマージし、他の端末にだけあるサブスクリプションやグループは削除しません。編集中で未保存の設定はアップロードしません。アップロードのみは、この端末の設定を変えません。 + 未同期 + サーバー + アドレス + https://alist.taokdao.com/dav + LAN アドレス + Wi-Fi のときだけ使います。インターネット未接続の Wi-Fi でも試します。空にすると LAN を使いません。WebDAV として応答しない場合は既定のアドレスを使います。プライベートネットワークでは http を使えます。例: http://192.168.1.69:5244/dav。 + ユーザー名 + パスワード + 設定済み + 未設定 + リモートフォルダ + /sync/Configs/clash-meta + 自動同期 + 起動後、およびサブスクリプションやグループのパッケージ名が変わったあとに同期します + 接続をテスト + 今すぐ同期 + ダウンロードのみ + アップロードのみ + 同期しています… + 接続しています… + リモートのファイルを列挙しています… + ダウンロード %1$d/%2$d + ダウンロード %1$d/%2$d · %3$s + この端末に書き込み %1$d/%2$d + この端末に書き込み %1$d/%2$d · %3$s + アップロード %1$d/%2$d + アップロード %1$d/%2$d · %3$s + リモートを削除 %1$d/%2$d + リモートを削除 %1$d/%2$d · %3$s + アプリ一覧は保存しましたが、Android が VPN の作り直しを拒否しました。動作中のトンネルは以前の一覧のままです。 diff --git a/design/src/main/res/values-ko-rKR/strings.xml b/design/src/main/res/values-ko-rKR/strings.xml index fd58573b47..b73843bb26 100644 --- a/design/src/main/res/values-ko-rKR/strings.xml +++ b/design/src/main/res/values-ko-rKR/strings.xml @@ -118,6 +118,10 @@ 연결 허용 모드 연결 허용 목록 앱별 연결 허용 관리 + 앱 모음 + 중국에서 자주 쓰는 앱 + 설치 %1$d · 선택 %2$d + 설치되지 않음 Clash 연결인 상태에서는 변경할 수 없습니다. 일반 DNS @@ -272,4 +276,36 @@ 새 구성 구성 이름 구성 이름 편집 + WebDAV 동기화 + 저장된 구독과 각 그룹의 패키지 이름을 동기화합니다. 기본 주소는 https 여야 합니다. LAN 주소를 적어 두면 Wi-Fi에서 먼저 시도하며, 인터넷이 없는 Wi-Fi도 포함합니다. LAN 주소가 비어 있으면 건너뜁니다. 모바일 데이터는 기본 주소만 사용합니다. 동기화는 먼저 병합하며, 다른 기기에만 있는 구독이나 그룹은 지우지 않습니다. 편집 중이라 아직 저장되지 않은 구성은 올리지 않습니다. 업로드만 하면 이 휴대전화의 구성은 바뀌지 않습니다. + 아직 동기화하지 않음 + 서버 + 주소 + https://alist.taokdao.com/dav + LAN 주소 + Wi-Fi에서만 사용합니다. 인터넷이 없는 Wi-Fi도 시도합니다. 비워 두면 LAN을 끄게 됩니다. WebDAV로 응답하지 않으면 기본 주소를 사용합니다. 사설망은 http를 쓸 수 있습니다. 예: http://192.168.1.69:5244/dav. + 사용자 이름 + 비밀번호 + 설정됨 + 설정 안 됨 + 원격 폴더 + /sync/Configs/clash-meta + 자동 동기화 + 시작한 뒤, 그리고 구독이나 그룹의 패키지 이름이 바뀐 뒤에 동기화합니다 + 연결 테스트 + 지금 동기화 + 다운로드만 + 업로드만 + 동기화하는 중… + 연결하는 중… + 원격 파일을 나열하는 중… + 다운로드 %1$d/%2$d + 다운로드 %1$d/%2$d · %3$s + 이 휴대전화에 쓰는 중 %1$d/%2$d + 이 휴대전화에 쓰는 중 %1$d/%2$d · %3$s + 업로드 %1$d/%2$d + 업로드 %1$d/%2$d · %3$s + 원격 파일 삭제 %1$d/%2$d + 원격 파일 삭제 %1$d/%2$d · %3$s + 앱 목록은 저장되었지만 Android가 VPN을 다시 만들지 않았습니다. 실행 중인 터널은 이전 목록을 그대로 사용합니다. diff --git a/design/src/main/res/values-ru/strings.xml b/design/src/main/res/values-ru/strings.xml index 9e3b3c10a5..867649f3d3 100644 --- a/design/src/main/res/values-ru/strings.xml +++ b/design/src/main/res/values-ru/strings.xml @@ -141,6 +141,10 @@ Режим контроля доступа Приложения с контролируемым доступом Настройте доступ для приложения + Наборы приложений + Популярные китайские приложения + Установлено %1$d · Выбрано %2$d + Не установлено Настройки недоступны, пока Clash не будет остановлен @@ -336,4 +340,36 @@ Новая конфигурация Название конфигурации Изменить название конфигурации + Синхронизация WebDAV + Синхронизируются сохранённые подписки и имена пакетов в каждой группе. Адрес по умолчанию должен быть https. Если указан адрес LAN, он проверяется первым при Wi-Fi, в том числе без доступа в интернет. Пустой адрес LAN не используется. Мобильная сеть использует только адрес по умолчанию. Синхронизация сначала объединяет данные и не удаляет подписки или группы, которые есть только на другом устройстве. Ещё не сохранённые профили не отправляются. «Только отправка» не меняет профили на этом телефоне. + Ещё не синхронизировано + Сервер + Адрес + https://alist.taokdao.com/dav + Адрес LAN + Используется только в Wi-Fi, в том числе без интернета. Оставьте поле пустым, чтобы отключить LAN. Если адрес не отвечает как WebDAV, используется адрес по умолчанию. В частной сети можно указать http, например http://192.168.1.69:5244/dav. + Имя пользователя + Пароль + Задан + Не задан + Удалённая папка + /sync/Configs/clash-meta + Автосинхронизация + Синхронизировать после запуска и после изменения подписок или пакетов в группах + Проверить соединение + Синхронизировать + Только загрузка + Только отправка + Синхронизация… + Подключение… + Список удалённых файлов… + Загрузка %1$d/%2$d + Загрузка %1$d/%2$d · %3$s + Запись на телефон %1$d/%2$d + Запись на телефон %1$d/%2$d · %3$s + Отправка %1$d/%2$d + Отправка %1$d/%2$d · %3$s + Удаление на сервере %1$d/%2$d + Удаление на сервере %1$d/%2$d · %3$s + Список приложений сохранён, но Android не дал пересоздать VPN. Работающий туннель всё ещё использует прежний список. diff --git a/design/src/main/res/values-vi/strings.xml b/design/src/main/res/values-vi/strings.xml index 4b1e351d05..efeb073a7f 100644 --- a/design/src/main/res/values-vi/strings.xml +++ b/design/src/main/res/values-vi/strings.xml @@ -25,6 +25,10 @@ Chế độ kiểm soát truy cập Các gói kiểm soát truy cập Định cấu hình quyền truy cập cho các ứng dụng + Bộ ứng dụng + Phần mềm phổ biến tại Trung Quốc + Đã cài %1$d · Đã chọn %2$d + Chưa cài Hồ sơ cần được lưu trước khi kích hoạt Khóa bí mật Age AGE-SECRET-KEY-… (tùy chọn) @@ -259,4 +263,36 @@ Cấu hình mới Tên cấu hình Sửa tên cấu hình + Đồng bộ WebDAV + Đồng bộ các gói đăng ký đã lưu và tên gói trong từng nhóm. Địa chỉ mặc định phải là https. Khi đã điền địa chỉ LAN, Wi-Fi sẽ thử địa chỉ đó trước, kể cả Wi-Fi không có internet. Để trống địa chỉ LAN thì bỏ qua. Dữ liệu di động chỉ dùng địa chỉ mặc định. Đồng bộ sẽ gộp trước và không xóa gói đăng ký hoặc nhóm chỉ có trên thiết bị khác. Cấu hình đang sửa, chưa lưu sẽ không được tải lên. Chỉ tải lên không đổi cấu hình trên điện thoại này. + Chưa đồng bộ + Máy chủ + Địa chỉ + https://alist.taokdao.com/dav + Địa chỉ LAN + Chỉ dùng trên Wi-Fi, kể cả mạng không có internet. Để trống để tắt LAN. Nếu địa chỉ không trả lời như WebDAV, ứng dụng dùng địa chỉ mặc định. Mạng nội bộ có thể dùng http, ví dụ http://192.168.1.69:5244/dav. + Tên người dùng + Mật khẩu + Đã đặt + Chưa đặt + Thư mục từ xa + /sync/Configs/clash-meta + Tự động đồng bộ + Đồng bộ sau khi khởi động và sau khi gói đăng ký hoặc tên gói trong nhóm thay đổi + Kiểm tra kết nối + Đồng bộ ngay + Chỉ tải xuống + Chỉ tải lên + Đang đồng bộ… + Đang kết nối… + Đang liệt kê tệp từ xa… + Tải xuống %1$d/%2$d + Tải xuống %1$d/%2$d · %3$s + Ghi vào máy %1$d/%2$d + Ghi vào máy %1$d/%2$d · %3$s + Tải lên %1$d/%2$d + Tải lên %1$d/%2$d · %3$s + Xóa tệp từ xa %1$d/%2$d + Xóa tệp từ xa %1$d/%2$d · %3$s + Danh sách ứng dụng đã được lưu, nhưng Android không cho tạo lại VPN. Đường hầm đang chạy vẫn dùng danh sách cũ. diff --git a/design/src/main/res/values-zh-rHK/strings.xml b/design/src/main/res/values-zh-rHK/strings.xml index c0161d56ae..c22d1336df 100644 --- a/design/src/main/res/values-zh-rHK/strings.xml +++ b/design/src/main/res/values-zh-rHK/strings.xml @@ -4,6 +4,10 @@ 關於 訪問控制模式 訪問控制應用包列表 + 應用集 + 國內常用軟件 + 已安裝 %1$d · 已選 %2$d + 未安裝 追加系統 DNS 應用損壞 自動更新 @@ -269,4 +273,36 @@ 新配置 配置名稱 編輯配置名稱 + WebDAV 同步 + 同步已儲存的訂閱配置,以及各分組裏的應用程式套件名稱。預設位址必須是 https。填了區域網絡位址時,連上 Wi-Fi 會先試區域網絡,沒有外網的 Wi-Fi 也會試。區域網絡位址留空則不走區域網絡。流動數據只用預設位址。同步會先合併,不會蓋掉其他裝置獨有的訂閱或分組。正在編輯、尚未儲存的配置不會上傳。僅上傳不會改這部手機上的配置。 + 尚未同步 + 伺服器 + 位址 + https://alist.taokdao.com/dav + 區域網絡位址 + 只在 Wi-Fi 下使用,沒有外網的 Wi-Fi 也會嘗試。留空則關閉區域網絡。對方沒有按 WebDAV 回應時改用預設位址。內網可以填 http,例如 http://192.168.1.69:5244/dav。 + 使用者名稱 + 密碼 + 已設定 + 未設定 + 遠端目錄 + /sync/Configs/clash-meta + 自動同步 + 啟動後,以及訂閱或分組套件名稱變化後自動同步 + 測試連線 + 立即同步 + 僅下載 + 僅上傳 + 正在同步… + 正在連線… + 正在列出遠端檔案… + 下載 %1$d/%2$d + 下載 %1$d/%2$d · %3$s + 寫入本機 %1$d/%2$d + 寫入本機 %1$d/%2$d · %3$s + 上傳 %1$d/%2$d + 上傳 %1$d/%2$d · %3$s + 清理遠端 %1$d/%2$d + 清理遠端 %1$d/%2$d · %3$s + 應用名單已儲存,但系統拒絕重建 VPN。正在運行的隧道仍使用原來的應用名單。 diff --git a/design/src/main/res/values-zh-rTW/strings.xml b/design/src/main/res/values-zh-rTW/strings.xml index dca2ea7c7a..aa32aac2c2 100644 --- a/design/src/main/res/values-zh-rTW/strings.xml +++ b/design/src/main/res/values-zh-rTW/strings.xml @@ -4,6 +4,10 @@ 關於 存取控制套件 存取控制應用程式套件清單 + 應用集 + 國內常用軟體 + 已安裝 %1$d · 已選 %2$d + 未安裝 附加作業系統 DNS 應用程式損毀 自動更新 @@ -269,4 +273,36 @@ 新配置 配置名稱 編輯配置名稱 + WebDAV 同步 + 同步已儲存的訂閱設定,以及各分組裡的應用程式套件名稱。預設位址必須是 https。填了區域網路位址時,連上 Wi-Fi 會先試區域網路,沒有外網的 Wi-Fi 也會試。區域網路位址留空則不走區域網路。行動數據只用預設位址。同步會先合併,不會蓋掉其他裝置獨有的訂閱或分組。正在編輯、尚未儲存的設定不會上傳。僅上傳不會改這支手機上的設定。 + 尚未同步 + 伺服器 + 位址 + https://alist.taokdao.com/dav + 區域網路位址 + 只在 Wi-Fi 下使用,沒有外網的 Wi-Fi 也會嘗試。留空則關閉區域網路。對方沒有按 WebDAV 回應時改用預設位址。內網可以填 http,例如 http://192.168.1.69:5244/dav。 + 使用者名稱 + 密碼 + 已設定 + 未設定 + 遠端目錄 + /sync/Configs/clash-meta + 自動同步 + 啟動後,以及訂閱或分組套件名稱變化後自動同步 + 測試連線 + 立即同步 + 僅下載 + 僅上傳 + 正在同步… + 正在連線… + 正在列出遠端檔案… + 下載 %1$d/%2$d + 下載 %1$d/%2$d · %3$s + 寫入本機 %1$d/%2$d + 寫入本機 %1$d/%2$d · %3$s + 上傳 %1$d/%2$d + 上傳 %1$d/%2$d · %3$s + 清理遠端 %1$d/%2$d + 清理遠端 %1$d/%2$d · %3$s + 應用名單已儲存,但系統拒絕重建 VPN。正在運行的隧道仍使用原來的應用名單。 diff --git a/design/src/main/res/values-zh/strings.xml b/design/src/main/res/values-zh/strings.xml index 02a80cc387..4ef4280e41 100644 --- a/design/src/main/res/values-zh/strings.xml +++ b/design/src/main/res/values-zh/strings.xml @@ -4,6 +4,10 @@ 关于 访问控制模式 访问控制应用包列表 + 应用集 + 国内常用软件 + 已安装 %1$d · 已选 %2$d + 未安装 追加系统 DNS 应用损坏 自动更新 @@ -289,21 +293,35 @@ 停止 Clash 服务 WebDAV 同步 - 同步已保存的订阅配置,以及各分组里的应用包名。地址必须是 https。同步会先合并,不会盖掉其他设备独有的订阅或分组。正在编辑、尚未保存的配置不会上传。 + 同步已保存的订阅配置,以及各分组里的应用包名。默认地址必须是 https。填写了局域网地址时,连上 Wi-Fi 会先试局域网,没有外网的 Wi-Fi 也会试。局域网地址留空则不走局域网。移动数据只用默认地址。同步会先合并,不会盖掉其他设备独有的订阅或分组。正在编辑、尚未保存的配置不会上传。仅上传不会改这台手机上的配置。 尚未同步 服务器 地址 - https://example.com/dav + https://alist.taokdao.com/dav + 局域网地址 + 只在 Wi-Fi 下使用,没有外网的 Wi-Fi 也会尝试。留空则关闭局域网。对方没有按 WebDAV 应答时改用默认地址。内网可以填 http,例如 http://192.168.1.69:5244/dav。 用户名 密码 已设置 未设置 远端目录 - /clash-meta + /sync/Configs/clash-meta 自动同步 启动后,以及订阅或分组包名变化后自动同步 测试连接 立即同步 仅下载 + 仅上传 正在同步… + 正在连接… + 正在列出远端文件… + 下载 %1$d/%2$d + 下载 %1$d/%2$d · %3$s + 写入本机 %1$d/%2$d + 写入本机 %1$d/%2$d · %3$s + 上传 %1$d/%2$d + 上传 %1$d/%2$d · %3$s + 清理远端 %1$d/%2$d + 清理远端 %1$d/%2$d · %3$s + 应用名单已保存,但系统拒绝重建 VPN。正在运行的隧道仍使用原来的应用名单。 diff --git a/design/src/main/res/values/strings.xml b/design/src/main/res/values/strings.xml index a553b1f121..ec8f39201b 100644 --- a/design/src/main/res/values/strings.xml +++ b/design/src/main/res/values/strings.xml @@ -156,6 +156,10 @@ Access Control Mode Access Control Packages Configure access permission for apps + App sets + Common Chinese apps + Installed %1$d · Selected %2$d + None installed Options Unavailable until Clash Stopped @@ -385,21 +389,35 @@ Stop Clash service WebDAV Sync - Sync saved subscriptions and the package names in each app group. The address must be https. A sync merges first and will not remove subscriptions or groups that exist only on another device. Profiles that are still being edited are not uploaded. + Sync saved subscriptions and the package names in each app group. The default address must be https. On Wi-Fi, a LAN address is tried first when one is set, including Wi-Fi without internet access. An empty LAN address is skipped. Mobile data uses only the default address. A sync merges first and will not remove subscriptions or groups that exist only on another device. Profiles that are still being edited are not uploaded. Upload only does not change profiles on this phone. Not synced yet Server Address - https://example.com/dav + https://alist.taokdao.com/dav + LAN address + Used only on Wi-Fi, including a network with no internet access. Leave it empty to turn LAN off. If the address does not answer as WebDAV, the default address is used. Private networks may use http, for example http://192.168.1.69:5244/dav. Username Password Set Not set Remote folder - /clash-meta + /sync/Configs/clash-meta Auto sync Sync after startup and after subscriptions or app groups change Test connection Sync now Download only + Upload only Syncing… + Connecting… + Listing remote files… + Downloading %1$d/%2$d + Downloading %1$d/%2$d · %3$s + Writing to this phone %1$d/%2$d + Writing to this phone %1$d/%2$d · %3$s + Uploading %1$d/%2$d + Uploading %1$d/%2$d · %3$s + Removing remote files %1$d/%2$d + Removing remote files %1$d/%2$d · %3$s + The app list was saved, but Android kept the current VPN. The running tunnel still uses the previous app list. diff --git a/design/src/test/java/com/github/kr328/clash/design/model/AppSetMembershipTest.kt b/design/src/test/java/com/github/kr328/clash/design/model/AppSetMembershipTest.kt new file mode 100644 index 0000000000..adb66ac295 --- /dev/null +++ b/design/src/test/java/com/github/kr328/clash/design/model/AppSetMembershipTest.kt @@ -0,0 +1,43 @@ +package com.github.kr328.clash.design.model + +import org.junit.Assert.assertEquals +import org.junit.Test + +class AppSetMembershipTest { + @Test + fun emptySetStaysUnchecked() { + val selected = mutableSetOf("com.example.keep") + val membership = appSetMembership(emptySet(), selected) + + assertEquals(AppSetCheckState.Unchecked, membership.state) + applyAppSetToggle(emptySet(), selected) + assertEquals(setOf("com.example.keep"), selected) + } + + @Test + fun partialSelectionBecomesFullySelected() { + val members = setOf("com.tencent.mm", "com.eg.android.AlipayGphone") + val selected = mutableSetOf("com.tencent.mm", "com.example.other") + + assertEquals(AppSetCheckState.Indeterminate, appSetMembership(members, selected).state) + applyAppSetToggle(members, selected) + + assertEquals( + setOf("com.tencent.mm", "com.eg.android.AlipayGphone", "com.example.other"), + selected, + ) + assertEquals(AppSetCheckState.Checked, appSetMembership(members, selected).state) + } + + @Test + fun fullSelectionClearsOnlyMembers() { + val members = setOf("com.android.settings", "com.android.phone") + val selected = mutableSetOf("com.android.settings", "com.android.phone", "com.tencent.mm") + + assertEquals(2, appSetMembership(members, selected).selectedCount) + applyAppSetToggle(members, selected) + + assertEquals(setOf("com.tencent.mm"), selected) + assertEquals(AppSetCheckState.Unchecked, appSetMembership(members, selected).state) + } +} diff --git a/service/src/main/java/com/github/kr328/clash/service/ProfileManager.kt b/service/src/main/java/com/github/kr328/clash/service/ProfileManager.kt index 15fce01ae7..010e257312 100644 --- a/service/src/main/java/com/github/kr328/clash/service/ProfileManager.kt +++ b/service/src/main/java/com/github/kr328/clash/service/ProfileManager.kt @@ -7,6 +7,7 @@ import com.github.kr328.clash.service.data.Pending import com.github.kr328.clash.service.data.PendingDao import com.github.kr328.clash.service.model.Profile import com.github.kr328.clash.service.remote.IFetchObserver +import com.github.kr328.clash.service.remote.IWebdavObserver import com.github.kr328.clash.service.remote.IProfileManager import com.github.kr328.clash.service.store.ServiceStore import com.github.kr328.clash.service.util.directoryLastModified @@ -190,14 +191,22 @@ class ProfileManager(private val context: Context) : IProfileManager, override suspend fun webdavPerform( action: String, url: String, + lanUrl: String, username: String, password: String, remotePath: String, + observer: IWebdavObserver?, ): String { - return WebdavSync(context).perform( + return WebdavSync(context) { phase, current, total, label -> + try { + observer?.onProgress(phase, current, total, label) + } catch (_: Exception) { + } + }.perform( action, StoredWebdavSettings( url = url, + lanUrl = lanUrl, username = username, password = password, remotePath = remotePath, diff --git a/service/src/main/java/com/github/kr328/clash/service/TunService.kt b/service/src/main/java/com/github/kr328/clash/service/TunService.kt index 643bbcd364..bc0c4a0dd8 100644 --- a/service/src/main/java/com/github/kr328/clash/service/TunService.kt +++ b/service/src/main/java/com/github/kr328/clash/service/TunService.kt @@ -15,6 +15,7 @@ import com.github.kr328.clash.service.model.AccessControlMode import com.github.kr328.clash.service.store.ServiceStore import com.github.kr328.clash.service.util.cancelAndJoinBlocking import com.github.kr328.clash.service.util.parseCIDR +import com.github.kr328.clash.service.util.sendClashReloadRejected import com.github.kr328.clash.service.util.sendClashStarted import com.github.kr328.clash.service.util.sendClashStopped import kotlinx.coroutines.* @@ -30,6 +31,7 @@ class TunService : VpnService(), CoroutineScope by CoroutineScope(Dispatchers.De val store = ServiceStore(self) val close = install(CloseModule(self)) + val reload = install(ReloadModule(self)) val tun = install(TunModule(self)) val config = install(ConfigurationModule(self)) val network = install(NetworkObserveModule(self)) @@ -51,6 +53,19 @@ class TunService : VpnService(), CoroutineScope by CoroutineScope(Dispatchers.De close.onEvent { true } + reload.onEvent { + when (withContext(NonCancellable) { reloadTun(tun) }) { + Reload.Ok -> false + Reload.Rejected -> { + sendClashReloadRejected() + false + } + Reload.Failed -> { + reason = "Reload tun failed" + true + } + } + } config.onEvent { reason = it.message @@ -224,7 +239,7 @@ class TunService : VpnService(), CoroutineScope by CoroutineScope(Dispatchers.De TunModule.TunDevice( fd = establish()?.detachFd() - ?: throw NullPointerException("Establish VPN rejected by system"), + ?: throw VpnEstablishException(), stack = store.tunStackMode, gateway = "$TUN_GATEWAY/$TUN_SUBNET_PREFIX" + if (store.allowIpv6) ",$TUN_GATEWAY6/$TUN_SUBNET_PREFIX6" else "", portal = TUN_PORTAL + if (store.allowIpv6) ",$TUN_PORTAL6" else "", @@ -235,6 +250,39 @@ class TunService : VpnService(), CoroutineScope by CoroutineScope(Dispatchers.De attach(device) } + private fun reloadTun(tun: TunModule): Reload { + try { + tun.open() + Log.i("Tun reloaded") + return Reload.Ok + } catch (e: VpnEstablishException) { + Log.w("Reload tun rejected, keep current interface") + return Reload.Rejected + } catch (e: Exception) { + Log.w("Reload tun: ${e.message}", e) + } + + return try { + tun.open() + Log.i("Tun reloaded") + Reload.Ok + } catch (e: VpnEstablishException) { + Log.w("Reload tun rejected, keep current interface") + Reload.Rejected + } catch (e: Exception) { + Log.e("Reload tun failed: ${e.message}", e) + Reload.Failed + } + } + + private enum class Reload { + Ok, + Rejected, + Failed, + } + + private class VpnEstablishException : Exception("Establish VPN rejected by system") + companion object { private const val TUN_MTU = 9000 private const val TUN_SUBNET_PREFIX = 30 diff --git a/service/src/main/java/com/github/kr328/clash/service/clash/module/ReloadModule.kt b/service/src/main/java/com/github/kr328/clash/service/clash/module/ReloadModule.kt new file mode 100644 index 0000000000..f079616342 --- /dev/null +++ b/service/src/main/java/com/github/kr328/clash/service/clash/module/ReloadModule.kt @@ -0,0 +1,21 @@ +package com.github.kr328.clash.service.clash.module + +import android.app.Service +import com.github.kr328.clash.common.constants.Intents +import com.github.kr328.clash.common.log.Log + +class ReloadModule(service: Service) : Module(service) { + override suspend fun run() { + val broadcasts = receiveBroadcast { + addAction(Intents.ACTION_CLASH_REQUEST_RELOAD) + } + + while (true) { + broadcasts.receive() + + Log.i("Request reload tun") + + enqueueEvent(Unit) + } + } +} diff --git a/service/src/main/java/com/github/kr328/clash/service/remote/IProfileManager.kt b/service/src/main/java/com/github/kr328/clash/service/remote/IProfileManager.kt index a927ccb482..a3ccf5d866 100644 --- a/service/src/main/java/com/github/kr328/clash/service/remote/IProfileManager.kt +++ b/service/src/main/java/com/github/kr328/clash/service/remote/IProfileManager.kt @@ -17,5 +17,13 @@ interface IProfileManager { suspend fun queryAll(): List suspend fun queryActive(): Profile? suspend fun setActive(profile: Profile) - suspend fun webdavPerform(action: String, url: String, username: String, password: String, remotePath: String): String + suspend fun webdavPerform( + action: String, + url: String, + lanUrl: String, + username: String, + password: String, + remotePath: String, + observer: IWebdavObserver? = null, + ): String } diff --git a/service/src/main/java/com/github/kr328/clash/service/remote/IWebdavObserver.kt b/service/src/main/java/com/github/kr328/clash/service/remote/IWebdavObserver.kt new file mode 100644 index 0000000000..77c3586fa3 --- /dev/null +++ b/service/src/main/java/com/github/kr328/clash/service/remote/IWebdavObserver.kt @@ -0,0 +1,8 @@ +package com.github.kr328.clash.service.remote + +import com.github.kr328.kaidl.BinderInterface + +@BinderInterface +fun interface IWebdavObserver { + fun onProgress(phase: String, current: Int, total: Int, label: String) +} diff --git a/service/src/main/java/com/github/kr328/clash/service/util/Broadcast.kt b/service/src/main/java/com/github/kr328/clash/service/util/Broadcast.kt index 8cbe278ef2..f6f4678058 100644 --- a/service/src/main/java/com/github/kr328/clash/service/util/Broadcast.kt +++ b/service/src/main/java/com/github/kr328/clash/service/util/Broadcast.kt @@ -56,6 +56,10 @@ fun Context.sendClashStarted() { sendBroadcastSelf(Intent(Intents.ACTION_CLASH_STARTED)) } +fun Context.sendClashReloadRejected() { + sendBroadcastSelf(Intent(Intents.ACTION_CLASH_RELOAD_REJECTED)) +} + fun Context.sendClashStopped(reason: String?) { sendBroadcastSelf( Intent(Intents.ACTION_CLASH_STOPPED).putExtra( diff --git a/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavAutoSync.kt b/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavAutoSync.kt index 9307777155..a49d12470f 100644 --- a/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavAutoSync.kt +++ b/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavAutoSync.kt @@ -73,7 +73,7 @@ object WebdavAutoSync { private suspend fun sync(context: Context, force: Boolean) { val settings = WebdavSettingsStore(context).load() - if (!settings.autoSync || settings.url.isBlank() || settings.username.isBlank() || settings.password.isEmpty()) { + if (!settings.autoSync || !settings.hasEndpoint() || settings.username.isBlank() || settings.password.isEmpty()) { return } if (!force) { diff --git a/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavClient.kt b/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavClient.kt index ab7e210287..9377bcac49 100644 --- a/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavClient.kt +++ b/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavClient.kt @@ -7,6 +7,7 @@ import okhttp3.OkHttpClient import okhttp3.Protocol import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody +import java.net.SocketTimeoutException import java.util.concurrent.TimeUnit data class WebdavConfig( @@ -15,6 +16,7 @@ data class WebdavConfig( val password: String, val remotePath: String, val clientId: String, + val allowPrivateHttp: Boolean = false, ) interface HttpTransport { @@ -79,10 +81,36 @@ class OkHttpTransport( } class WebdavClient( - private val transport: HttpTransport = OkHttpTransport(), + private val transport: HttpTransport = webdavHttpTransport(15_000, 120_000, 180_000), + private val probeTransport: HttpTransport = webdavHttpTransport( + WEBDAV_LAN_PROBE_MS.toInt(), + WEBDAV_LAN_PROBE_MS.toInt(), + WEBDAV_LAN_PROBE_MS.toInt(), + ), ) { + /** No credentials are sent. Timeout, refusal, DNS failure, and a non-WebDAV response do not count. */ + fun reachable(url: String): Boolean { + return try { + val response = probeTransport.execute( + HttpRequest( + method = "PROPFIND", + url = url, + headers = mapOf( + "User-Agent" to WEBDAV_USER_AGENT, + "Depth" to "0", + "Content-Type" to "application/xml; charset=utf-8", + ), + body = """""", + ), + ) + isWebdavEndpointResponse(response) + } catch (_: Exception) { + false + } + } + fun get(config: WebdavConfig, fileName: String): WebdavGetResult { - val response = request("GET", fileUrl(config.url, config.remotePath, fileName), config) + val response = request("GET", href(config, fileName), config) if (response.status == 404) return WebdavGetResult(found = false, body = "", etag = null) if (!response.ok) throw WebdavException(statusCode(response, "get"), response.status, statusMessage(response, "get")) return WebdavGetResult(found = true, body = response.body, etag = response.header("etag")) @@ -90,9 +118,9 @@ class WebdavClient( fun list(config: WebdavConfig, subdir: String = ""): List { val url = if (subdir.isBlank()) { - folderUrl(config.url, config.remotePath) + folderUrl(config.url, config.remotePath, config.allowPrivateHttp) } else { - val nested = fileUrl(config.url, config.remotePath, subdir.trim('/')) + val nested = href(config, subdir.trim('/')) if (nested.endsWith("/")) nested else "$nested/" } val response = request( @@ -117,8 +145,8 @@ class WebdavClient( createOnly: Boolean = false, ): WebdavPutResult { val tempName = webdavTempFileName(fileName, config.clientId) - putDirect(config, tempName, body) - val destUrl = fileUrl(config.url, config.remotePath, fileName) + val staged = putDirect(config, tempName, body) + val destUrl = href(config, fileName) val extra = linkedMapOf( "Destination" to destUrl, "Overwrite" to if (createOnly) "F" else "T", @@ -127,24 +155,34 @@ class WebdavClient( extra["If-Match"] = ifMatch extra["If"] = "<$destUrl> ($ifMatch)" } - val moved = tryMove(config, tempName, extra) - if (moved != null) return moved - val written = putDirect(config, fileName, body, ifMatch, createOnly) - try { - delete(config, tempName) - } catch (_: Exception) { + return when (val moved = tryMove(config, tempName, extra)) { + is MoveAttempt.Ok -> moved.result + MoveAttempt.Unsupported -> { + val written = putDirect(config, fileName, body, ifMatch, createOnly) + deleteQuiet(config, tempName) + written + } + MoveAttempt.Conflict -> { + recoverFalseMoveConflict(config, tempName, fileName, body, ifMatch, staged.etag) + ?: run { + deleteQuiet(config, tempName) + throw WebdavConflictException() + } + } } - return written } - fun delete(config: WebdavConfig, fileName: String) { - val response = request("DELETE", fileUrl(config.url, config.remotePath, fileName), config) + fun delete(config: WebdavConfig, fileName: String, ifMatch: String? = null) { + val extra = linkedMapOf() + if (!ifMatch.isNullOrEmpty()) extra["If-Match"] = ifMatch + val response = request("DELETE", href(config, fileName), config, extra = extra) if (response.status == 404 || response.status == 204 || response.ok) return + if (response.status == 412) throw WebdavConflictException() throw WebdavException(statusCode(response, "delete"), response.status, statusMessage(response, "delete")) } fun ensureParents(config: WebdavConfig) { - val target = fileUrl(config.url, config.remotePath, "$WEBDAV_PROFILES_DIR/_placeholder.json") + val target = href(config, "$WEBDAV_PROFILES_DIR/_placeholder.json") val root = if (config.url.trim().endsWith("/")) config.url.trim() else "${config.url.trim()}/" for (collection in collectionUrlsBetween(root, target)) { val colUrl = if (collection.endsWith("/")) collection else "$collection/" @@ -164,7 +202,7 @@ class WebdavClient( createOnly: Boolean = false, unlockTried: Boolean = false, ): WebdavPutResult { - val url = fileUrl(config.url, config.remotePath, fileName) + val url = href(config, fileName) val extra = linkedMapOf("Content-Type" to "application/json; charset=utf-8") if (!ifMatch.isNullOrEmpty()) extra["If-Match"] = ifMatch else if (createOnly) extra["If-None-Match"] = "*" @@ -181,28 +219,84 @@ class WebdavClient( throw WebdavException(statusCode(response, "put"), response.status, statusMessage(response, "put")) } + /** + * MOVE applies If-Match to the temporary source, so a server can return 412 + * even when the destination etag is unchanged. Re-read that etag: if it is + * the same, overwrite without the condition; if it changed, this is a real conflict. + */ + private fun recoverFalseMoveConflict( + config: WebdavConfig, + tempName: String, + fileName: String, + body: String, + ifMatch: String?, + stagedEtag: String?, + ): WebdavPutResult? { + if (ifMatch.isNullOrEmpty()) return null + val current = readResourceEtag(config, fileName) ?: return null + if (!webdavEtagsMatch(current, ifMatch)) return null + val destUrl = href(config, fileName) + when ( + val plain = tryMove( + config, + tempName, + mapOf("Destination" to destUrl, "Overwrite" to "T"), + ) + ) { + is MoveAttempt.Ok -> return WebdavPutResult(plain.result.etag ?: stagedEtag) + MoveAttempt.Unsupported, MoveAttempt.Conflict -> Unit + } + return try { + putDirect(config, fileName, body, ifMatch = current, createOnly = false) + } finally { + deleteQuiet(config, tempName) + } + } + + private fun readResourceEtag(config: WebdavConfig, fileName: String): String? { + return try { + val response = request( + "PROPFIND", + href(config, fileName), + config, + body = """""", + extra = mapOf("Depth" to "0", "Content-Type" to "application/xml; charset=utf-8"), + ) + if (response.status != 207 && !response.ok) null else firstGetEtag(response.body) + } catch (_: Exception) { + null + } + } + + private fun deleteQuiet(config: WebdavConfig, fileName: String) { + try { + delete(config, fileName) + } catch (_: Exception) { + } + } + + private sealed class MoveAttempt { + data class Ok(val result: WebdavPutResult) : MoveAttempt() + data object Unsupported : MoveAttempt() + data object Conflict : MoveAttempt() + } + private fun tryMove( config: WebdavConfig, tempName: String, extra: Map, unlockTried: Boolean = false, - ): WebdavPutResult? { - val response = request("MOVE", fileUrl(config.url, config.remotePath, tempName), config, extra = extra) - if (response.status == 405 || response.status == 501) return null - if (response.status == 412) { - try { - delete(config, tempName) - } catch (_: Exception) { - } - throw WebdavConflictException() - } + ): MoveAttempt { + val response = request("MOVE", href(config, tempName), config, extra = extra) + if (response.status == 405 || response.status == 501) return MoveAttempt.Unsupported + if (response.status == 412) return MoveAttempt.Conflict if (response.status == 423 && !unlockTried) { val tokens = (extractLockTokens(response.body) + discoverLockTokens(config, tempName)).distinct() for (token in tokens) unlock(config, tempName, token) return tryMove(config, tempName, extra, unlockTried = true) } if (response.status == 201 || response.status == 204 || response.ok) { - return WebdavPutResult(etag = response.header("etag")) + return MoveAttempt.Ok(WebdavPutResult(etag = response.header("etag"))) } throw WebdavException(statusCode(response, "move"), response.status, statusMessage(response, "move")) } @@ -211,7 +305,7 @@ class WebdavClient( return try { val response = request( "PROPFIND", - fileUrl(config.url, config.remotePath, fileName), + href(config, fileName), config, body = """""", extra = mapOf("Depth" to "0", "Content-Type" to "application/xml; charset=utf-8"), @@ -226,7 +320,7 @@ class WebdavClient( try { request( "UNLOCK", - fileUrl(config.url, config.remotePath, fileName), + href(config, fileName), config, extra = mapOf("Lock-Token" to "<${token.trim('<', '>')}>"), ) @@ -234,6 +328,10 @@ class WebdavClient( } } + private fun href(config: WebdavConfig, name: String): String { + return fileUrl(config.url, config.remotePath, name, config.allowPrivateHttp) + } + private fun request( method: String, url: String, @@ -259,9 +357,11 @@ class WebdavClient( response } catch (err: WebdavException) { throw err + } catch (err: SocketTimeoutException) { + throw WebdavException("timeout") } catch (err: Exception) { val message = err.message.orEmpty() - if (message.contains("abort", true) || message.contains("timeout", true)) { + if (message.contains("abort", true) || message.contains("timeout", true) || message.contains("timed out", true)) { throw WebdavException("timeout") } throw WebdavException("connect", detail = message) diff --git a/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavMerge.kt b/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavMerge.kt index 792863b169..4711e05b89 100644 --- a/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavMerge.kt +++ b/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavMerge.kt @@ -28,8 +28,8 @@ fun mergeWebdav( remoteActiveStrategyUpdatedAt: Long = 0, ): MergeResult { val tombstones = mergeTombstones(localTombstones + remoteTombstones, now) - val localByIdentity = localProfiles.associateBy { subscriptionIdentity(it.type, it.source, it.uuid) } - val remoteByIdentity = remoteProfiles.associateBy { subscriptionIdentity(it.type, it.source, it.uuid) } + val localByIdentity = collapseProfiles(localProfiles) + val remoteByIdentity = collapseProfiles(remoteProfiles) val keptTombstones = tombstones.toMutableList() val profiles = mutableListOf() @@ -90,8 +90,8 @@ fun mergeStrategies( now: Long, ): StrategyMerge { val tombstones = mergeTombstones(localTombstones + remoteTombstones, now).toMutableList() - val localById = local.associateBy { it.uuid } - val remoteById = remote.associateBy { it.uuid } + val localById = collapseStrategies(local) + val remoteById = collapseStrategies(remote) val kept = mutableListOf() for (uuid in localById.keys + remoteById.keys) { val merged = when { @@ -108,6 +108,20 @@ fun mergeStrategies( return StrategyMerge(kept.sortedBy { it.uuid }, tombstones.sortedBy { it.uuid }) } +/** + * Keep the merge result. Tombstones recorded after [snapshot] was taken are still new + * and must be kept. Tombstones the merge already dropped are not written back. + */ +fun retainMergedTombstones( + merged: List, + snapshot: List, + current: List, + now: Long, +): List { + val seen = snapshot.toSet() + return mergeTombstones(merged + current.filter { it !in seen }, now) +} + fun mergeTombstones(tombstones: List, now: Long): List { val kept = linkedMapOf() for (tombstone in tombstones) { @@ -121,6 +135,39 @@ fun mergeTombstones(tombstones: List, now: Long): List): Map { + return profiles.preferNewer( + identity = { subscriptionIdentity(it.type, it.source, it.uuid) }, + updatedAt = { it.updatedAt }, + tie = { it.uuid }, + ) +} + +private fun collapseStrategies(strategies: List): Map { + return strategies.preferNewer( + identity = { it.uuid }, + updatedAt = { it.updatedAt }, + tie = { it.uuid }, + ) +} + +private fun List.preferNewer( + identity: (T) -> String, + updatedAt: (T) -> Long, + tie: (T) -> String, +): Map { + val best = linkedMapOf() + for (item in this) { + val key = identity(item) + val previous = best[key] + val newer = previous == null || + updatedAt(item) > updatedAt(previous) || + (updatedAt(item) == updatedAt(previous) && tie(item) > tie(previous)) + if (newer) best[key] = item + } + return best +} + private fun mergeProfile(local: ProfilePayload, remote: ProfilePayload): ProfilePayload { val config = if (remote.updatedAt > local.updatedAt) remote else local val createdAt = listOf(local.createdAt, remote.createdAt).filter { it > 0 }.minOrNull() diff --git a/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavPrivateHttp.kt b/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavPrivateHttp.kt new file mode 100644 index 0000000000..286fd2e67d --- /dev/null +++ b/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavPrivateHttp.kt @@ -0,0 +1,229 @@ +package com.github.kr328.clash.service.webdav + +import okhttp3.OkHttpClient +import okhttp3.Protocol +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.io.InputStream +import java.net.InetSocketAddress +import java.net.Socket +import java.net.SocketTimeoutException +import java.net.URI +import java.util.concurrent.TimeUnit + +/** + * Public https stays on OkHttp, which follows the network security config. + * Private http uses a direct socket so the rest of the app can keep cleartext blocked. + */ +internal fun webdavHttpTransport( + connectTimeoutMs: Int, + readTimeoutMs: Int, + callTimeoutMs: Int, +): HttpTransport { + val secure = OkHttpTransport( + OkHttpClient.Builder() + .followRedirects(false) + .followSslRedirects(false) + .protocols(listOf(Protocol.HTTP_1_1)) + .connectTimeout(connectTimeoutMs.toLong(), TimeUnit.MILLISECONDS) + .readTimeout(readTimeoutMs.toLong(), TimeUnit.MILLISECONDS) + .writeTimeout(readTimeoutMs.toLong(), TimeUnit.MILLISECONDS) + .callTimeout(callTimeoutMs.toLong(), TimeUnit.MILLISECONDS) + .build(), + ) + return RoutingHttpTransport(secure, PrivateHttpTransport(connectTimeoutMs, readTimeoutMs)) +} + +internal class RoutingHttpTransport( + private val secure: HttpTransport, + private val cleartext: HttpTransport, +) : HttpTransport { + override fun execute(request: HttpRequest): HttpResponse { + val uri = try { + URI(request.url) + } catch (_: Exception) { + return secure.execute(request) + } + val host = uri.host.orEmpty() + val scheme = uri.scheme?.lowercase() + return if (scheme == "http" && isPrivateWebdavHost(host)) { + cleartext.execute(request) + } else { + secure.execute(request) + } + } +} + +internal class PrivateHttpTransport( + private val connectTimeoutMs: Int, + private val readTimeoutMs: Int, +) : HttpTransport { + override fun execute(request: HttpRequest): HttpResponse { + val uri = try { + URI(request.url) + } catch (_: Exception) { + throw WebdavException("bad_url") + } + val host = uri.host?.trim().orEmpty().removePrefix("[").removeSuffix("]") + if (uri.scheme?.lowercase() != "http" || !isPrivateWebdavHost(host)) { + throw WebdavException("lan_http") + } + if (!request.method.matches(Regex("[A-Z]+"))) throw WebdavException("bad_url") + val port = if (uri.port == -1) 80 else uri.port + val socket = Socket() + try { + socket.connect(InetSocketAddress(host, port), connectTimeoutMs) + socket.soTimeout = readTimeoutMs + val output = socket.getOutputStream() + output.write(encodeRequest(request, uri, host, port)) + output.flush() + return readResponse(socket.getInputStream()) + } catch (err: WebdavException) { + throw err + } catch (err: SocketTimeoutException) { + throw IOException("timeout", err) + } finally { + socket.close() + } + } + + private fun encodeRequest(request: HttpRequest, uri: URI, host: String, port: Int): ByteArray { + val payload = request.body?.toByteArray(Charsets.UTF_8) + val target = buildString { + append(uri.rawPath?.takeIf { it.isNotEmpty() } ?: "/") + if (!uri.rawQuery.isNullOrEmpty()) { + append('?') + append(uri.rawQuery) + } + } + val hostHeader = if (host.contains(':')) "[$host]:$port" else "$host:$port" + val text = buildString { + append(request.method).append(' ').append(target).append(" HTTP/1.1\r\n") + append("Host: ").append(headerValue(hostHeader)).append("\r\n") + append("Connection: close\r\n") + for ((name, value) in request.headers) { + if (name.equals("Host", true) || name.equals("Content-Length", true) || name.equals("Connection", true)) { + continue + } + append(headerValue(name)).append(": ").append(headerValue(value)).append("\r\n") + } + if (payload != null) { + append("Content-Length: ").append(payload.size).append("\r\n") + } + append("\r\n") + } + val head = text.toByteArray(Charsets.ISO_8859_1) + if (payload == null) return head + return head + payload + } + + private fun headerValue(value: String): String { + if (value.any { it == '\r' || it == '\n' || it == '\u0000' }) throw WebdavException("bad_url") + return value + } + + private fun readResponse(input: InputStream): HttpResponse { + var statusLine = readLine(input) + var hops = 0 + while (hops < 3 && statusCode(statusLine) == 100) { + while (readLine(input).isNotEmpty()) Unit + statusLine = readLine(input) + hops += 1 + } + val status = statusCode(statusLine) + val statusText = statusLine.substringAfter(status.toString(), "").trim() + val headers = linkedMapOf() + var count = 0 + while (true) { + val line = readLine(input) + if (line.isEmpty()) break + count += 1 + if (count > 64) throw IOException("too many headers") + val colon = line.indexOf(':') + if (colon <= 0) continue + val name = line.substring(0, colon).trim() + val value = line.substring(colon + 1).trim() + if (!headers.keys.any { it.equals(name, true) }) headers[name] = value + } + val body = readBody(input, headers) + return HttpResponse(status, statusText, body.toString(Charsets.UTF_8), headers) + } + + private fun statusCode(line: String): Int { + val parts = line.split(' ') + if (parts.size < 2 || !parts[0].startsWith("HTTP/")) throw IOException("bad status") + return parts[1].toIntOrNull() ?: throw IOException("bad status") + } + + private fun readBody(input: InputStream, headers: Map): ByteArray { + val encoding = header(headers, "Transfer-Encoding").orEmpty() + if (encoding.contains("chunked", true)) return readChunked(input) + val lengthHeader = header(headers, "Content-Length") + if (lengthHeader != null) { + val length = lengthHeader.trim().toLongOrNull() ?: throw IOException("bad length") + if (length < 0 || length > WEBDAV_MAX_PROFILE_BYTES) throw WebdavException("too_large") + if (length == 0L) return ByteArray(0) + val buf = ByteArray(length.toInt()) + readFully(input, buf) + return buf + } + return readUntilEof(input) + } + + private fun readChunked(input: InputStream): ByteArray { + val out = ByteArrayOutputStream() + while (true) { + val size = readLine(input).substringBefore(';').trim().toIntOrNull(16) + ?: throw IOException("bad chunk") + if (size < 0) throw IOException("bad chunk") + if (size == 0) { + while (readLine(input).isNotEmpty()) Unit + break + } + if (out.size() + size > WEBDAV_MAX_PROFILE_BYTES) throw WebdavException("too_large") + val buf = ByteArray(size) + readFully(input, buf) + out.write(buf) + val end = readLine(input) + if (end.isNotEmpty()) throw IOException("bad chunk") + } + return out.toByteArray() + } + + private fun readUntilEof(input: InputStream): ByteArray { + val out = ByteArrayOutputStream() + val buf = ByteArray(4096) + while (true) { + val n = input.read(buf) + if (n < 0) break + if (out.size() + n > WEBDAV_MAX_PROFILE_BYTES) throw WebdavException("too_large") + out.write(buf, 0, n) + } + return out.toByteArray() + } + + private fun readFully(input: InputStream, buf: ByteArray) { + var off = 0 + while (off < buf.size) { + val n = input.read(buf, off, buf.size - off) + if (n < 0) throw IOException("closed") + off += n + } + } + + private fun readLine(input: InputStream): String { + val out = ByteArrayOutputStream() + while (out.size() <= 8192) { + val b = input.read() + if (b < 0) break + if (b == '\n'.code) break + if (b != '\r'.code) out.write(b) + } + if (out.size() > 8192) throw IOException("header too long") + return out.toString(Charsets.ISO_8859_1) + } + + private fun header(headers: Map, name: String): String? { + return headers.entries.firstOrNull { it.key.equals(name, true) }?.value + } +} diff --git a/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavProfiles.kt b/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavProfiles.kt index 2f2d1fcb6e..c317c1251d 100644 --- a/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavProfiles.kt +++ b/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavProfiles.kt @@ -90,14 +90,21 @@ class WebdavProfiles(private val context: Context) { ) } - suspend fun apply(result: MergeResult, local: LocalSnapshot, startedAt: Long): ApplyCounts { + suspend fun apply( + result: MergeResult, + local: LocalSnapshot, + startedAt: Long, + onProgress: (current: Int, total: Int, label: String) -> Unit = { _, _, _ -> }, + ): ApplyCounts { var downloaded = 0 var removed = 0 var skipped = 0 val localByUuid = local.profiles.associateBy { it.uuid } val mergedIds = result.profiles.map { it.uuid }.toSet() + val total = result.profiles.size - for (profile in result.profiles) { + for ((index, profile) in result.profiles.withIndex()) { + onProgress(index + 1, total, profile.name) val previous = localByUuid[profile.uuid] ?: local.profiles.find { sameSubscription(it, profile) } if (previous != null && sameContent(previous, profile)) continue diff --git a/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavProtocol.kt b/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavProtocol.kt index f7f0e0cbe3..d000b0605e 100644 --- a/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavProtocol.kt +++ b/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavProtocol.kt @@ -2,6 +2,7 @@ package com.github.kr328.clash.service.webdav import java.net.URI import java.net.URLDecoder +import java.net.URLEncoder import java.security.SecureRandom import java.text.SimpleDateFormat import java.util.Locale @@ -12,7 +13,13 @@ const val WEBDAV_PROFILE_KIND = "clash-meta-profile" const val WEBDAV_SYNC_VERSION = 1 const val WEBDAV_META_FILE = "_sync.json" const val WEBDAV_PROFILES_DIR = "profiles" -const val WEBDAV_DEFAULT_REMOTE_PATH = "/clash-meta" +const val WEBDAV_DEFAULT_URL = "https://alist.taokdao.com/dav" +const val WEBDAV_DEFAULT_LAN_URL = "http://192.168.1.69:5244/dav" +const val WEBDAV_LAN_PROBE_MS = 2_500L +const val WEBDAV_DEFAULT_USERNAME = "clarence" +const val WEBDAV_DEFAULT_REMOTE_PATH = "/sync/Configs/clash-meta" +const val WEBDAV_LEGACY_MISREAD_URL = "https://alist.taokodao.com/dav" +const val WEBDAV_LEGACY_REMOTE_PATH = "/clash-meta" const val WEBDAV_USER_AGENT = "ClashMetaForAndroid/1.0" const val WEBDAV_TMP_SUFFIX = ".tmp" const val WEBDAV_TMP_MAX_AGE_MS = 3L * 24 * 60 * 60 * 1000 @@ -46,6 +53,111 @@ data class WebdavListEntry( val lastModified: String? = null, ) +fun normalizeWebdavUrl(raw: String): String { + val url = raw.trim().trimEnd('/') + if (url.isEmpty() || url == WEBDAV_LEGACY_MISREAD_URL) return WEBDAV_DEFAULT_URL + return url +} + +/** Blank stays blank so a cleared LAN address stays off. The first-run default is applied by [StoredWebdavSettings]. */ +fun normalizeWebdavLanUrl(raw: String?): String { + return raw?.trim()?.trimEnd('/').orEmpty() +} + +/** + * A LAN probe does not send credentials. A normal web server that answers 200 or 404 + * is not selected, so the later authenticated request is not sent there. + */ +fun isWebdavEndpointResponse(response: HttpResponse): Boolean { + if (response.header("DAV")?.isNotBlank() == true) return true + if (response.status == 207) return true + if (response.status == 401 || response.status == 403) { + return response.header("WWW-Authenticate")?.isNotBlank() == true + } + return false +} + +fun isPrivateWebdavHost(hostname: String): Boolean { + val bare = hostname.trim().lowercase().removeSuffix(".").removePrefix("[").removeSuffix("]") + if (bare.isEmpty()) return false + if ( + bare == "localhost" || + bare.endsWith(".localhost") || + bare.endsWith(".local") || + bare.endsWith(".lan") || + bare.endsWith(".home") + ) { + return true + } + if (bare == "::1") return true + if (!bare.contains('.') && !bare.contains(':')) return true + val v4 = Regex("""^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$""").matchEntire(bare) + if (v4 != null) { + val parts = v4.groupValues.drop(1).map { it.toInt() } + if (parts.any { it > 255 }) return false + val a = parts[0] + val b = parts[1] + if (a == 10 || a == 127) return true + if (a == 192 && b == 168) return true + if (a == 172 && b in 16..31) return true + if (a == 169 && b == 254) return true + return false + } + if (bare.contains(':')) { + val head = bare.substringBefore('%').substringBefore(':') + if (Regex("""^fe[89ab][0-9a-f]{0,2}$""").matches(head)) return true + if (Regex("""^f[cd][0-9a-f]{0,2}$""").matches(head)) return true + } + return false +} + +fun resolveWebdavBaseUrl( + url: String, + lanUrl: String, + remotePath: String, + allowLan: Boolean, + probe: (String) -> Boolean, +): String { + val pub = url.trim().trimEnd('/') + val storedLan = lanUrl.trim().trimEnd('/') + if (!allowLan && storedLan.isNotEmpty() && pub.isEmpty()) throw WebdavException("lan_offline") + val lan = if (allowLan) storedLan else "" + if (lan.isEmpty()) { + if (pub.isEmpty()) throw WebdavException("empty_url") + return requirePublicWebdavUrl(pub) + } + val target = try { + val folder = joinWebdavUrl(lan, resolveWebdavRemoteFolder(remotePath), allowPrivateHttp = true) + if (folder.endsWith("/")) folder else "$folder/" + } catch (err: Exception) { + if (pub.isNotEmpty()) return requirePublicWebdavUrl(pub) + if (err is WebdavException) throw err + throw WebdavException("bad_url") + } + if (probe(target)) return lan + if (pub.isEmpty()) throw WebdavException("lan_unreachable") + return requirePublicWebdavUrl(pub) +} + +/** The public field is https only. Private http belongs on the LAN address. */ +fun requirePublicWebdavUrl(url: String): String { + val root = try { + URI(url) + } catch (_: Exception) { + throw WebdavException("bad_url") + } + if (root.scheme?.lowercase() != "https") throw WebdavException("https") + return url +} + +fun normalizeWebdavRemotePath(raw: String): String { + val path = raw.trim().trimEnd('/') + if (path.isEmpty() || path == WEBDAV_LEGACY_REMOTE_PATH || path == "/cursor-manager" || path == "/sync/Configs/cursor-manager") { + return WEBDAV_DEFAULT_REMOTE_PATH + } + return resolveWebdavRemoteFolder(path) +} + fun resolveWebdavRemoteFolder(path: String): String { val trimmed = path.trim().trimEnd('/') if (trimmed.isEmpty()) return WEBDAV_DEFAULT_REMOTE_PATH @@ -56,7 +168,7 @@ fun resolveWebdavRemoteFolder(path: String): String { return if (trimmed.startsWith("/")) trimmed else "/$trimmed" } -fun joinWebdavUrl(base: String, remotePath: String): String { +fun joinWebdavUrl(base: String, remotePath: String, allowPrivateHttp: Boolean = false): String { val trimmed = base.trim() if (trimmed.isEmpty()) throw WebdavException("empty_url") val root = try { @@ -64,20 +176,90 @@ fun joinWebdavUrl(base: String, remotePath: String): String { } catch (_: Exception) { throw WebdavException("bad_url") } - if (root.scheme?.lowercase() != "https") throw WebdavException("https") - val rel = remotePath.trim().trimStart('/') + val scheme = root.scheme?.lowercase() + when { + scheme == "https" -> Unit + allowPrivateHttp && scheme == "http" && isPrivateWebdavHost(root.host ?: "") -> Unit + allowPrivateHttp && scheme == "http" -> throw WebdavException("lan_http") + else -> throw WebdavException("https") + } + val raw = remotePath.trim() + if ( + raw.isEmpty() || + raw.contains('\u0000') || + raw.contains("..") || + raw.startsWith("//") || + Regex("""^[a-zA-Z][a-zA-Z0-9+.-]*:""").containsMatchIn(raw) + ) { + throw WebdavException("bad_path") + } + val rel = raw.trimStart('/') if (rel.isEmpty()) throw WebdavException("empty_path") + val encoded = rel.split('/').joinToString("/") { segment -> + URLEncoder.encode(segment, Charsets.UTF_8.name()).replace("+", "%20") + } val rootHref = if (trimmed.endsWith("/")) trimmed else "$trimmed/" - return URI(rootHref).resolve(rel).toString() + val joined = try { + URI(rootHref).resolve(encoded) + } catch (_: Exception) { + throw WebdavException("bad_url") + } + if (!sameWebdavOrigin(root, joined)) throw WebdavException("bad_path") + val rootPath = (if (root.path.endsWith("/")) root.path else "${root.path}/").replace(Regex("/+"), "/") + val outPath = joined.path.replace(Regex("/+"), "/") + if (outPath != rootPath.trimEnd('/') && !outPath.startsWith(rootPath)) { + throw WebdavException("bad_path") + } + return joined.toString() +} + +private fun sameWebdavOrigin(root: URI, joined: URI): Boolean { + if (root.scheme?.lowercase() != joined.scheme?.lowercase()) return false + if (!root.host.equals(joined.host, ignoreCase = true)) return false + val rootPort = if (root.port == -1) defaultWebdavPort(root.scheme) else root.port + val joinedPort = if (joined.port == -1) defaultWebdavPort(joined.scheme) else joined.port + return rootPort == joinedPort +} + +private fun defaultWebdavPort(scheme: String?): Int { + return if (scheme.equals("https", ignoreCase = true)) 443 else 80 +} + +fun normalizeWebdavEtag(etag: String?): String? { + if (etag.isNullOrBlank()) return null + var value = etag.trim() + .replace("&", "&", ignoreCase = true) + .replace(""", "\"", ignoreCase = true) + .replace("'", "'", ignoreCase = true) + .replace("<", "<", ignoreCase = true) + .replace(">", ">", ignoreCase = true) + if (value.length >= 2 && value.startsWith("W/", ignoreCase = true)) { + value = value.substring(2).trim() + } + if (value.length >= 2 && value.startsWith("\"") && value.endsWith("\"")) { + value = value.substring(1, value.length - 1) + } + return value.ifEmpty { null } +} + +fun webdavEtagsMatch(left: String?, right: String?): Boolean { + val a = normalizeWebdavEtag(left) + val b = normalizeWebdavEtag(right) + return a != null && a == b +} + +fun firstGetEtag(xml: String): String? { + return Regex("<(?:[\\w.-]+:)?getetag\\b[^>]*>\\s*([^<]*)", RegexOption.IGNORE_CASE) + .find(xml)?.groupValues?.getOrNull(1)?.trim()?.ifEmpty { null } } -fun folderUrl(base: String, remotePath: String): String { - val url = joinWebdavUrl(base, resolveWebdavRemoteFolder(remotePath)) +fun folderUrl(base: String, remotePath: String, allowPrivateHttp: Boolean = false): String { + val url = joinWebdavUrl(base, resolveWebdavRemoteFolder(remotePath), allowPrivateHttp) return if (url.endsWith("/")) url else "$url/" } -fun fileUrl(base: String, remotePath: String, name: String): String { - return joinWebdavUrl(base, "${resolveWebdavRemoteFolder(remotePath)}/$name") +fun fileUrl(base: String, remotePath: String, name: String, allowPrivateHttp: Boolean = false): String { + return joinWebdavUrl(base, "${resolveWebdavRemoteFolder(remotePath)}/$name", allowPrivateHttp) } fun collectionUrlsBetween(rootUrl: String, fileUrl: String): List { diff --git a/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavSettingsStore.kt b/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavSettingsStore.kt index 7e58ac632a..27d308cecb 100644 --- a/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavSettingsStore.kt +++ b/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavSettingsStore.kt @@ -6,8 +6,9 @@ import java.io.File @Serializable private data class SettingsFile( - val url: String = "", - val username: String = "", + val url: String = WEBDAV_DEFAULT_URL, + val lanUrl: String = WEBDAV_DEFAULT_LAN_URL, + val username: String = WEBDAV_DEFAULT_USERNAME, val passwordSealed: String = "", val remotePath: String = WEBDAV_DEFAULT_REMOTE_PATH, val autoSync: Boolean = false, @@ -20,6 +21,10 @@ class WebdavSettingsStore(context: Context) { get() = read().url.ifBlank { null } set(value) = update { it.copy(url = value?.trim().orEmpty()) } + var lanUrl: String? + get() = read().lanUrl.ifBlank { null } + set(value) = update { it.copy(lanUrl = value?.trim().orEmpty()) } + var username: String? get() = read().username.ifBlank { null } set(value) = update { it.copy(username = value?.trim().orEmpty()) } @@ -40,7 +45,7 @@ class WebdavSettingsStore(context: Context) { fun configured(): Boolean { val current = read() - return current.url.isNotBlank() && current.username.isNotBlank() && current.password.isNotEmpty() + return current.hasEndpoint() && current.username.isNotBlank() && current.password.isNotEmpty() } private fun update(block: (StoredWebdavSettings) -> StoredWebdavSettings) { @@ -51,12 +56,15 @@ class WebdavSettingsStore(context: Context) { if (!file.exists()) return StoredWebdavSettings() return try { val stored = webdavJson.decodeFromString(SettingsFile.serializer(), file.readText()) - StoredWebdavSettings( - url = stored.url.trim(), - username = stored.username.trim(), - password = WebdavCipher.open(stored.passwordSealed), - remotePath = resolveWebdavRemoteFolder(stored.remotePath), - autoSync = stored.autoSync, + seedWebdavSettings( + StoredWebdavSettings( + url = stored.url.trim(), + lanUrl = stored.lanUrl.trim(), + username = stored.username.trim(), + password = WebdavCipher.open(stored.passwordSealed), + remotePath = stored.remotePath.trim(), + autoSync = stored.autoSync, + ), ) } catch (_: Exception) { StoredWebdavSettings() @@ -66,10 +74,11 @@ class WebdavSettingsStore(context: Context) { private fun write(settings: StoredWebdavSettings) { file.parentFile?.mkdirs() val payload = SettingsFile( - url = settings.url.trim(), - username = settings.username.trim(), + url = normalizeWebdavUrl(settings.url), + lanUrl = normalizeWebdavLanUrl(settings.lanUrl), + username = settings.username.trim().ifBlank { WEBDAV_DEFAULT_USERNAME }, passwordSealed = WebdavCipher.seal(settings.password), - remotePath = resolveWebdavRemoteFolder(settings.remotePath), + remotePath = normalizeWebdavRemotePath(settings.remotePath), autoSync = settings.autoSync, ) val tmp = File(file.parentFile, "settings.json.tmp") @@ -82,9 +91,36 @@ class WebdavSettingsStore(context: Context) { } data class StoredWebdavSettings( - val url: String = "", - val username: String = "", + val url: String = WEBDAV_DEFAULT_URL, + val lanUrl: String = WEBDAV_DEFAULT_LAN_URL, + val username: String = WEBDAV_DEFAULT_USERNAME, val password: String = "", val remotePath: String = WEBDAV_DEFAULT_REMOTE_PATH, val autoSync: Boolean = false, -) +) { + fun hasEndpoint(): Boolean = url.isNotBlank() || lanUrl.isNotBlank() +} + +internal fun seedWebdavSettings(settings: StoredWebdavSettings): StoredWebdavSettings { + val configured = settings.password.isNotEmpty() || settings.autoSync + if (configured) { + return settings.copy( + url = settings.url.trim().trimEnd('/').let { url -> + if (url == WEBDAV_LEGACY_MISREAD_URL) WEBDAV_DEFAULT_URL else url + }, + lanUrl = normalizeWebdavLanUrl(settings.lanUrl), + username = settings.username.trim(), + remotePath = if (settings.remotePath.trim().trimEnd('/') == WEBDAV_LEGACY_REMOTE_PATH) { + WEBDAV_DEFAULT_REMOTE_PATH + } else { + resolveWebdavRemoteFolder(settings.remotePath) + }, + ) + } + return settings.copy( + url = normalizeWebdavUrl(settings.url), + lanUrl = normalizeWebdavLanUrl(settings.lanUrl), + username = settings.username.trim().ifBlank { WEBDAV_DEFAULT_USERNAME }, + remotePath = normalizeWebdavRemotePath(settings.remotePath), + ) +} diff --git a/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavSync.kt b/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavSync.kt index a674afc3f6..d0d9dd25b6 100644 --- a/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavSync.kt +++ b/service/src/main/java/com/github/kr328/clash/service/webdav/WebdavSync.kt @@ -1,6 +1,8 @@ package com.github.kr328.clash.service.webdav import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkCapabilities import com.github.kr328.clash.service.R import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.sync.Mutex @@ -10,6 +12,7 @@ import kotlinx.coroutines.withContext class WebdavSync( private val context: Context, private val client: WebdavClient = WebdavClient(), + private val onProgress: (phase: String, current: Int, total: Int, label: String) -> Unit = { _, _, _, _ -> }, ) { suspend fun perform(action: String, settings: StoredWebdavSettings): String { return gate.withLock { @@ -36,8 +39,10 @@ class WebdavSync( } private suspend fun execute(action: String, settings: StoredWebdavSettings): String { + progress("connect", 0, 0, "") val config = config(settings) if (action == ACTION_TEST) { + progress("list", 0, 0, "") client.ensureParents(config) client.list(config) return context.getString(R.string.webdav_connected) @@ -77,13 +82,26 @@ class WebdavSync( remoteActiveStrategy = remote.meta.activeAppsStrategy, remoteActiveStrategyUpdatedAt = remote.meta.activeAppsStrategyUpdatedAt, ) - val counts = profiles.apply(merged, local, now) - val uploaded = if (action == ACTION_PULL) 0 else upload(config, remote, merged) - WebdavLocalState(context).update { current -> - current.copy( - tombstones = mergeTombstones(merged.tombstones + current.tombstones, now), - strategyTombstones = mergeTombstones(merged.strategyTombstones + current.strategyTombstones, now), - ) + val counts = if (action == ACTION_PUSH) { + ApplyCounts(downloaded = 0, removed = 0, skipped = 0) + } else { + profiles.apply(merged, local, now) { current, total, label -> + progress("apply", current, total, label) + } + } + val uploaded = if (action == ACTION_PULL) 0 else upload(config, remote, merged, report = true) + if (action != ACTION_PUSH) { + WebdavLocalState(context).update { current -> + current.copy( + tombstones = retainMergedTombstones(merged.tombstones, local.tombstones, current.tombstones, now), + strategyTombstones = retainMergedTombstones( + merged.strategyTombstones, + local.strategyTombstones, + current.strategyTombstones, + now, + ), + ) + } } if (counts.downloaded == 0 && uploaded == 0 && counts.removed == 0) { return context.getString(R.string.webdav_sync_idle) @@ -92,23 +110,25 @@ class WebdavSync( } private fun download(config: WebdavConfig): RemoteBundle { + progress("list", 0, 0, "") client.ensureParents(config) val now = System.currentTimeMillis() val root = client.list(config) val nested = client.list(config, WEBDAV_PROFILES_DIR) sweep(config, root.map { it.name to it } + nested.map { "$WEBDAV_PROFILES_DIR/${it.name}" to it }, now) val etags = linkedMapOf() + val listedMetaEtag = root.firstOrNull { !it.collection && it.name == WEBDAV_META_FILE }?.etag val metaGot = client.get(config, WEBDAV_META_FILE) - val meta = if (!metaGot.found || metaGot.body.isBlank()) { - SyncMetaPayload() - } else { - etags[WEBDAV_META_FILE] = metaGot.etag - parseMeta(metaGot.body) - } + val readMeta = interpretRemoteMeta(metaGot.found, metaGot.body, metaGot.etag, listedMetaEtag) + if (readMeta.recordEtag) etags[WEBDAV_META_FILE] = readMeta.etag + val meta = readMeta.meta val profiles = mutableListOf() val unreadable = mutableSetOf() - for (entry in nested) { - if (entry.collection || isWebdavTempFileName(entry.name) || !entry.name.endsWith(".json")) continue + val files = nested.filter { !it.collection && !isWebdavTempFileName(it.name) && it.name.endsWith(".json") } + var downloaded = 0 + for (entry in files) { + downloaded += 1 + progress("download", downloaded, files.size, entry.name.removeSuffix(".json")) val name = "$WEBDAV_PROFILES_DIR/${entry.name}" val got = client.get(config, name) if (!got.found) continue @@ -120,14 +140,16 @@ class WebdavSync( return RemoteBundle(profiles, meta, etags, unreadable) } - private fun upload(config: WebdavConfig, remote: RemoteBundle, merged: MergeResult): Int { + private fun upload(config: WebdavConfig, remote: RemoteBundle, merged: MergeResult, report: Boolean): Int { val remoteByUuid = remote.profiles.associateBy { it.uuid } var uploaded = 0 - val kept = mutableSetOf() - for (profile in merged.profiles) { + val kept = merged.profiles.map { profileFileName(it.uuid) }.toMutableSet() + val writable = merged.profiles.filter { profileFileName(it.uuid) !in remote.unreadable } + var seen = 0 + for (profile in writable) { + seen += 1 val name = profileFileName(profile.uuid) - kept.add(name) - if (name in remote.unreadable) continue + if (report) progress("upload", seen, writable.size, profile.name) val existing = remoteByUuid[profile.uuid] if (existing != null && sameRemote(existing, profile)) continue client.put( @@ -139,11 +161,12 @@ class WebdavSync( ) uploaded += 1 } + val doomed = remote.etags.keys.filter { isProfileFileName(it) && it !in kept && it !in remote.unreadable } var removedRemote = 0 - for (name in remote.etags.keys) { - if (!isProfileFileName(name) || name in kept || name in remote.unreadable) continue - client.delete(config, name) + for (name in doomed) { removedRemote += 1 + if (report) progress("delete", removedRemote, doomed.size, name.substringAfterLast('/').removeSuffix(".json")) + client.delete(config, name, ifMatch = remote.etags[name]) } val metaChanged = uploaded > 0 || removedRemote > 0 || remote.meta.access() != merged.access || @@ -186,15 +209,25 @@ class WebdavSync( } private fun config(settings: StoredWebdavSettings): WebdavConfig { - if (settings.url.isBlank()) throw WebdavException("empty_url") if (settings.username.isBlank()) throw WebdavException("empty_user") if (settings.password.isEmpty()) throw WebdavException("empty_password") + val lan = normalizeWebdavLanUrl(settings.lanUrl) + val base = resolveWebdavBaseUrl( + url = settings.url, + lanUrl = lan, + remotePath = settings.remotePath, + allowLan = prefersWebdavLan(context), + probe = { url -> client.reachable(url) }, + ) + val allowPrivateHttp = base.trimEnd('/') == lan.trimEnd('/') + joinWebdavUrl(base, resolveWebdavRemoteFolder(settings.remotePath), allowPrivateHttp) return WebdavConfig( - url = settings.url.trim(), + url = base, username = settings.username.trim(), password = settings.password, remotePath = resolveWebdavRemoteFolder(settings.remotePath), clientId = WebdavLocalState(context).clientId(), + allowPrivateHttp = allowPrivateHttp, ) } @@ -209,9 +242,45 @@ class WebdavSync( const val ACTION_TEST = "test" const val ACTION_SYNC = "sync" const val ACTION_PULL = "pull" + const val ACTION_PUSH = "push" private val gate = Mutex() } + + private fun progress(phase: String, current: Int, total: Int, label: String) { + try { + onProgress(phase, current, total, label) + } catch (_: Exception) { + } + } +} + +internal fun prefersWebdavLan(context: Context): Boolean { + val connectivity = context.getSystemService(ConnectivityManager::class.java) ?: return false + @Suppress("DEPRECATION") + val networks = connectivity.allNetworks + return networks.any { network -> + val capabilities = connectivity.getNetworkCapabilities(network) ?: return@any false + // A Wi-Fi network that failed internet validation can still reach a LAN server. + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) + } +} + +internal data class RemoteMetaRead( + val meta: SyncMetaPayload, + val etag: String?, + val recordEtag: Boolean, +) + +internal fun interpretRemoteMeta( + found: Boolean, + body: String, + getEtag: String?, + listedEtag: String?, +): RemoteMetaRead { + if (!found) return RemoteMetaRead(SyncMetaPayload(), null, recordEtag = false) + if (body.isBlank()) throw WebdavException("broken_meta") + return RemoteMetaRead(parseMeta(body), getEtag ?: listedEtag, recordEtag = true) } private fun sameRemote(existing: ProfilePayload, profile: ProfilePayload): Boolean { @@ -229,6 +298,9 @@ private fun WebdavException.localize(context: Context): String { "empty_url" -> context.getString(R.string.webdav_error_empty_url) "bad_url" -> context.getString(R.string.webdav_error_bad_url) "https" -> context.getString(R.string.webdav_error_https) + "lan_http" -> context.getString(R.string.webdav_error_lan_http) + "lan_offline" -> context.getString(R.string.webdav_error_lan_offline) + "lan_unreachable" -> context.getString(R.string.webdav_error_lan_unreachable) "empty_user" -> context.getString(R.string.webdav_error_empty_user) "empty_password" -> context.getString(R.string.webdav_error_empty_password) "auth" -> context.getString(R.string.webdav_error_auth) diff --git a/service/src/main/res/values-zh/strings.xml b/service/src/main/res/values-zh/strings.xml index 2f96f79135..6252a43761 100644 --- a/service/src/main/res/values-zh/strings.xml +++ b/service/src/main/res/values-zh/strings.xml @@ -21,6 +21,9 @@ 请先填写 WebDAV 地址 WebDAV 地址无效 WebDAV 地址必须是 https + 局域网 http 只能指向内网地址 + 当前不在 Wi-Fi,无法使用局域网地址 + 局域网 WebDAV 连不通,且没有默认地址 请先填写 WebDAV 用户名 请先填写 WebDAV 密码 WebDAV 认证失败,请检查用户名和密码 diff --git a/service/src/main/res/values/strings.xml b/service/src/main/res/values/strings.xml index 88cd232287..108f0f09de 100644 --- a/service/src/main/res/values/strings.xml +++ b/service/src/main/res/values/strings.xml @@ -24,6 +24,9 @@ Enter the WebDAV address Invalid WebDAV address WebDAV address must use https + A LAN http address must point at a private network + Not on Wi-Fi, so the LAN address cannot be used + The LAN WebDAV did not respond, and there is no default address Enter the WebDAV username Enter the WebDAV password WebDAV authentication failed. Check the username and password diff --git a/service/src/test/java/com/github/kr328/clash/service/webdav/WebdavClientTest.kt b/service/src/test/java/com/github/kr328/clash/service/webdav/WebdavClientTest.kt new file mode 100644 index 0000000000..c7852a7878 --- /dev/null +++ b/service/src/test/java/com/github/kr328/clash/service/webdav/WebdavClientTest.kt @@ -0,0 +1,210 @@ +package com.github.kr328.clash.service.webdav + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.InputStream +import java.net.ServerSocket +import java.util.concurrent.atomic.AtomicReference + +class WebdavClientTest { + private val config = WebdavConfig( + url = "https://example.com/dav", + username = "user", + password = "secret", + remotePath = "/sync/Configs/clash-meta", + clientId = "abcd1234", + ) + + @Test + fun unchangedDestinationSurvivesMoveIfMatchOnTheTempFile() { + val calls = mutableListOf() + val client = client { request -> + calls += request + when (calls.size) { + 1 -> HttpResponse(201, "Created", "", emptyMap()) + 2 -> { + assertEquals("MOVE", request.method) + assertEquals("\"abc\"", request.headers["If-Match"]) + HttpResponse(412, "Precondition Failed", "", emptyMap()) + } + 3 -> HttpResponse(207, "Multi-Status", """"abc"""", emptyMap()) + 4 -> { + assertEquals("MOVE", request.method) + assertFalse(request.headers.keys.any { it.equals("If-Match", ignoreCase = true) }) + HttpResponse(204, "No Content", "", mapOf("ETag" to "\"def\"")) + } + else -> throw AssertionError("unexpected ${request.method}") + } + } + val written = client.put(config, "_sync.json", "{}", ifMatch = "\"abc\"") + assertEquals("\"def\"", written.etag) + assertEquals(4, calls.size) + } + + @Test + fun changedDestinationIsAConflict() { + val calls = mutableListOf() + val client = client { request -> + calls += request + when (calls.size) { + 1 -> HttpResponse(201, "Created", "", emptyMap()) + 2 -> HttpResponse(412, "Precondition Failed", "", emptyMap()) + 3 -> HttpResponse(207, "Multi-Status", """"other"""", emptyMap()) + 4 -> { + assertEquals("DELETE", request.method) + assertTrue(request.url.endsWith(".tmp")) + HttpResponse(204, "No Content", "", emptyMap()) + } + else -> throw AssertionError("unexpected ${request.method}") + } + } + try { + client.put(config, "_sync.json", "{}", ifMatch = "\"abc\"") + throw AssertionError("conflict") + } catch (err: WebdavConflictException) { + assertEquals(412, err.status) + } + assertEquals("DELETE", calls.last().method) + } + + @Test + fun deleteSendsIfMatchAndTreats412AsConflict() { + val calls = mutableListOf() + val client = client { request -> + calls += request + assertEquals("\"abc\"", request.headers["If-Match"]) + HttpResponse(412, "Precondition Failed", "", emptyMap()) + } + try { + client.delete(config, "profiles/id.json", ifMatch = "\"abc\"") + throw AssertionError("conflict") + } catch (_: WebdavConflictException) { + } + assertEquals("DELETE", calls.single().method) + val gone = client { HttpResponse(404, "Not Found", "", emptyMap()) } + gone.delete(config, "profiles/id.json", ifMatch = "\"abc\"") + } + + @Test + fun probeAcceptsWebdavResponsesOnly() { + assertTrue(isWebdavEndpointResponse(HttpResponse(207, "Multi-Status", "", emptyMap()))) + assertTrue(isWebdavEndpointResponse(HttpResponse(200, "OK", "", mapOf("DAV" to "1, 2")))) + assertTrue( + isWebdavEndpointResponse( + HttpResponse(401, "Unauthorized", "", mapOf("WWW-Authenticate" to "Basic realm=\"alist\"")), + ), + ) + assertFalse(isWebdavEndpointResponse(HttpResponse(401, "Unauthorized", "", emptyMap()))) + assertFalse(isWebdavEndpointResponse(HttpResponse(200, "OK", "hello", emptyMap()))) + assertFalse(isWebdavEndpointResponse(HttpResponse(404, "Not Found", "", emptyMap()))) + } + + @Test + fun lanProbeDoesNotSendCredentials() { + val seen = AtomicReference("") + val url = serve( + "HTTP/1.1 401 Unauthorized\r\nWWW-Authenticate: Basic realm=\"alist\"\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + seen, + ) + assertTrue(WebdavClient().reachable("$url/dav/")) + assertFalse(seen.get().contains("Authorization")) + assertTrue(seen.get().contains("PROPFIND /dav/")) + } + + @Test + fun plainHttpIsNotALanWebdavServer() { + val seen = AtomicReference("") + val url = serve( + "HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok", + seen, + ) + assertFalse(WebdavClient().reachable("$url/")) + assertTrue(seen.get().contains("PROPFIND /")) + assertFalse(seen.get().contains("Authorization")) + } + + @Test + fun privateHttpReadsAChunkedWebdavBody() { + val url = serve( + "HTTP/1.1 207 Multi-Status\r\nDAV: 1, 2\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n5\r\nhello\r\n0\r\n\r\n", + AtomicReference(""), + ) + val response = PrivateHttpTransport(2_000, 2_000).execute( + HttpRequest("PROPFIND", "$url/dav/", emptyMap(), ""), + ) + assertEquals(207, response.status) + assertEquals("Multi-Status", response.statusText) + assertEquals("hello", response.body) + assertEquals("1, 2", response.header("DAV")) + } + + @Test + fun privateHttpRejectsAPublicHost() { + try { + PrivateHttpTransport(500, 500).execute(HttpRequest("GET", "http://example.com/dav", emptyMap())) + throw AssertionError("public http") + } catch (err: WebdavException) { + assertEquals("lan_http", err.code) + } + } + + private fun serve(response: String, seen: AtomicReference): String { + val server = ServerSocket(0) + server.soTimeout = 5_000 + val url = "http://127.0.0.1:${server.localPort}" + val thread = Thread { + try { + server.accept().use { socket -> + seen.set(readHttpRequest(socket.getInputStream())) + socket.getOutputStream().write(response.toByteArray(Charsets.ISO_8859_1)) + socket.getOutputStream().flush() + } + } finally { + server.close() + } + } + thread.isDaemon = true + thread.start() + return url + } + + private fun readHttpRequest(input: InputStream): String { + val text = StringBuilder() + while (true) { + val line = readTestLine(input) + text.append(line).append('\n') + if (line.isEmpty()) break + } + val length = Regex("(?i)content-length: (\\d+)").find(text)?.groupValues?.get(1)?.toIntOrNull() ?: 0 + if (length > 0) { + val body = ByteArray(length) + var off = 0 + while (off < length) { + val n = input.read(body, off, length - off) + if (n < 0) break + off += n + } + text.append(body.toString(Charsets.UTF_8)) + } + return text.toString() + } + + private fun readTestLine(input: InputStream): String { + val out = StringBuilder() + while (true) { + val b = input.read() + if (b < 0 || b == '\n'.code) break + if (b != '\r'.code) out.append(b.toChar()) + } + return out.toString() + } + + private fun client(handler: (HttpRequest) -> HttpResponse): WebdavClient { + val transport = object : HttpTransport { + override fun execute(request: HttpRequest): HttpResponse = handler(request) + } + return WebdavClient(transport, transport) + } +} diff --git a/service/src/test/java/com/github/kr328/clash/service/webdav/WebdavSyncTest.kt b/service/src/test/java/com/github/kr328/clash/service/webdav/WebdavSyncTest.kt index 2e83bf43c4..c0baa9abc1 100644 --- a/service/src/test/java/com/github/kr328/clash/service/webdav/WebdavSyncTest.kt +++ b/service/src/test/java/com/github/kr328/clash/service/webdav/WebdavSyncTest.kt @@ -24,7 +24,84 @@ class WebdavSyncTest { fileUrl("https://example.com/dav", "/clash-meta", "profiles/a.json"), ) assertEquals("/clash-meta", resolveWebdavRemoteFolder("/clash-meta/accounts.json")) - assertEquals("/clash-meta", resolveWebdavRemoteFolder("")) + assertEquals("/sync/Configs/clash-meta", resolveWebdavRemoteFolder("")) + assertEquals(WEBDAV_DEFAULT_URL, normalizeWebdavUrl("")) + assertEquals(WEBDAV_DEFAULT_URL, normalizeWebdavUrl(WEBDAV_LEGACY_MISREAD_URL)) + assertEquals(WEBDAV_DEFAULT_REMOTE_PATH, normalizeWebdavRemotePath("/clash-meta")) + assertEquals(WEBDAV_DEFAULT_REMOTE_PATH, normalizeWebdavRemotePath("/sync/Configs/cursor-manager")) + assertEquals("", normalizeWebdavLanUrl("")) + assertEquals("", normalizeWebdavLanUrl(null)) + assertEquals("", normalizeWebdavLanUrl(" ")) + assertEquals("http://192.168.1.8:5244/dav", normalizeWebdavLanUrl("http://192.168.1.8:5244/dav/")) + assertEquals("", seedWebdavSettings(StoredWebdavSettings(password = "x", autoSync = true, lanUrl = "")).lanUrl) + assertEquals(WEBDAV_DEFAULT_LAN_URL, seedWebdavSettings(StoredWebdavSettings()).lanUrl) + } + + @Test + fun lanHttpIsPrivateOnly() { + assertEquals( + "http://192.168.1.69:5244/dav/sync/Configs/clash-meta/profiles/a.json", + joinWebdavUrl("http://192.168.1.69:5244/dav", "/sync/Configs/clash-meta/profiles/a.json", allowPrivateHttp = true), + ) + try { + joinWebdavUrl("http://192.168.1.69:5244/dav", "/sync/Configs/clash-meta/a.json") + throw AssertionError("lan http needs the private flag") + } catch (err: WebdavException) { + assertEquals("https", err.code) + } + try { + joinWebdavUrl("http://example.com/dav", "/sync/Configs/clash-meta/a.json", allowPrivateHttp = true) + throw AssertionError("public http is rejected") + } catch (err: WebdavException) { + assertEquals("lan_http", err.code) + } + } + + @Test + fun lanProbeFallsBackToPublicAddress() { + val folder = "http://192.168.1.69:5244/dav/sync/Configs/clash-meta/" + assertEquals( + "http://192.168.1.69:5244/dav", + resolveWebdavBaseUrl( + url = WEBDAV_DEFAULT_URL, + lanUrl = WEBDAV_DEFAULT_LAN_URL, + remotePath = WEBDAV_DEFAULT_REMOTE_PATH, + allowLan = true, + probe = { it == folder }, + ), + ) + assertEquals( + WEBDAV_DEFAULT_URL, + resolveWebdavBaseUrl( + url = WEBDAV_DEFAULT_URL, + lanUrl = WEBDAV_DEFAULT_LAN_URL, + remotePath = WEBDAV_DEFAULT_REMOTE_PATH, + allowLan = true, + probe = { false }, + ), + ) + assertEquals( + WEBDAV_DEFAULT_URL, + resolveWebdavBaseUrl( + url = WEBDAV_DEFAULT_URL, + lanUrl = WEBDAV_DEFAULT_LAN_URL, + remotePath = WEBDAV_DEFAULT_REMOTE_PATH, + allowLan = false, + probe = { true }, + ), + ) + try { + resolveWebdavBaseUrl( + url = "", + lanUrl = WEBDAV_DEFAULT_LAN_URL, + remotePath = WEBDAV_DEFAULT_REMOTE_PATH, + allowLan = false, + probe = { true }, + ) + throw AssertionError("wifi is required when only the lan address is set") + } catch (err: WebdavException) { + assertEquals("lan_offline", err.code) + } } @Test @@ -189,6 +266,132 @@ class WebdavSyncTest { assertEquals(listOf("com.a", "com.c"), merged.strategies.first { it.uuid == "a" }.packages) } + @Test + fun remotePathCannotLeaveTheWebdavOrigin() { + assertEquals( + "https://example.com/dav/My%20Configs/a.json", + joinWebdavUrl("https://example.com/dav", "/My Configs/a.json"), + ) + for (path in listOf("/../etc/passwd", "http://evil.example/x", "//evil.example/x")) { + try { + joinWebdavUrl("https://example.com/dav", path) + throw AssertionError(path) + } catch (err: WebdavException) { + assertEquals(path, "bad_path", err.code) + } + } + } + + @Test + fun brokenLanTargetFallsBackAndPublicHttpIsRejected() { + var probed = false + assertEquals( + WEBDAV_DEFAULT_URL, + resolveWebdavBaseUrl( + url = WEBDAV_DEFAULT_URL, + lanUrl = WEBDAV_DEFAULT_LAN_URL, + remotePath = "/../escape", + allowLan = true, + probe = { probed = true; true }, + ), + ) + assertFalse(probed) + val encoded = mutableListOf() + assertEquals( + WEBDAV_DEFAULT_LAN_URL, + resolveWebdavBaseUrl( + url = WEBDAV_DEFAULT_URL, + lanUrl = WEBDAV_DEFAULT_LAN_URL, + remotePath = "/My Configs", + allowLan = true, + probe = { encoded += it; true }, + ), + ) + assertEquals(listOf("http://192.168.1.69:5244/dav/My%20Configs/"), encoded) + try { + resolveWebdavBaseUrl( + url = "http://10.0.0.8/dav", + lanUrl = "", + remotePath = WEBDAV_DEFAULT_REMOTE_PATH, + allowLan = false, + probe = { true }, + ) + throw AssertionError("public http") + } catch (err: WebdavException) { + assertEquals("https", err.code) + } + } + + @Test + fun sameSideDuplicateKeepsTheNewerProfile() { + val older = profile("aaa", source = "https://example.com/sub", updatedAt = 10, name = "old") + val newer = profile("zzz", source = "https://example.com/sub", updatedAt = 40, name = "new") + val merged = mergeWebdav( + listOf(older, newer), + emptyList(), + emptyList(), + emptyList(), + access(), + access(), + now = 100, + ) + assertEquals("zzz", merged.profiles.single().uuid) + assertEquals("new", merged.profiles.single().name) + val tied = mergeWebdav( + listOf( + profile("aaa", source = "https://example.com/sub", updatedAt = 40), + profile("mmm", source = "https://example.com/sub", updatedAt = 40), + ), + emptyList(), + emptyList(), + emptyList(), + access(), + access(), + now = 100, + ) + assertEquals("mmm", tied.profiles.single().uuid) + } + + @Test + fun sameSideDuplicateStrategyKeepsTheNewerCopy() { + val older = AppsStrategyPayload("a", "Old", "AcceptAll", emptyList(), 10) + val newer = AppsStrategyPayload("a", "New", "AcceptSelected", listOf("com.a"), 30) + val merged = mergeStrategies(listOf(older, newer), emptyList(), emptyList(), emptyList(), 100) + assertEquals("New", merged.strategies.single().name) + assertEquals(listOf("com.a"), merged.strategies.single().packages) + } + + @Test + fun droppedTombstoneIsNotWrittenBack() { + val stale = TombstonePayload("id", "https://example.com/sub", "Url", 100) + val fresh = TombstonePayload("other", "", "File", 900) + val stored = retainMergedTombstones( + merged = emptyList(), + snapshot = listOf(stale), + current = listOf(stale, fresh), + now = 1_000, + ) + assertEquals(listOf(fresh), stored) + } + + @Test + fun emptyMetaIsBrokenAndExistingMetaKeepsItsEtag() { + try { + interpretRemoteMeta(found = true, body = " ", getEtag = "\"abc\"", listedEtag = null) + throw AssertionError("empty meta") + } catch (err: WebdavException) { + assertEquals("broken_meta", err.code) + } + val body = encodeMeta(SyncMetaPayload()) + val fromList = interpretRemoteMeta(found = true, body = body, getEtag = null, listedEtag = "\"from-list\"") + assertTrue(fromList.recordEtag) + assertEquals("\"from-list\"", fromList.etag) + val missing = interpretRemoteMeta(found = false, body = "", getEtag = null, listedEtag = "\"x\"") + assertFalse(missing.recordEtag) + assertTrue(webdavEtagsMatch("W/\"abc\"", "\"abc\"")) + assertFalse(webdavEtagsMatch("\"abc\"", "\"def\"")) + } + @Test fun rejectsUnsafePathsAndBrokenProfiles() { try { From ff5dbdfa90024d9c654712ff1c6c67c1b62ddb83 Mon Sep 17 00:00:00 2001 From: clarence Date: Tue, 20 Oct 2026 22:18:58 +0800 Subject: [PATCH 08/10] Replace the remaining XML screens with Compose, and keep subscription usage and the running tunnel when a refresh or TUN start fails. Co-authored-by: Cursor --- .gitignore | 4 + AGENTS.md | 56 + CLAUDE.md | 5 + DESIGN.md | 41 + NOTICE | 9 + README.md | 4 + app/README.md | 16 + app/docs/INDEX.md | 31 + app/docs/activities.md | 138 + app/docs/logcat.md | 69 + app/docs/remote.md | 97 + app/src/main/AndroidManifest.xml | 7 +- .../kr328/clash/AccessControlActivity.kt | 60 +- .../kr328/clash/AppsStrategyActivity.kt | 44 +- .../kr328/clash/ExternalControlActivity.kt | 32 +- .../com/github/kr328/clash/FilesActivity.kt | 10 +- .../com/github/kr328/clash/LogcatActivity.kt | 2 +- .../com/github/kr328/clash/MainActivity.kt | 50 +- .../com/github/kr328/clash/MainApplication.kt | 46 +- .../clash/MetaFeatureSettingsActivity.kt | 26 +- .../github/kr328/clash/NewProfileActivity.kt | 4 +- .../github/kr328/clash/PropertiesActivity.kt | 22 +- .../com/github/kr328/clash/ProxyActivity.kt | 137 +- .../com/github/kr328/clash/TileService.kt | 24 +- .../github/kr328/clash/VpnRequestActivity.kt | 58 + .../kr328/clash/WebdavSettingsActivity.kt | 2 + .../com/github/kr328/clash/util/GeoFiles.kt | 121 + build.gradle.kts | 2 +- common/README.md | 16 + common/docs/INDEX.md | 73 + common/docs/constants.md | 79 + common/docs/store.md | 56 + .../kr328/clash/common/compat/Context.kt | 5 +- .../kr328/clash/common/compat/Intents.kt | 10 +- .../github/kr328/clash/common/compat/UI.kt | 64 +- .../github/kr328/clash/common/util/Ticker.kt | 4 +- core/README.md | 16 + core/docs/INDEX.md | 22 + core/docs/bridge.md | 130 + core/docs/models.md | 300 ++ core/docs/native-layout.md | 158 + core/src/main/cpp/main.c | 16 +- core/src/main/golang/native/config.go | 11 +- core/src/main/golang/native/config/fetch.go | 47 +- .../src/main/golang/native/config/override.go | 47 +- core/src/main/golang/native/config/process.go | 20 +- .../src/main/golang/native/config/provider.go | 12 +- core/src/main/golang/native/tun.go | 15 +- core/src/main/golang/native/tun/tun.go | 2 + core/src/main/golang/native/tunnel/proxies.go | 2 + .../java/com/github/kr328/clash/core/Clash.kt | 10 +- .../github/kr328/clash/core/bridge/Bridge.kt | 4 +- .../github/kr328/clash/core/util/Traffic.kt | 6 +- design/README.md | 18 + design/build.gradle.kts | 10 + design/docs/INDEX.md | 23 + design/docs/adapters.md | 18 + design/docs/compose.md | 40 + design/docs/preferences.md | 44 + design/docs/screens.md | 79 + .../kr328/clash/design/AccessControlDesign.kt | 285 +- .../kr328/clash/design/ApkBrokenDesign.kt | 73 +- .../kr328/clash/design/AppCrashedDesign.kt | 72 +- .../kr328/clash/design/AppSettingsDesign.kt | 180 +- .../kr328/clash/design/AppsStrategyDesign.kt | 91 +- .../com/github/kr328/clash/design/Design.kt | 19 +- .../github/kr328/clash/design/FilesDesign.kt | 157 +- .../github/kr328/clash/design/HelpDesign.kt | 107 +- .../github/kr328/clash/design/LogcatDesign.kt | 108 +- .../github/kr328/clash/design/LogsDesign.kt | 79 +- .../github/kr328/clash/design/MainDesign.kt | 129 +- .../clash/design/MetaFeatureSettingsDesign.kt | 637 ++-- .../clash/design/NetworkSettingsDesign.kt | 281 +- .../kr328/clash/design/NewProfileDesign.kt | 82 +- .../clash/design/OverrideSettingsDesign.kt | 949 +++-- .../kr328/clash/design/ProfilesDesign.kt | 187 +- .../kr328/clash/design/PropertiesDesign.kt | 104 +- .../kr328/clash/design/ProvidersDesign.kt | 102 +- .../github/kr328/clash/design/ProxyDesign.kt | 278 +- .../kr328/clash/design/SettingsDesign.kt | 60 +- .../clash/design/WebdavSettingsDesign.kt | 308 +- .../kr328/clash/design/adapter/AppAdapter.kt | 51 - .../clash/design/adapter/AppSetAdapter.kt | 116 - .../adapter/AppsStrategyConfigAdapter.kt | 44 - .../design/adapter/EditableTextMapAdapter.kt | 12 +- .../kr328/clash/design/adapter/FileAdapter.kt | 53 - .../clash/design/adapter/LogFileAdapter.kt | 38 - .../clash/design/adapter/LogMessageAdapter.kt | 40 - .../clash/design/adapter/ProfileAdapter.kt | 56 - .../design/adapter/ProfileProviderAdapter.kt | 48 - .../clash/design/adapter/ProviderAdapter.kt | 75 - .../clash/design/adapter/ProxyAdapter.kt | 44 - .../clash/design/adapter/ProxyPageAdapter.kt | 112 - .../design/component/AccessControlMenu.kt | 97 - .../kr328/clash/design/component/ProxyMenu.kt | 120 - .../design/component/ProxyPageFactory.kt | 61 - .../kr328/clash/design/component/ProxyView.kt | 195 -- .../clash/design/component/ProxyViewConfig.kt | 40 - .../clash/design/component/ProxyViewState.kt | 127 - .../github/kr328/clash/design/dialog/Input.kt | 10 +- .../clash/design/ios/AccessControlScreen.kt | 315 ++ .../kr328/clash/design/ios/ApkBrokenScreen.kt | 94 + .../clash/design/ios/AppCrashedScreen.kt | 147 + .../clash/design/ios/AppSettingsScreen.kt | 146 + .../clash/design/ios/AppsStrategyScreen.kt | 183 + .../kr328/clash/design/ios/FilesScreen.kt | 171 + .../kr328/clash/design/ios/HelpScreen.kt | 128 + .../kr328/clash/design/ios/HomeScreen.kt | 276 ++ .../kr328/clash/design/ios/IosComponents.kt | 523 +++ .../kr328/clash/design/ios/IosControls.kt | 657 ++++ .../github/kr328/clash/design/ios/IosTheme.kt | 132 + .../kr328/clash/design/ios/LogcatScreen.kt | 252 ++ .../kr328/clash/design/ios/LogsScreen.kt | 87 + .../design/ios/MetaFeatureSettingsScreen.kt | 21 + .../clash/design/ios/NetworkSettingsScreen.kt | 253 ++ .../clash/design/ios/NewProfileScreen.kt | 96 + .../design/ios/OverrideSettingsScreen.kt | 164 + .../kr328/clash/design/ios/ProfilesScreen.kt | 303 ++ .../clash/design/ios/PropertiesScreen.kt | 228 ++ .../kr328/clash/design/ios/ProvidersScreen.kt | 175 + .../kr328/clash/design/ios/ProxyScreen.kt | 322 ++ .../kr328/clash/design/ios/SettingsScreen.kt | 73 + .../clash/design/ios/WebdavSettingsScreen.kt | 253 ++ .../clash/design/model/ProfilePageState.kt | 5 - .../kr328/clash/design/model/ProviderState.kt | 28 - .../clash/design/model/ProxyPageState.kt | 6 - .../kr328/clash/design/preference/Value.kt | 3 +- .../kr328/clash/design/util/ActivityBar.kt | 21 - .../kr328/clash/design/util/Elevation.kt | 59 - .../kr328/clash/design/util/ScrollView.kt | 6 - .../kr328/clash/design/view/ActionLabel.kt | 62 - .../clash/design/view/ActionTextField.kt | 87 - .../clash/design/view/ActivityBarLayout.kt | 28 - .../clash/design/view/AppRecyclerView.kt | 17 - .../clash/design/view/LargeActionCard.kt | 65 - .../clash/design/view/LargeActionLabel.kt | 75 - .../clash/design/view/ObservableScrollView.kt | 32 - .../main/java/nota/ui/ios/ShellIosColors.kt | 71 + .../main/java/nota/ui/ios/ShellIosPalette.kt | 34 + .../main/java/nota/ui/ios/ShellIosSpecs.kt | 106 + .../main/java/nota/ui/list/ListRowColors.kt | 47 + .../main/java/nota/ui/list/ListRowLayout.kt | 40 + .../src/main/java/nota/ui/list/ListRowSpec.kt | 56 + .../main/java/nota/ui/list/ListRowVariant.kt | 16 + .../main/java/nota/ui/list/ShellListRow.kt | 340 ++ .../nota/ui/settings/PreferenceChevron.kt | 57 + .../ui/settings/PreferenceGroupPosition.kt | 19 + .../nota/ui/settings/PreferenceItemColors.kt | 45 + .../nota/ui/settings/PreferenceItemSpec.kt | 193 ++ .../nota/ui/settings/ShellPreferenceItem.kt | 263 ++ .../main/java/nota/ui/switch/ShellSwitch.kt | 193 ++ .../nota/ui/switch/SwitchAnimationSpec.kt | 20 + .../main/java/nota/ui/switch/SwitchColors.kt | 50 + .../java/nota/ui/switch/SwitchGeometry.kt | 86 + .../main/java/nota/ui/switch/SwitchHaptic.kt | 26 + .../java/nota/ui/switch/SwitchTrackSpec.kt | 58 + .../main/java/nota/ui/theme/ShellCorner.kt | 23 + .../java/nota/ui/theme/ShellLineHeight.kt | 7 + .../src/main/java/nota/ui/theme/ShellPress.kt | 58 + .../main/java/nota/ui/theme/ShellSpacing.kt | 27 + .../universe/agentic/ui/search/SearchField.kt | 188 + .../ui/search/SearchFieldClearButton.kt | 91 + .../agentic/ui/search/SearchFieldDimens.kt | 21 + .../agentic/ui/search/SearchFieldLayout.kt | 68 + .../agentic/ui/search/SearchFieldVariant.kt | 10 + design/src/main/res/layout/adapter_app.xml | 64 - .../src/main/res/layout/adapter_app_set.xml | 77 - .../res/layout/adapter_app_set_divider.xml | 7 - .../res/layout/adapter_app_set_header.xml | 17 - .../layout/adapter_apps_strategy_config.xml | 119 - design/src/main/res/layout/adapter_file.xml | 105 - .../main/res/layout/adapter_log_message.xml | 50 - .../src/main/res/layout/adapter_profile.xml | 142 - .../res/layout/adapter_profile_provider.xml | 44 - .../src/main/res/layout/adapter_provider.xml | 99 - .../res/layout/adapter_sideload_provider.xml | 69 - .../main/res/layout/common_activity_bar.xml | 28 - .../main/res/layout/common_recycler_list.xml | 16 - .../res/layout/component_action_label.xml | 39 - .../layout/component_action_text_field.xml | 62 - .../layout/component_large_action_label.xml | 37 - .../main/res/layout/design_access_control.xml | 70 - .../main/res/layout/design_app_crashed.xml | 43 - .../main/res/layout/design_apps_strategy.xml | 60 - design/src/main/res/layout/design_files.xml | 68 - design/src/main/res/layout/design_logcat.xml | 102 - design/src/main/res/layout/design_logs.xml | 89 - design/src/main/res/layout/design_main.xml | 167 - .../main/res/layout/design_new_profile.xml | 31 - .../src/main/res/layout/design_profiles.xml | 84 - .../src/main/res/layout/design_properties.xml | 171 - .../src/main/res/layout/design_providers.xml | 59 - design/src/main/res/layout/design_proxy.xml | 134 - .../src/main/res/layout/design_settings.xml | 80 - .../res/layout/design_settings_common.xml | 39 - .../layout/design_settings_meta_feature.xml | 66 - .../res/layout/design_settings_overide.xml | 66 - .../src/main/res/layout/dialog_files_menu.xml | 65 - .../main/res/layout/dialog_profiles_menu.xml | 68 - design/src/main/res/layout/dialog_search.xml | 52 - .../src/main/res/menu/menu_access_control.xml | 56 - design/src/main/res/menu/menu_proxy.xml | 60 - design/src/main/res/values-ja-rJP/strings.xml | 7 +- design/src/main/res/values-ko-rKR/strings.xml | 7 +- design/src/main/res/values-ru/strings.xml | 9 +- design/src/main/res/values-vi/strings.xml | 15 +- design/src/main/res/values-zh-rHK/strings.xml | 142 +- design/src/main/res/values-zh-rTW/strings.xml | 132 +- design/src/main/res/values-zh/strings.xml | 102 +- design/src/main/res/values/attrs.xml | 35 - design/src/main/res/values/strings.xml | 56 +- design/src/main/res/values/themes.xml | 22 + .../preference/NullableTextAdapterPortTest.kt | 16 + dev/README.md | 26 + dev/archive/README.md | 16 + dev/archive/docs-product-requirements.md | 38 + dev/decisions/001-docs-system.md | 38 + dev/decisions/002-compose-ios-home.md | 46 + dev/decisions/INDEX.md | 17 + dev/demand/INDEX.md | 14 + dev/iterations/INDEX.md | 14 + dev/parallel/INDEX.md | 14 + .../archive/compose-remaining-screens.md | 96 + dev/parallel/archive/ui-visual-consistency.md | 96 + dev/plans/INDEX.md | 20 + dev/plans/compose-remaining-screens.md | 731 ++++ dev/plans/overview.md | 18 + dev/plans/review-2026-10-20-r01-app-plan.md | 53 + dev/plans/review-2026-10-20-r01-app.md | 258 ++ .../review-2026-10-20-r02-service-plan.md | 53 + dev/plans/review-2026-10-20-r02-service.md | 349 ++ .../review-2026-10-20-r03-common-plan.md | 69 + dev/plans/review-2026-10-20-r03-common.md | 164 + .../review-2026-10-20-r04-hideapi-plan.md | 23 + dev/plans/review-2026-10-20-r04-hideapi.md | 19 + .../review-2026-10-20-r05-build-meta-plan.md | 46 + dev/plans/review-2026-10-20-r05-build-meta.md | 109 + .../review-2026-10-20-r06-design-ui-plan.md | 54 + dev/plans/review-2026-10-20-r06-design-ui.md | 189 + ...review-2026-10-20-r07-design-shell-plan.md | 45 + .../review-2026-10-20-r07-design-shell.md | 208 ++ .../review-2026-10-20-r08-core-bridge-plan.md | 89 + .../review-2026-10-20-r08-core-bridge.md | 302 ++ ...eview-2026-10-20-r09-clash-adapter-plan.md | 53 + .../review-2026-10-20-r09-clash-adapter.md | 236 ++ ...iew-2026-10-20-r10-clash-component-plan.md | 53 + .../review-2026-10-20-r10-clash-component.md | 275 ++ ...view-2026-10-20-r11-clash-listener-plan.md | 84 + .../review-2026-10-20-r11-clash-listener.md | 443 +++ ...review-2026-10-20-r12-clash-common-plan.md | 53 + .../review-2026-10-20-r12-clash-common.md | 157 + ...iew-2026-10-20-r13-transport-heavy-plan.md | 104 + .../review-2026-10-20-r13-transport-heavy.md | 236 ++ ...iew-2026-10-20-r14-transport-proxy-plan.md | 53 + .../review-2026-10-20-r14-transport-proxy.md | 122 + ...view-2026-10-20-r15-transport-misc-plan.md | 55 + .../review-2026-10-20-r15-transport-misc.md | 196 ++ ...eview-2026-10-20-r16-clash-control-plan.md | 54 + .../review-2026-10-20-r16-clash-control.md | 190 + dev/plans/review-2026-10-20.md | 55 + dev/plans/ui-redesign-proposal.md | 64 + dev/plans/ui-timing-fixes.md | 88 + dev/plans/ui-visual-consistency.md | 165 + dev/progress/INDEX.md | 17 + dev/progress/status.md | 62 + dev/roadmap/INDEX.md | 14 + docs/DOCS-SPEC.md | 106 + docs/DOCUMENTATION.md | 202 ++ docs/INDEX.md | 56 + docs/README.md | 76 + docs/architecture/overview.md | 41 + docs/glossary.md | 24 + docs/guides/getting-started/INDEX.md | 17 + docs/guides/getting-started/build.md | 110 + docs/guides/getting-started/testing.md | 53 + docs/modules/app/INDEX.md | 24 + docs/modules/common/INDEX.md | 21 + docs/modules/core/INDEX.md | 26 + docs/modules/design/INDEX.md | 22 + docs/modules/hideapi/INDEX.md | 16 + docs/modules/service/INDEX.md | 32 + docs/product/INDEX.md | 37 + docs/product/requirements/INDEX.md | 23 + docs/product/requirements/PRD-001.md | 14 + docs/product/requirements/PRD-002.md | 14 + docs/product/requirements/PRD-003.md | 16 + docs/product/requirements/PRD-004.md | 14 + docs/product/requirements/PRD-005.md | 14 + docs/product/requirements/PRD-006.md | 16 + docs/product/traceability.md | 23 + docs/product/vision.md | 22 + docs/systems/access-control/INDEX.md | 55 + docs/systems/profiles/INDEX.md | 22 + docs/systems/profiles/lifecycle.md | 90 + docs/systems/runtime/INDEX.md | 28 + docs/systems/runtime/lifecycle.md | 68 + docs/systems/ui/INDEX.md | 61 + docs/systems/webdav/INDEX.md | 38 + docs/templates/architecture-template.md | 41 + docs/templates/decision-template.md | 31 + docs/templates/demand-template.md | 48 + docs/templates/parallel-template.md | 88 + docs/templates/plan-template.md | 43 + docs/templates/progress-template.md | 63 + gradle/libs.versions.toml | 7 + hideapi/README.md | 16 + hideapi/docs/INDEX.md | 53 + scripts/archive-status-overflow.sh | 127 + scripts/check-docs-health.py | 815 +++++ scripts/generate-docs-index.sh | 5 + service/README.md | 16 + service/docs/INDEX.md | 28 + service/docs/documents.md | 132 + service/docs/process.md | 82 + service/docs/profiles-store.md | 124 + service/docs/runtime.md | 114 + .../kr328/clash/service/ClashManager.kt | 4 + .../kr328/clash/service/ProfileProcessor.kt | 30 +- .../kr328/clash/service/ProfileWorker.kt | 30 + .../clash/module/ConfigurationModule.kt | 13 +- .../clash/service/document/FileDocument.kt | 6 +- .../kr328/clash/service/document/Picker.kt | 14 +- .../clash/service/remote/IClashManager.kt | 1 + .../kr328/clash/service/store/ServiceStore.kt | 24 +- .../clash/service/webdav/WebdavLocalState.kt | 1 + .../kr328/clash/service/webdav/WebdavMerge.kt | 1 + .../clash/service/webdav/WebdavProfiles.kt | 57 +- .../kr328/clash/service/webdav/WebdavSync.kt | 6 +- .../src/main/res/values-ja-rJP/strings.xml | 3 +- .../src/main/res/values-ko-rKR/strings.xml | 3 +- service/src/main/res/values-ru/strings.xml | 3 +- .../src/main/res/values-zh-rHK/strings.xml | 3 +- .../src/main/res/values-zh-rTW/strings.xml | 3 +- service/src/main/res/values-zh/strings.xml | 3 +- service/src/main/res/values/strings.xml | 3 +- ui-web-demo/README.md | 45 + ui-web-demo/assemble.py | 34 + ui-web-demo/generate.py | 2297 ++++++++++++ ui-web-demo/index.html | 3081 +++++++++++++++++ ui-web-demo/parts_css.py | 319 ++ ui-web-demo/parts_html.py | 1092 ++++++ ui-web-demo/parts_js.py | 1667 +++++++++ ui-web-demo/serve.py | 21 + 343 files changed, 30357 insertions(+), 6619 deletions(-) create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 DESIGN.md create mode 100644 app/README.md create mode 100644 app/docs/INDEX.md create mode 100644 app/docs/activities.md create mode 100644 app/docs/logcat.md create mode 100644 app/docs/remote.md create mode 100644 app/src/main/java/com/github/kr328/clash/VpnRequestActivity.kt create mode 100644 app/src/main/java/com/github/kr328/clash/util/GeoFiles.kt create mode 100644 common/README.md create mode 100644 common/docs/INDEX.md create mode 100644 common/docs/constants.md create mode 100644 common/docs/store.md create mode 100644 core/README.md create mode 100644 core/docs/INDEX.md create mode 100644 core/docs/bridge.md create mode 100644 core/docs/models.md create mode 100644 core/docs/native-layout.md create mode 100644 design/README.md create mode 100644 design/docs/INDEX.md create mode 100644 design/docs/adapters.md create mode 100644 design/docs/compose.md create mode 100644 design/docs/preferences.md create mode 100644 design/docs/screens.md delete mode 100644 design/src/main/java/com/github/kr328/clash/design/adapter/AppAdapter.kt delete mode 100644 design/src/main/java/com/github/kr328/clash/design/adapter/AppSetAdapter.kt delete mode 100644 design/src/main/java/com/github/kr328/clash/design/adapter/AppsStrategyConfigAdapter.kt delete mode 100644 design/src/main/java/com/github/kr328/clash/design/adapter/FileAdapter.kt delete mode 100644 design/src/main/java/com/github/kr328/clash/design/adapter/LogFileAdapter.kt delete mode 100644 design/src/main/java/com/github/kr328/clash/design/adapter/LogMessageAdapter.kt delete mode 100644 design/src/main/java/com/github/kr328/clash/design/adapter/ProfileAdapter.kt delete mode 100644 design/src/main/java/com/github/kr328/clash/design/adapter/ProfileProviderAdapter.kt delete mode 100644 design/src/main/java/com/github/kr328/clash/design/adapter/ProviderAdapter.kt delete mode 100644 design/src/main/java/com/github/kr328/clash/design/adapter/ProxyAdapter.kt delete mode 100644 design/src/main/java/com/github/kr328/clash/design/adapter/ProxyPageAdapter.kt delete mode 100644 design/src/main/java/com/github/kr328/clash/design/component/AccessControlMenu.kt delete mode 100644 design/src/main/java/com/github/kr328/clash/design/component/ProxyMenu.kt delete mode 100644 design/src/main/java/com/github/kr328/clash/design/component/ProxyPageFactory.kt delete mode 100644 design/src/main/java/com/github/kr328/clash/design/component/ProxyView.kt delete mode 100644 design/src/main/java/com/github/kr328/clash/design/component/ProxyViewConfig.kt delete mode 100644 design/src/main/java/com/github/kr328/clash/design/component/ProxyViewState.kt create mode 100644 design/src/main/java/com/github/kr328/clash/design/ios/AccessControlScreen.kt create mode 100644 design/src/main/java/com/github/kr328/clash/design/ios/ApkBrokenScreen.kt create mode 100644 design/src/main/java/com/github/kr328/clash/design/ios/AppCrashedScreen.kt create mode 100644 design/src/main/java/com/github/kr328/clash/design/ios/AppSettingsScreen.kt create mode 100644 design/src/main/java/com/github/kr328/clash/design/ios/AppsStrategyScreen.kt create mode 100644 design/src/main/java/com/github/kr328/clash/design/ios/FilesScreen.kt create mode 100644 design/src/main/java/com/github/kr328/clash/design/ios/HelpScreen.kt create mode 100644 design/src/main/java/com/github/kr328/clash/design/ios/HomeScreen.kt create mode 100644 design/src/main/java/com/github/kr328/clash/design/ios/IosComponents.kt create mode 100644 design/src/main/java/com/github/kr328/clash/design/ios/IosControls.kt create mode 100644 design/src/main/java/com/github/kr328/clash/design/ios/IosTheme.kt create mode 100644 design/src/main/java/com/github/kr328/clash/design/ios/LogcatScreen.kt create mode 100644 design/src/main/java/com/github/kr328/clash/design/ios/LogsScreen.kt create mode 100644 design/src/main/java/com/github/kr328/clash/design/ios/MetaFeatureSettingsScreen.kt create mode 100644 design/src/main/java/com/github/kr328/clash/design/ios/NetworkSettingsScreen.kt create mode 100644 design/src/main/java/com/github/kr328/clash/design/ios/NewProfileScreen.kt create mode 100644 design/src/main/java/com/github/kr328/clash/design/ios/OverrideSettingsScreen.kt create mode 100644 design/src/main/java/com/github/kr328/clash/design/ios/ProfilesScreen.kt create mode 100644 design/src/main/java/com/github/kr328/clash/design/ios/PropertiesScreen.kt create mode 100644 design/src/main/java/com/github/kr328/clash/design/ios/ProvidersScreen.kt create mode 100644 design/src/main/java/com/github/kr328/clash/design/ios/ProxyScreen.kt create mode 100644 design/src/main/java/com/github/kr328/clash/design/ios/SettingsScreen.kt create mode 100644 design/src/main/java/com/github/kr328/clash/design/ios/WebdavSettingsScreen.kt delete mode 100644 design/src/main/java/com/github/kr328/clash/design/model/ProfilePageState.kt delete mode 100644 design/src/main/java/com/github/kr328/clash/design/model/ProviderState.kt delete mode 100644 design/src/main/java/com/github/kr328/clash/design/model/ProxyPageState.kt delete mode 100644 design/src/main/java/com/github/kr328/clash/design/util/ActivityBar.kt delete mode 100644 design/src/main/java/com/github/kr328/clash/design/util/Elevation.kt delete mode 100644 design/src/main/java/com/github/kr328/clash/design/util/ScrollView.kt delete mode 100644 design/src/main/java/com/github/kr328/clash/design/view/ActionLabel.kt delete mode 100644 design/src/main/java/com/github/kr328/clash/design/view/ActionTextField.kt delete mode 100644 design/src/main/java/com/github/kr328/clash/design/view/ActivityBarLayout.kt delete mode 100644 design/src/main/java/com/github/kr328/clash/design/view/AppRecyclerView.kt delete mode 100644 design/src/main/java/com/github/kr328/clash/design/view/LargeActionCard.kt delete mode 100644 design/src/main/java/com/github/kr328/clash/design/view/LargeActionLabel.kt delete mode 100644 design/src/main/java/com/github/kr328/clash/design/view/ObservableScrollView.kt create mode 100644 design/src/main/java/nota/ui/ios/ShellIosColors.kt create mode 100644 design/src/main/java/nota/ui/ios/ShellIosPalette.kt create mode 100644 design/src/main/java/nota/ui/ios/ShellIosSpecs.kt create mode 100644 design/src/main/java/nota/ui/list/ListRowColors.kt create mode 100644 design/src/main/java/nota/ui/list/ListRowLayout.kt create mode 100644 design/src/main/java/nota/ui/list/ListRowSpec.kt create mode 100644 design/src/main/java/nota/ui/list/ListRowVariant.kt create mode 100644 design/src/main/java/nota/ui/list/ShellListRow.kt create mode 100644 design/src/main/java/nota/ui/settings/PreferenceChevron.kt create mode 100644 design/src/main/java/nota/ui/settings/PreferenceGroupPosition.kt create mode 100644 design/src/main/java/nota/ui/settings/PreferenceItemColors.kt create mode 100644 design/src/main/java/nota/ui/settings/PreferenceItemSpec.kt create mode 100644 design/src/main/java/nota/ui/settings/ShellPreferenceItem.kt create mode 100644 design/src/main/java/nota/ui/switch/ShellSwitch.kt create mode 100644 design/src/main/java/nota/ui/switch/SwitchAnimationSpec.kt create mode 100644 design/src/main/java/nota/ui/switch/SwitchColors.kt create mode 100644 design/src/main/java/nota/ui/switch/SwitchGeometry.kt create mode 100644 design/src/main/java/nota/ui/switch/SwitchHaptic.kt create mode 100644 design/src/main/java/nota/ui/switch/SwitchTrackSpec.kt create mode 100644 design/src/main/java/nota/ui/theme/ShellCorner.kt create mode 100644 design/src/main/java/nota/ui/theme/ShellLineHeight.kt create mode 100644 design/src/main/java/nota/ui/theme/ShellPress.kt create mode 100644 design/src/main/java/nota/ui/theme/ShellSpacing.kt create mode 100644 design/src/main/java/universe/agentic/ui/search/SearchField.kt create mode 100644 design/src/main/java/universe/agentic/ui/search/SearchFieldClearButton.kt create mode 100644 design/src/main/java/universe/agentic/ui/search/SearchFieldDimens.kt create mode 100644 design/src/main/java/universe/agentic/ui/search/SearchFieldLayout.kt create mode 100644 design/src/main/java/universe/agentic/ui/search/SearchFieldVariant.kt delete mode 100644 design/src/main/res/layout/adapter_app.xml delete mode 100644 design/src/main/res/layout/adapter_app_set.xml delete mode 100644 design/src/main/res/layout/adapter_app_set_divider.xml delete mode 100644 design/src/main/res/layout/adapter_app_set_header.xml delete mode 100644 design/src/main/res/layout/adapter_apps_strategy_config.xml delete mode 100644 design/src/main/res/layout/adapter_file.xml delete mode 100644 design/src/main/res/layout/adapter_log_message.xml delete mode 100644 design/src/main/res/layout/adapter_profile.xml delete mode 100644 design/src/main/res/layout/adapter_profile_provider.xml delete mode 100644 design/src/main/res/layout/adapter_provider.xml delete mode 100644 design/src/main/res/layout/adapter_sideload_provider.xml delete mode 100644 design/src/main/res/layout/common_activity_bar.xml delete mode 100644 design/src/main/res/layout/common_recycler_list.xml delete mode 100644 design/src/main/res/layout/component_action_label.xml delete mode 100644 design/src/main/res/layout/component_action_text_field.xml delete mode 100644 design/src/main/res/layout/component_large_action_label.xml delete mode 100644 design/src/main/res/layout/design_access_control.xml delete mode 100644 design/src/main/res/layout/design_app_crashed.xml delete mode 100644 design/src/main/res/layout/design_apps_strategy.xml delete mode 100644 design/src/main/res/layout/design_files.xml delete mode 100644 design/src/main/res/layout/design_logcat.xml delete mode 100644 design/src/main/res/layout/design_logs.xml delete mode 100644 design/src/main/res/layout/design_main.xml delete mode 100644 design/src/main/res/layout/design_new_profile.xml delete mode 100644 design/src/main/res/layout/design_profiles.xml delete mode 100644 design/src/main/res/layout/design_properties.xml delete mode 100644 design/src/main/res/layout/design_providers.xml delete mode 100644 design/src/main/res/layout/design_proxy.xml delete mode 100644 design/src/main/res/layout/design_settings.xml delete mode 100644 design/src/main/res/layout/design_settings_common.xml delete mode 100644 design/src/main/res/layout/design_settings_meta_feature.xml delete mode 100644 design/src/main/res/layout/design_settings_overide.xml delete mode 100644 design/src/main/res/layout/dialog_files_menu.xml delete mode 100644 design/src/main/res/layout/dialog_profiles_menu.xml delete mode 100644 design/src/main/res/layout/dialog_search.xml delete mode 100644 design/src/main/res/menu/menu_access_control.xml delete mode 100644 design/src/main/res/menu/menu_proxy.xml create mode 100644 design/src/test/java/com/github/kr328/clash/design/preference/NullableTextAdapterPortTest.kt create mode 100644 dev/README.md create mode 100644 dev/archive/README.md create mode 100644 dev/archive/docs-product-requirements.md create mode 100644 dev/decisions/001-docs-system.md create mode 100644 dev/decisions/002-compose-ios-home.md create mode 100644 dev/decisions/INDEX.md create mode 100644 dev/demand/INDEX.md create mode 100644 dev/iterations/INDEX.md create mode 100644 dev/parallel/INDEX.md create mode 100644 dev/parallel/archive/compose-remaining-screens.md create mode 100644 dev/parallel/archive/ui-visual-consistency.md create mode 100644 dev/plans/INDEX.md create mode 100644 dev/plans/compose-remaining-screens.md create mode 100644 dev/plans/overview.md create mode 100644 dev/plans/review-2026-10-20-r01-app-plan.md create mode 100644 dev/plans/review-2026-10-20-r01-app.md create mode 100644 dev/plans/review-2026-10-20-r02-service-plan.md create mode 100644 dev/plans/review-2026-10-20-r02-service.md create mode 100644 dev/plans/review-2026-10-20-r03-common-plan.md create mode 100644 dev/plans/review-2026-10-20-r03-common.md create mode 100644 dev/plans/review-2026-10-20-r04-hideapi-plan.md create mode 100644 dev/plans/review-2026-10-20-r04-hideapi.md create mode 100644 dev/plans/review-2026-10-20-r05-build-meta-plan.md create mode 100644 dev/plans/review-2026-10-20-r05-build-meta.md create mode 100644 dev/plans/review-2026-10-20-r06-design-ui-plan.md create mode 100644 dev/plans/review-2026-10-20-r06-design-ui.md create mode 100644 dev/plans/review-2026-10-20-r07-design-shell-plan.md create mode 100644 dev/plans/review-2026-10-20-r07-design-shell.md create mode 100644 dev/plans/review-2026-10-20-r08-core-bridge-plan.md create mode 100644 dev/plans/review-2026-10-20-r08-core-bridge.md create mode 100644 dev/plans/review-2026-10-20-r09-clash-adapter-plan.md create mode 100644 dev/plans/review-2026-10-20-r09-clash-adapter.md create mode 100644 dev/plans/review-2026-10-20-r10-clash-component-plan.md create mode 100644 dev/plans/review-2026-10-20-r10-clash-component.md create mode 100644 dev/plans/review-2026-10-20-r11-clash-listener-plan.md create mode 100644 dev/plans/review-2026-10-20-r11-clash-listener.md create mode 100644 dev/plans/review-2026-10-20-r12-clash-common-plan.md create mode 100644 dev/plans/review-2026-10-20-r12-clash-common.md create mode 100644 dev/plans/review-2026-10-20-r13-transport-heavy-plan.md create mode 100644 dev/plans/review-2026-10-20-r13-transport-heavy.md create mode 100644 dev/plans/review-2026-10-20-r14-transport-proxy-plan.md create mode 100644 dev/plans/review-2026-10-20-r14-transport-proxy.md create mode 100644 dev/plans/review-2026-10-20-r15-transport-misc-plan.md create mode 100644 dev/plans/review-2026-10-20-r15-transport-misc.md create mode 100644 dev/plans/review-2026-10-20-r16-clash-control-plan.md create mode 100644 dev/plans/review-2026-10-20-r16-clash-control.md create mode 100644 dev/plans/review-2026-10-20.md create mode 100644 dev/plans/ui-redesign-proposal.md create mode 100644 dev/plans/ui-timing-fixes.md create mode 100644 dev/plans/ui-visual-consistency.md create mode 100644 dev/progress/INDEX.md create mode 100644 dev/progress/status.md create mode 100644 dev/roadmap/INDEX.md create mode 100644 docs/DOCS-SPEC.md create mode 100644 docs/DOCUMENTATION.md create mode 100644 docs/INDEX.md create mode 100644 docs/README.md create mode 100644 docs/architecture/overview.md create mode 100644 docs/glossary.md create mode 100644 docs/guides/getting-started/INDEX.md create mode 100644 docs/guides/getting-started/build.md create mode 100644 docs/guides/getting-started/testing.md create mode 100644 docs/modules/app/INDEX.md create mode 100644 docs/modules/common/INDEX.md create mode 100644 docs/modules/core/INDEX.md create mode 100644 docs/modules/design/INDEX.md create mode 100644 docs/modules/hideapi/INDEX.md create mode 100644 docs/modules/service/INDEX.md create mode 100644 docs/product/INDEX.md create mode 100644 docs/product/requirements/INDEX.md create mode 100644 docs/product/requirements/PRD-001.md create mode 100644 docs/product/requirements/PRD-002.md create mode 100644 docs/product/requirements/PRD-003.md create mode 100644 docs/product/requirements/PRD-004.md create mode 100644 docs/product/requirements/PRD-005.md create mode 100644 docs/product/requirements/PRD-006.md create mode 100644 docs/product/traceability.md create mode 100644 docs/product/vision.md create mode 100644 docs/systems/access-control/INDEX.md create mode 100644 docs/systems/profiles/INDEX.md create mode 100644 docs/systems/profiles/lifecycle.md create mode 100644 docs/systems/runtime/INDEX.md create mode 100644 docs/systems/runtime/lifecycle.md create mode 100644 docs/systems/ui/INDEX.md create mode 100644 docs/systems/webdav/INDEX.md create mode 100644 docs/templates/architecture-template.md create mode 100644 docs/templates/decision-template.md create mode 100644 docs/templates/demand-template.md create mode 100644 docs/templates/parallel-template.md create mode 100644 docs/templates/plan-template.md create mode 100644 docs/templates/progress-template.md create mode 100644 hideapi/README.md create mode 100644 hideapi/docs/INDEX.md create mode 100755 scripts/archive-status-overflow.sh create mode 100755 scripts/check-docs-health.py create mode 100755 scripts/generate-docs-index.sh create mode 100644 service/README.md create mode 100644 service/docs/INDEX.md create mode 100644 service/docs/documents.md create mode 100644 service/docs/process.md create mode 100644 service/docs/profiles-store.md create mode 100644 service/docs/runtime.md create mode 100644 ui-web-demo/README.md create mode 100644 ui-web-demo/assemble.py create mode 100644 ui-web-demo/generate.py create mode 100644 ui-web-demo/index.html create mode 100644 ui-web-demo/parts_css.py create mode 100644 ui-web-demo/parts_html.py create mode 100644 ui-web-demo/parts_js.py create mode 100755 ui-web-demo/serve.py diff --git a/.gitignore b/.gitignore index 8a8e9b91e0..07a6d3b0b7 100644 --- a/.gitignore +++ b/.gitignore @@ -63,5 +63,9 @@ google-services.json # logs *.log +# Python bytecode +__pycache__/ +*.pyc + # MacOS .DS_Store diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..4ae4b233db --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,56 @@ +# AGENTS.md — 开发向导 + +> 本文件是 ClashMetaForAndroid 的开发向导。`CLAUDE.md` 只指向这里。 + +ClashMetaX 是 [mihomo](https://github.com/MetaCubeX/mihomo) 的 Android 图形界面。本仓库是 TIIEHenry fork,在上游应用上保留应用分流策略、访问控制应用集合和 WebDAV 同步。 + +> **新会话必读顺序:** +> 1. 本文件 +> 2. [docs/DOCUMENTATION.md](docs/DOCUMENTATION.md) — 文档维护规则 +> 3. [dev/progress/status.md](dev/progress/status.md) — 当前快照 + +编码前再看相关的 [docs/systems/](docs/systems/) 或 [docs/modules/](docs/modules/)。构建、签名和内核子模块的约束见 [构建指南](docs/guides/getting-started/build.md)。 + +## 文档导航 + +| 文档 | 说明 | +|:-----|:-----| +| [文档索引](docs/INDEX.md) | 全局入口 | +| [产品需求](docs/product/INDEX.md) | 用户可观察的承诺 | +| [高层设计](DESIGN.md) | 进程与模块边界 | +| [架构概览](docs/architecture/overview.md) | 模块依赖 | +| [文档规范](docs/DOCS-SPEC.md) | 目录与归属 | +| [维护规则](docs/DOCUMENTATION.md) | frontmatter、提交前文档门禁 | +| [术语表](docs/glossary.md) | 本仓库用词 | + +## 模块 + +Gradle 模块由 `settings.gradle.kts` 决定:`app`、`core`、`service`、`design`、`common`、`hideapi`。导航在 [docs/modules/](docs/modules/)。 + +依赖方向:`app` 使用 `core`、`service`、`design`、`common`,并 `compileOnly` `hideapi`。`service` 与 `design` 使用 `core` 和 `common`。`common` `compileOnly` `hideapi`。 + +## 构建 + +```bash +git submodule update --init --recursive +./gradlew :app:assembleMetaRelease +``` + +默认 flavor 是 `alpha`。已安装到手机上的包使用 `meta` flavor,包名 `com.github.metacubex.clash.meta`。JDK 目标是 21。没有 `signing.properties` 时 release 使用 debug 签名。细节与内核 pin 见 [构建指南](docs/guides/getting-started/build.md)。 + +## 开发流程 + +1. 行为或契约变更先写进 `dev/plans/` 或对应 `docs/systems/`,需要取舍时写 ADR。 +2. 编码。 +3. 提交前按 [规则 3a](docs/DOCUMENTATION.md#规则-3a提交前文档门禁) 更新行动层;用户可见行为再更新知识层。 +4. 只 `git add` 本次主题的文件。工作区里其他未提交改动留在原地。 + +单行且无行为变化的修改可以只提交代码。 + +## 约束 + +- 过时文档移入 `dev/archive/`,不删除。 +- `status.md` 保持 200 行以内的当前快照。溢出后注用 `bash scripts/archive-status-overflow.sh` 滚入归档。 +- 结构变更后运行 `python3 scripts/check-docs-health.py`。 +- 不要把设备序列号、签名指纹、密钥口令写进仓库文档。WebDAV 默认常量以 `service` 模块源码为准,文档只链接,不另抄一套。 +- 根 `README.md` 保持上游英文介绍。Fork 的架构与构建说明写在 `docs/`。 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..045bb6fc7e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,5 @@ +# CLAUDE.md + +本文件是指针。开发向导以 [AGENTS.md](AGENTS.md) 为准。 + +文档维护规则见 [docs/DOCUMENTATION.md](docs/DOCUMENTATION.md)。当前快照见 [dev/progress/status.md](dev/progress/status.md)。 diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000000..ba85bf54ab --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,41 @@ +--- +title: "ClashMetaX 高层设计" +type: architecture +status: accepted +phase: N/A +updated: 2026-10-15 +summary: "UI 进程与 :background 进程的边界,以及六个 Gradle 模块的职责" +--- + +# 高层设计 + +本应用把 mihomo 跑在 Android VPN 服务里,并用一套配置界面管理配置文件、代理选择和按应用分流。 + +## 进程 + +界面跑在默认进程。`ClashService`、`TunService`、`RemoteService`、配置处理与状态 Provider 声明在 `:background`。界面通过 `service` 模块的远程接口读写配置和隧道状态,不在 UI 进程里加载 `libclash.so` 的业务循环。 + +`TunService` 持有 `android.net.VpnService`。流量是否进入隧道,由当前配置、访问控制名单,以及可选的应用分流策略决定。 + +## 模块 + +| 模块 | 职责 | +|:-----|:-----| +| app | Activity、外部控制入口、日志界面 | +| design | 界面布局、列表与设置控件 | +| service | `:background` 服务、配置库、WebDAV、访问控制与分流策略的存储 | +| core | JNI 与 Go 桥,链到 mihomo,产出 `libclash.so` | +| common | 跨模块常量、Store、兼容工具 | +| hideapi | 隐藏 API 的编译期存根 | + +模块依赖与阅读入口见 [架构概览](docs/architecture/overview.md)。 + +## 内核 + +mihomo 以 Git 子模块放在 `core/src/foss/golang/clash`。`core/src/main/golang` 用 `replace` 指向它,再由 golang-android 插件编进 native 库。子模块的检出约束见 [构建指南](docs/guides/getting-started/build.md)。 + +## Fork 行为 + +相对上游界面,本 fork 增加三类用户可见行为:命名的应用分流策略、访问控制里的系统/国内应用集合、配置与策略的 WebDAV 同步。契约分别见 [访问控制](docs/systems/access-control/INDEX.md) 与 [WebDAV](docs/systems/webdav/INDEX.md)。 + +产品承诺见 [docs/product/](docs/product/INDEX.md)。这里不写验收步骤。 diff --git a/NOTICE b/NOTICE index 6853d8820c..a98e7206a2 100644 --- a/NOTICE +++ b/NOTICE @@ -1,5 +1,14 @@ 3th-party software licenses + * AgenticUILibrary +========================================================================== +design/src/main/java/nota/ui and design/src/main/java/universe/agentic/ui/search +contain source copied from AgenticUILibrary under the Apache License 2.0. +Adapted for this AndroidX Compose module: Material 3 theme calls were +removed, dark mode follows UiStore through LocalShellIosDark, and the +switch thumb is a round rect because the iOS spec is a circle. +The upstream tree did not ship a NOTICE file. + * Clash ========================================================================== GNU GENERAL PUBLIC LICENSE diff --git a/README.md b/README.md index 03757eca76..a495b949cb 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,10 @@ A Graphical user interface of [Clash.Meta](https://github.com/MetaCubeX/Clash.Meta) for Android +### Documentation + +Architecture, build constraints, and product behavior for this fork are in the documentation system. Start at [docs/INDEX.md](docs/INDEX.md). The agent guide is [AGENTS.md](AGENTS.md). + ### Feature Feature of [Clash.Meta](https://github.com/MetaCubeX/Clash.Meta) diff --git a/app/README.md b/app/README.md new file mode 100644 index 0000000000..d612b2d6c1 --- /dev/null +++ b/app/README.md @@ -0,0 +1,16 @@ +--- +title: "app 模块" +type: overview +status: accepted +phase: N/A +updated: 2026-10-15 +summary: "Android 应用模块:Activity、外部控制和日志" +--- + +# app + +应用进程里的界面入口。屏幕布局在 `design`,隧道和配置存储在 `service`。 + +导航:[docs/modules/app/INDEX.md](../docs/modules/app/INDEX.md) + +模块设计:[app/docs/INDEX.md](docs/INDEX.md) diff --git a/app/docs/INDEX.md b/app/docs/INDEX.md new file mode 100644 index 0000000000..ba63b70f65 --- /dev/null +++ b/app/docs/INDEX.md @@ -0,0 +1,31 @@ +--- +title: "app 模块设计" +type: index +status: accepted +phase: N/A +updated: 2026-10-15 +summary: "应用进程的 Activity、对 :background 的调用,以及日志捕获" +--- + +# app 模块设计 + +`app` 是默认进程里的界面入口。屏幕在 `design`,隧道、配置库和 WebDAV 实现在 `service` 的 `:background` 进程。本目录只写这个模块自己怎么把两边接起来。 + +| 文档 | 写什么 | +|:-----|:-------| +| [Activity](activities.md) | `BaseActivity` 如何把界面交给 `design`,各 Activity 的职责,外部控制与主界面的差别 | +| [远程调用](remote.md) | 应用进程怎样绑定 `:background`,以及状态、文件、偏好和启停各走哪条路 | +| [日志捕获](logcat.md) | `LogcatService` 到 `LogcatReader` 的实际链路,以及崩溃页用的系统 logcat | + +模块导航在 [docs/modules/app/INDEX.md](../../docs/modules/app/INDEX.md)。 + +跨模块行为以这些文档为准,这里不重写: + +| 主题 | 文档 | +|:-----|:-----| +| 界面分层 | [界面](../../docs/systems/ui/INDEX.md) | +| 隧道与外部控制动作 | [运行时](../../docs/systems/runtime/INDEX.md) | +| 配置 | [配置](../../docs/systems/profiles/INDEX.md) | +| 访问控制与分流策略 | [访问控制](../../docs/systems/access-control/INDEX.md) | +| WebDAV | [WebDAV](../../docs/systems/webdav/INDEX.md) | +| 用户可观察的承诺 | [产品需求](../../docs/product/requirements/INDEX.md) | diff --git a/app/docs/activities.md b/app/docs/activities.md new file mode 100644 index 0000000000..8908ec2b8e --- /dev/null +++ b/app/docs/activities.md @@ -0,0 +1,138 @@ +--- +title: "app 的 Activity" +type: architecture +status: accepted +phase: N/A +updated: 2026-10-19 +summary: "BaseActivity 把 Design 设为内容视图。从策略进入的名单标题是策略名称" +--- + +# app 的 Activity + +Activity 在应用进程。布局、列表和设置控件在 `design` 的 `*Design`。对 `:background` 的调用方式见 [远程调用](remote.md)。分层说明见 [界面](../../docs/systems/ui/INDEX.md)。 + +## BaseActivity + +`BaseActivity>` 继承 `AppCompatActivity`,协程作用域是 `MainScope()`,并实现 `Broadcasts.Observer`。 + +`onCreate` 先按 `UiStore.darkMode` 套用 `AppThemeLight` 或 `AppThemeDark`,并把状态栏、导航栏设成当前主题的颜色。接着把 `uiStore.hideFromRecents` 写到本应用每一个 `appTask`。然后 `launch { main() }`。子类实现挂起函数 `main()`。 + +把界面交给 `design` 的顺序: + +1. 子类构造对应的 `*Design`。`Design` 持有 `root` 视图,以及一个无限容量的 `requests` 通道。 +2. `setContentDesign` 在 `window.decorView` 上投递一帧之后才赋值。 +3. `design` 非空时 `setContentView(design.root)`;置空时放进一个空 `View`。 +4. 子类在 `select` 里收取 `design.requests`,自己去调远程接口或打开下一个 Activity,再调用 Design 上的方法把状态写回去。 +5. `onDestroy` 调用 `design.cancel()`,并取消 Activity 协程。 + +另一路输入是 `events`。`onStart` 把 Activity 登记为 `Remote.broadcasts` 的观察者,并发出 `ActivityStart`。`onStop` 移除观察者,并发出 `ActivityStop`。服务重建、隧道起停、配置加载、配置变更、配置更新成功或失败,都转成 `Event`。隧道停止且带了原因、同时 Activity 处于 started 时,用这个原因构造 `ClashException`,在当前 Design 上弹 toast。 + +`clashRunning` 读的是 `Remote.broadcasts.clashRunning`,不是当场查询状态 Provider。 + +`finish()` 先跑一次 `defer` 挂起块,再在不可取消的上下文里调用 `super.finish()`。重复进入 `finish()` 时,如果 `defer` 还在跑,直接返回。日夜模式与当前配置不一致时,`ApplicationObserver.createdActivities` 里已创建的 Activity 全部 `recreate()`。`onSupportNavigateUp` 转到 `onBackPressed()`。 + +`startActivityForResult` 用 `util/Activity.kt` 的 `ActivityResultLifecycle` 登记一次性合约,把结果恢复成挂起函数的返回值。 + +## 主界面 + +`MainActivity` 使用 `MainDesign`。`launchMode` 是 `singleTask`。清单里它响应 `MAIN`、`APPLICATION_PREFERENCES` 和 `QS_TILE_PREFERENCES`,但启动器图标挂在 `MainActivityAlias` 上,别名再指向它。 + +事件 `ActivityStart`、服务重建、隧道起停、配置加载或变更时调用 `fetch()`:写入 `clashRunning`、会话覆盖里的模式(没有则用隧道状态里的模式)、是否存在 provider、当前节点、当前配置名,以及 `ServiceStore` 里生效分流策略的名字。隧道在跑时,`fetch()` 会立刻取一次流量,之后每秒再取。流量来自 `queryTrafficNow()` 和 `queryTrafficTotal()`。 + +当前节点只在隧道运行时查询。模式是 `Direct` 则不查。否则 `queryProxyGroupNames(true)`:`Global` 取名称忽略大小写等于 `GLOBAL` 的组,没有则取第一个组;其他模式取第一个名称不是 `GLOBAL` 的组,没有也退回第一个组。再以 `ProxySort.Default` 读该组的 `now`,空白则不显示。 + +`MainDesign.Request` 的去向: + +| 请求 | 行为 | +|:-----|:-----| +| `ToggleStatus` | 在跑则 `stopClashService()`,否则走下面的启动 | +| `OpenProxy` | 打开 `ProxyActivity`。组名非空时写入 `EXTRA_PROXY_GROUP`。这不是导出的 `clash://` 契约 | +| `OpenProfiles` / `OpenProviders` / `OpenAppsStrategy` / `OpenSettings` / `OpenHelp` | 打开对应 Activity | +| `OpenLogs` | `LogcatService.running` 为真时打开 `LogcatActivity`,否则打开 `LogsActivity` | +| `OpenAbout` | 显示包的 `versionName`,以及 `Bridge.nativeCoreVersion()` | +| `SetMode` | 先提示模式切换,再把 `Clash.OverrideSlot.Session` 的 `mode` 写回去并 `fetch()` | + +启动前先 `withProfile { queryActive() }`。没有当前配置,或当前配置尚未导入时,toast 提示并给出打开 `ProfilesActivity` 的动作,不启动服务。否则调用 `startClashService()`。返回的 VPN 授权 Intent 非空时,用 `StartActivityForResult` 拉起系统授权;`RESULT_OK` 后再调一次 `startClashService()`。这一步抛异常则 toast 无法启动 VPN。 + +图标未隐藏时,`onCreate` 注册三个动态快捷方式:切换、启动、停止。Intent 的类名是 `ExternalControlActivity`,动作是 `Intents` 里的对应常量,并带 `NEW_TASK`、`EXCLUDE_FROM_RECENTS`、`NO_ANIMATION`。Android 13 及以上若还没有通知权限,会请求 `POST_NOTIFICATIONS`;授权结果的回调是空的。 + +## 外部控制与主界面 + +`ExternalControlActivity` 不继承 `BaseActivity`,不创建 Design。清单把它标成导出、透明、`noHistory`、`excludeFromRecents`。它是给其他应用、链接和快捷方式用的入口,不是主界面上的一屏。用户能观察到的承诺见 [PRD-006](../../docs/product/requirements/PRD-006.md)。动作字符串与 meta 包名的对应关系见 [运行时](../../docs/systems/runtime/INDEX.md)。 + +`onCreate` 去掉过渡动画。除了 `ACTION_VIEW` 会先返回、在协程里收尾,其余分支处理完就 `finish()`。 + +`ACTION_VIEW` 要求链接上有 `url`。`type` 为 `file` 时创建 `Profile.Type.File`,为 `url` 或其他值时创建 `Profile.Type.Url`。名称缺省是 `R.string.new_profile`。`update-interval` 按分钟解析:大于 0 时至少 15 分钟,否则为 0,再换成毫秒。`withProfile` 里 `create` 再 `patch`,然后打开带这个 UUID 的 `PropertiesActivity` 并 `finish()`。导入之后的配置行为见 [配置](../../docs/systems/profiles/INDEX.md)。 + +启停不打开主界面,也不走 `MainActivity.startClash()`: + +| 动作 | 行为 | +|:-----|:-----| +| `ACTION_TOGGLE_CLASH` | `StatusClient.currentProfile()` 非空则停止,否则启动 | +| `ACTION_START_CLASH` | 已在跑只 toast;否则启动 | +| `ACTION_STOP_CLASH` | 停止并 toast | + +这里的「在跑」是状态 Provider 返回了当前配置名,不是 `Remote.broadcasts.clashRunning`。启动前先 `withProfile { queryActive() }`。没有当前配置,或当前配置尚未导入时,toast `R.string.no_profile_selected`,不调用 `startClashService()`,也不 toast 已启动。`onCreate` 在查询返回前就 `finish()`,所以这次查询和 toast 用 `applicationContext`,协程不在 `onDestroy` 里取消。已有导入配置时调用 `startClashService()`。若返回 VPN 授权 Intent,只 toast 无法启动 VPN,不拉起授权界面。主界面的开关会拉起授权。两边最后都落到 `util/Clash.kt` 的 `startClashService` / `stopClashService`。 + +`TileService` 是另一条控制路径,也不经过 `ExternalControlActivity` 或 `MainDesign`。点击时磁贴为 inactive 就 `startClashService()`,为 active 就 `stopClashService()`,返回的授权 Intent 被忽略。开始监听时用 `StatusClient` 取当前配置名,并注册隧道起停、配置加载和服务重建广播;磁贴标签是配置名,空则用启动名称。 + +`RestartReceiver` 在清单里导出,但 `android:enabled` 默认是 `false`。`AppSettingsActivity.autoRestart` 用 `PackageManager.setComponentEnabledSetting` 开关这个组件。收到 `BOOT_COMPLETED` 或 `MY_PACKAGE_REPLACED` 时,若 `StatusProvider.shouldStartClashOnBoot` 为真,调用 `startClashService()`。 + +`DialerReceiver` 导出,匹配 `android_secret_code` 主机 `252746382`,收到后以 `NEW_TASK` 打开 `MainActivity`。 + +## 配置、代理与文件 + +`ProfilesActivity` 用 `ProfilesDesign`。进入前台或配置变更时 `withProfile { queryAll() }` 刷新列表。请求包括新建、更新单个、更新全部已导入且类型不是 `File` 的配置、删除、编辑、复制和设为当前。未导入的配置不能直接设为当前,改为让 Design 要求保存。更新完成或失败时按 UUID 查出名称再 toast;失败 toast 可以打开对应的 `PropertiesActivity`。前台时每分钟让 Design 更新经过时间。 + +`NewProfileActivity` 先列出文件、URL、二维码,再加上能响应 `Intents.ACTION_PROVIDE_URL` 的外部 Activity。文件和 URL 用 `withProfile { create }` 后打开 `PropertiesActivity`,对方 `RESULT_OK` 时本页结束。二维码走 `ScanQRCode`,成功则按 URL 类型创建并把扫到的字符串当作 source。外部提供者用 `startActivityForResult` 取回 `data` URI 和可选名称,类型为 `Profile.Type.External`。 + +`PropertiesActivity` 从 Intent 取 UUID,`queryByUUID` 失败就结束。离开时 `defer` 调用 `release`。`ActivityStop` 且用户没有取消、字段相对进入时有变化,则 `patch` 名称、来源、间隔和 `ageSecretKey`。提交时再校验名称和非文件类型的来源,然后 `patch` 加 `commit`。浏览文件打开带同一 UUID 的 `FilesActivity`。服务重建则结束本页。返回键在有未保存修改时先问是否放弃。 + +`FilesActivity` 用 `FilesClient` 列 `:background` 里文件 Provider 的文档。根文档 id 是配置 UUID。`configurationEditable` 是 `profile.type != Url`,只决定根目录上已有文件的导入。新建只在子目录出现,根上不发 `ImportFile(null)`。从子目录返回且栈变空时,立刻把界面标成根目录,新建随之消失,然后才 `fetch`。栈空时收到 `ImportFile(null)` 直接返回,不调用 `stack.last()`。栈顶目录用 `list`;根目录若存在空的 `config.yaml`,列表只保留这一项。请求包括进入目录、查看、删除、重命名、导入和导出。返回键发送 `PopStack`,栈空才结束。 + +`ProxyActivity` 读取代理组名(是否排除不可选由 `uiStore.proxyExcludeNotSelectable` 决定)。组名列表为空时不发送 `Reload`。否则初始下标依次看旋转保存的组、`EXTRA_PROXY_GROUP`、`uiStore.proxyLastGroup`,再退回 0。只有用户在组列表里点选才写 `proxyLastGroup`,并改写 Intent 里的 extra。重新打开时带上当时显示的组名,这一步不写 `proxyLastGroup`。每个组有重载序号和点选序号,都放在 Activity 里。收到该组的 `Reload` 时重载序号加一;查询返回时序号已变,就丢掉整段结果,不调用 `updateGroup`,也不改 `now`。点选序号只在 `Select` 时加一。查询返回时重载序号仍有效、但点选序号已变,则列表照常画上,`now` 保留这次点选。`Select` 仍调用 `patchSelector`,把本地 `now` 设成点中的名字,再 `requestRedrawVisible`。重载用许可数为 10 的信号量调用 `queryProxyGroup`。延迟测试调用 `healthCheck` 后直接开始该组的下一次查询,不经过通道再发一条 `Reload`。只有这次查询真正画上才能清掉测速进度。被丢掉的旧结果不清进度,也不消费这张条子。打开、排序和切组的重载保持进度。配置加载后组名变了,就重新打开本 Activity 并结束当前实例。界面不再发送 `PatchMode` 或 `ReloadAll`。 + +`ProvidersActivity` 用 `queryProviders()` 的排序结果建 `ProvidersDesign`。更新请求调用 `updateProvider`。配置加载后列表变了,同样重新打开自身。前台时每分钟更新经过时间。 + +## 设置 + +`SettingsActivity` 只做转发:应用、网络、覆盖、Meta 功能、WebDAV。 + +`AppSettingsActivity` 把 `UiStore`、`ServiceStore`、当前是否在跑,以及隐藏图标的回调交给 `AppSettingsDesign`。隧道起停或服务重建时 `recreate()`。Design 发出请求时,已创建的 Activity 全部 `recreate()`。隐藏图标切换 `MainActivityAlias` 的组件启用状态。开机重启即上一节的 `RestartReceiver`。 + +`NetworkSettingsActivity` 打开不带 UUID 的 `AccessControlActivity`(全局名单)。访问控制项变化时记下自动同步,并在满足条件时 `withProfile { webdavPerform(ACTION_SYNC, ...) }`。条件与名单语义见 [访问控制](../../docs/systems/access-control/INDEX.md) 和 [WebDAV](../../docs/systems/webdav/INDEX.md)。 + +`OverrideSettingsActivity` 读出 `OverrideSlot.Persist`,`defer` 在离开时 `patchOverride` 写回。确认重置后,把 `defer` 换成 `clearOverride` 再结束。 + +`MetaFeatureSettingsActivity` 同样读写 `OverrideSlot.Persist`。另外可以用 `GetContent` 导入地理文件。扩展名必须是 `.metadb`、`.db`、`.dat` 或 `.mmdb`,否则对话框拒绝。接受的文件按类型写成 `clashDir` 下的 `geoip`、`geosite`、`country` 或 `ASN` 再加原扩展名。`clashDir` 是 `filesDir/clash`。 + +`WebdavSettingsActivity` 用 `WebdavSettingsStore` 和 `WebdavLocalState.lastMessage()` 建界面。测试、同步、拉取、推送分别对应 `WebdavSync` 的四个动作,经 `withProfile { webdavPerform }` 执行。进行中再次点击会被 `busy` 丢掉。同步或测试进行中拨开自动同步时,开关和 `store.autoSync` 都保持关。不忙时从关到开仍写上并发送 `Sync`,不确认。端点、合并和默认常量见 [WebDAV](../../docs/systems/webdav/INDEX.md),本文不抄那些字面量。 + +`AppsStrategyActivity` 直接读写 `ServiceStore` 的策略列表和生效 UUID:新建、改名、改模式、删除、设为生效。点选一份策略会打开带该 UUID 的 `AccessControlActivity`。变更后按 [WebDAV](../../docs/systems/webdav/INDEX.md) 的自动同步条件调用 `webdavPerform(ACTION_SYNC)`。保存位置和谁在驱动隧道见 [访问控制](../../docs/systems/access-control/INDEX.md)。 + +`AccessControlActivity` 的返回键直接 `finish()`。`defer` 把当前选中包名写入策略或全局名单,再按该文档所述决定是否在返回之后重载隧道和同步。从策略进入且名称非空时,窗口标题设成该策略的名称。列表加载调用 `loadAccessControlApps`,国内包名来自 `DomesticAppCatalog`。点选规则不在这里重复。 + +## 日志、帮助与异常页 + +`LogsActivity` 和 `LogcatActivity` 的捕获、读文件和导出见 [日志捕获](logcat.md)。 + +`HelpActivity` 把 Design 给出的 URI 用 `ACTION_VIEW` 打开。它只消费 `events`,不处理 Design 请求。 + +`ApkBrokenActivity` 由 `Remote.verifyApp` 在安装包里找不到匹配 ABI 的 `libclash.so` 时拉起。Design 请求里的 URL 用 `ACTION_VIEW` 打开。 + +`AppCrashedActivity` 是 `singleTask`。`Remote` 在 `RemoteService` 短时间连续断开或绑定失败时,先结束已创建的 Activity,再以 `NEW_TASK` 打开它。页面用 `SystemLogcat.dumpCrash()` 填日志。这是系统 `logcat` 命令,不是 `LogcatService` 写的文件。 + +## 清单里的导出入口 + +不逐条列权限。导出的组件是: + +| 组件 | 导出时做什么 | +|:-----|:-------------| +| `MainActivity` | 应用内主界面。另有 `MAIN`,但没有启动器 category | +| `MainActivityAlias` | `LAUNCHER` 与 `LEANBACK_LAUNCHER`,目标是 `MainActivity` | +| `ExternalControlActivity` | `VIEW`(`clash` / `clashmeta` 的 `install-config`)以及清单里写死的启动、停止、切换动作 | +| `TileService` | 快捷设置磁贴,要求 `BIND_QUICK_SETTINGS_TILE` | +| `RestartReceiver` | 开机与覆盖安装;默认禁用 | +| `DialerReceiver` | 秘密码,打开主界面 | + +其余 Activity 和 `LogcatService` 的 `exported` 都是 `false`。`LogcatService` 的前台类型是 `specialUse`。 diff --git a/app/docs/logcat.md b/app/docs/logcat.md new file mode 100644 index 0000000000..efccfe2b2b --- /dev/null +++ b/app/docs/logcat.md @@ -0,0 +1,69 @@ +--- +title: "日志捕获" +type: architecture +status: accepted +phase: N/A +updated: 2026-10-15 +summary: "LogcatService 在应用进程订阅 :background 的日志并写文件;LogcatReader 再读回来" +--- + +# 日志捕获 + +Clash 运行日志和崩溃页上的系统 logcat 是两条链路。前者由 `LogcatService` 订阅 `:background` 里的 `IClashManager.setLogObserver`,写成 `cacheDir/logs` 下的文件。后者是 `AppCrashedActivity` 执行一次 `logcat -d`。 + +## 谁打开哪一屏 + +`MainActivity` 收到 `OpenLogs` 时:`LogcatService.running` 为真就打开 `LogcatActivity`,否则打开 `LogsActivity`。 + +`LogsActivity` 在 `ActivityStart` 时列出 `cacheDir/logs` 里能被 `LogFile.parseFromFileName` 认出的文件,格式是 `clash-<毫秒>.log`。删除全部会 `logsDir.deleteRecursively()`,`logsDir` 就是这个目录。打开某个文件时给 `LogcatActivity` 带上文件名。开始捕获则打开不带文件名的 `LogcatActivity` 并结束自己。 + +`LogcatActivity.main` 看 Intent 里有没有文件名。有则读本地文件;没有则走实时捕获。文件名解析失败,或读取抛异常,toast 无效日志文件,并且不再 `setContentDesign`。 + +## 实时捕获 + +`mainStreaming` 用 `LogcatDesign(streaming = true)`,然后 `startForegroundService(LogcatService)`,再 `bindService`。绑定返回的 Binder 在 `queryLocalInterface` 里忽略 descriptor,直接交回 `LogcatService` 实例,所以这个绑定只在同一进程里有用。`LogcatService` 没有单独的 `android:process`,和 Activity 同在默认进程。 + +Activity 处于 started 时,每 500 毫秒调用 `snapshot`。第一次要全量;之后 `snapshot` 在没有增删时返回 null,界面不刷新。`Close` 时 `stopService`,再打开 `LogsActivity` 并结束自己。`onDestroy` 解除这次绑定。 + +`LogcatService.onCreate` 把 `running` 设为 true,创建通知渠道 `clash_logcat_channel`,以前台服务类型 `specialUse` 显示通知,通知点击打开 `LogcatActivity`。然后它自己 `bindService(RemoteService)`,`BIND_AUTO_CREATE`。这是对 `:background` 的另一次绑定,不经过 `Remote.service`,因此界面全部不可见、`Remote` 解除自己的绑定时,正在跑的日志服务仍可以连着 `RemoteService`。 + +`onServiceConnected` 后: + +1. Binder 已死则 `stopSelf`。 +2. `unwrap` 成 `IRemoteService`,取 `clash()`。 +3. `logsDir.mkdirs()`,用 `LogcatWriter` 打开一个新文件。`LogFile.generate()` 的文件名是当前时间毫秒。 +4. `setLogObserver`。观察者的 `newItem` 对容量 128 的通道 `trySend`。通道满时 `trySend` 不等待。 +5. 循环 `receive`。每一条先 `LogcatWriter.appendMessage`,再 `LogcatCache.append`。 + +`:background` 侧 `ClashManager.setLogObserver` 在观察者非空时调用 `Clash.subscribeLogcat()`,再把收到的 `LogMessage` 交给 `observer.newItem`。`subscribeLogcat` 使用容量 32 的通道,原生回调里是 `trySend`。方向是:应用进程注册观察者,`:background` 再把条目回调进来。观察者换成 null,或注册一个新观察者时,`ClashManager` 取消上一段订阅并 `Clash.forceGc()`。 + +写文件或读取循环失败时,若 Binder 仍活着就 `setLogObserver(null)`,然后 `stopSelf`。`RemoteService` 断开也会 `stopSelf`。`onDestroy` 取消服务协程、解除到 `RemoteService` 的绑定、停止前台通知,并把 `running` 设为 false。 + +`LogcatCache` 是容量 128 的环形缓冲。放满时丢掉最旧的一条,并累计 `removed`。`snapshot(full)` 在非全量且 `removed` 和 `appended` 都是 0 时返回 null;否则交出当前列表和这两个计数,然后把计数清零。全量时,返回的 `appended` 是当前条数再加上未清零的 `appended`。 + +## 文件格式与 LogcatReader + +`LogcatWriter` 每一行是: + +```text +:: +``` + +`time.time` 是毫秒。级别名来自 `LogMessage.Level.name`。 + +`LogcatReader` 只读这种文件。它跳过空白修剪后以 `#` 开头的行,再按 `:` 分成至多三段: + +- 第一段能解析成 Long:时间用这个毫秒,级别用 `Level.valueOf`,消息是第三段。 +- 第一段不是 Long:时间沿用上一条,级别用 `Warning`,消息是整行重新用 `:` 拼回去。 + +`LogcatActivity` 读完后用 `LogcatDesign(streaming = false)`,一次 `patchMessages` 整表。删除会删掉该文件并 `finish()`。导出用 `CreateDocument("text/plain")`,由 `LogcatFilter` 写成另一种文本:第一行是 `# Capture on ...`,之后每行是对齐过的时间、级别名和消息。导出文件不是 `LogcatWriter` 的格式;`LogcatReader` 不会被用来读这个导出结果。 + +## 系统 logcat + +`SystemLogcat.dumpCrash` 执行: + +```text +logcat -d -s Go DEBUG AndroidRuntime ClashMetaForAndroid LwIP +``` + +它读完标准输出,丢掉以 `------` 开头的行。失败时返回空字符串。只有 `AppCrashedActivity` 调用它,用来填充崩溃页,不写 `logsDir`,也不经过 `LogcatService`。 diff --git a/app/docs/remote.md b/app/docs/remote.md new file mode 100644 index 0000000000..07342e1510 --- /dev/null +++ b/app/docs/remote.md @@ -0,0 +1,97 @@ +--- +title: "app 怎样调用 :background" +type: architecture +status: accepted +phase: N/A +updated: 2026-10-15 +summary: "界面进程绑定 RemoteService;启停、状态、文件和偏好各自另有一条路" +--- + +# app 怎样调用 :background + +`:background` 里有哪些服务见 [运行时](../../docs/systems/runtime/INDEX.md) 和 [架构概览](../../docs/architecture/overview.md)。本文只写 `app` 源码里的调用方向。 + +`RemoteService`、`ClashService`、`TunService`、`FilesProvider`、`StatusProvider`、`PreferenceProvider` 的 `android:process` 都是 `:background`。`app` 清单没有给 Activity、`LogcatService`、`TileService` 指定进程,它们在默认应用进程。 + +## 进程入口 + +`MainApplication.attachBaseContext` 调用 `Global.init`。`onCreate` 在区分进程之前先把地理文件解到 `filesDir/clash`:`geoip.metadb`、`geosite.dat`、`ASN.mmdb`、`BundleMRS.7z`。文件不存在,或修改时间早于本包 `lastUpdateTime` 时,从 assets 重新拷贝。 + +随后: + +- `currentProcessName == packageName`(界面进程)时调用 `Remote.launch()`。 +- 其他进程(包括 `:background`)调用 `sendServiceRecreated()`,不绑定远程服务。`Broadcasts` 收到该广播后把 `clashRunning` 置为 false,并通知观察者。接收器还没登记时,这次广播不会被 `Broadcasts` 处理。 + +## 可见时才绑定 RemoteService + +`Remote.launch()` 只做两件事:挂上 `ApplicationObserver`,并在 IO 线程跑 `verifyApp`。 + +`ApplicationObserver` 在任一 Activity `onStart` 后视为可见,全部 Activity 停止或销毁后视为不可见。可见时 `service.bind()` 且 `broadcasts.register()`;不可见时 `service.unbind()` 且 `broadcasts.unregister()`。因此界面进程持有的 `IRemoteService` 连接跟「有没有 Activity 处于 started」走,不跟隧道是否在跑走。`LogcatService` 自己另绑一次,不受这次 unbind 影响,见 [日志捕获](logcat.md)。 + +`remote/Service.kt` 的 `bind()` 对 `RemoteService` 使用 `BIND_AUTO_CREATE`。`onServiceConnected` 把 `IBinder` `unwrap` 成 `IRemoteService`,放进 `Resource`。`onServiceDisconnected` 把 `Resource` 置空。若距离上次断开不到 10 秒,则 unbind,并执行 `Remote` 传入的崩溃回调:结束已创建的 Activity,再启动 `AppCrashedActivity`。`bindService` 抛异常时同样走这个回调。 + +`Resource.get()` 在值为空时挂起,`set` 非空后唤醒等待者。`reset` 只在当前值与传入实例是同一引用时清空。 + +`verifyApp` 比较 `AppStore.updatedAt` 和包的 `lastUpdateTime`。不一致时用 `verifyApk()` 在 apk 与 split 里找 `lib//libclash.so`,ABI 必须和 `Build.SUPPORTED_ABIS` 有交集。失败则结束已创建 Activity 并打开 `ApkBrokenActivity`。成功则把 `updatedAt` 写成这次的 `lastUpdateTime`。 + +## Binder:IRemoteService + +方向是界面进程调用 `:background`,不是反过来。 + +``` +界面进程 + bindService(RemoteService) + → IRemoteService.clash() → IClashManager(ClashManager) + → IRemoteService.profile() → IProfileManager(ProfileManager) +``` + +`util/Remote.kt` 是这条 Binder 的唯一包装: + +- `withClash` 取 `Remote.service.remote`,再 `clash()`,在 `Dispatchers.IO` 上执行块。 +- `withProfile` 同样取连接,再 `profile()`。 +- 只捕获 `DeadObjectException`:打日志、`reset` 这次拿到的实例,然后无限重试。其他异常原样抛出。 + +`IClashManager` 上界面会用到的方法:`queryTunnelState`、`queryTrafficNow`、`queryTrafficTotal`、`queryProxyGroupNames`、`queryProxyGroup`、`queryProviders`、`patchSelector`、`healthCheck`、`updateProvider`、`queryOverride`、`patchOverride`、`clearOverride`、`setLogObserver`。`queryConfiguration` 在接口上,当前 `app` 源码没有调用。 + +`IProfileManager` 上的方法:`create`、`clone`、`commit`、`release`、`delete`、`patch`、`update`、`queryByUUID`、`queryAll`、`queryActive`、`setActive`、`webdavPerform`。配置和同步的语义见 [配置](../../docs/systems/profiles/INDEX.md) 与 [WebDAV](../../docs/systems/webdav/INDEX.md)。 + +调用方:主界面、代理、提供者、覆盖设置、Meta 功能设置走 `withClash`。主界面、外部导入、配置列表、配置属性、新建配置、网络设置、分流策略、WebDAV 设置、访问控制离开后的同步走 `withProfile`。`LogcatService` 不走 `withClash`,它自己 `unwrap` 之后调用 `clash().setLogObserver`。 + +## 不经过 IRemoteService 的三条路 + +这些仍然从默认进程进 `:background`,但不是 `RemoteService` 的 Binder。 + +**状态。** `StatusClient` 对 `Authorities.STATUS_PROVIDER`(`:background` 的 `StatusProvider`)做 `contentResolver.call`,方法是 `currentProfile`。返回 Bundle 里的 `name`;服务没在跑或调用失败时得到 null。`ExternalControlActivity`、`TileService` 和 `Broadcasts.register` 用「名字非空」表示隧道在跑。 + +**文件。** `FilesClient` 用 `DocumentsContract` 访问 `Authorities.FILES_PROVIDER`(`:background` 的 `FilesProvider`):列子文档、重命名、删除、以及经 `copyContentTo` 导入或导出。只有 `FilesActivity` 使用它。 + +**偏好。** `ServiceStore` 在 `BaseService` / `TunService` 上直接打开名为 `service` 的 `SharedPreferences`;其他 Context(界面进程)走 `MultiProcessPreference`,authority 是 `Authorities.SETTINGS_PROVIDER`,也就是 `:background` 的 `PreferenceProvider`。策略 JSON 写在调用方 `filesDir` 下,细节见 [访问控制](../../docs/systems/access-control/INDEX.md)。 + +`AppStore` 和 `TipsStore` 不走这个 Provider。它们用本进程 `MODE_PRIVATE` 的 `SharedPreferences`,文件名分别是 `app` 和 `tips`。`AppStore` 只保存上面的 `updated_at`。`TipsStore` 有 `request_donate` 和 `primary_version`,当前仓库没有其他引用。 + +## 启停与重载 + +`util/Clash.kt`: + +- `startClashService`:`UiStore.enableVpn` 为真时先 `VpnService.prepare`。需要授权则把系统 Intent 返回给调用方,不启动服务。已授权则前台启动 `TunService`。未开 VPN 则前台启动 `ClashService`。两者都在 `:background`。 +- `stopClashService`:`sendBroadcastSelf(ACTION_CLASH_REQUEST_STOP)`。广播 `setPackage` 为本包,并要求 `RECEIVE_SELF_BROADCASTS`。 +- `requestClashReload`:同样发送 `ACTION_CLASH_REQUEST_RELOAD`。`AccessControlActivity` 在离开页面之后使用它。 + +谁处理这两条请求在 `service`,不在 `app`。 + +## 广播::background 通知界面进程 + +`sendBroadcastSelf` 把包名设成本包。`Broadcasts` 的接收器再核对 `intent.package` 是否为本包,然后按动作分发: + +| 动作 | 界面侧 | +|:-----|:-------| +| `ACTION_SERVICE_RECREATED` | `clashRunning = false`,`onServiceRecreated` | +| `ACTION_CLASH_STARTED` | `clashRunning = true`,`onStarted` | +| `ACTION_CLASH_STOPPED` | `clashRunning = false`,`onStopped`,额外字段是停止原因 | +| `ACTION_PROFILE_CHANGED` | `onProfileChanged` | +| `ACTION_PROFILE_UPDATE_COMPLETED` / `FAILED` | 从额外字段解析 UUID;失败还有原因字符串 | +| `ACTION_PROFILE_LOADED` | `onProfileLoaded` | + +`register()` 成功之后用 `StatusClient.currentProfile() != null` 填一次 `clashRunning`。 + +`Broadcasts.registered` 初始是 false,`register` 和 `unregister` 都没有把它写成 true。因此 `register()` 不会因为「已经注册」而返回;`unregister()` 一进来就返回,不会 `unregisterReceiver`,也不会把 `clashRunning` 置回 false。可见性回调仍然会调用这两个函数。`TileService` 自己在监听期间注册和注销,并带上 `RECEIVE_SELF_BROADCASTS`;`Broadcasts` 注册时没有传这个权限参数。 diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7e32ed8708..5293129a49 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -101,6 +101,11 @@ + + android:label="@string/apps_strategy" /> () { } } - val design = AccessControlDesign(this, uiStore, selected) + if (config != null && config.name.isNotBlank()) { + title = config.name + } + + val design = AccessControlDesign(this, uiStore, selected) { members -> + applyAppSetToggle(members, selected) + } setContentDesign(design) @@ -110,18 +120,44 @@ class AccessControlActivity : BaseActivity() { } AccessControlDesign.Request.Import -> { - val clipboard = getSystemService() - val data = clipboard?.primaryClip - - if (data != null && data.itemCount > 0) { - val packages = data.getItemAt(0).text.split("\n").toSet() - val all = design.apps.map(AppInfo::packageName).intersect(packages) - - selected.clear() - selected.addAll(all) + val confirmed = iosConfirm( + context = this@AccessControlActivity, + title = getString(com.github.kr328.clash.design.R.string.import_), + message = getString(com.github.kr328.clash.design.R.string.import_from_clipboard), + confirm = getString(com.github.kr328.clash.design.R.string.ok), + destructive = true, + ) + if (confirmed) { + var failed = false + try { + val text = getSystemService() + ?.primaryClip + ?.takeIf { it.itemCount > 0 } + ?.getItemAt(0) + ?.text + if (text == null) { + failed = true + } else { + val packages = text.split("\n") + .map { it.replace("\r", "").trim() } + .filter { it.isNotEmpty() } + .toSet() + val matched = design.apps.map(AppInfo::packageName).intersect(packages) + if (matched.isNotEmpty()) { + selected.clear() + selected.addAll(matched) + } + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + failed = true + } + if (failed) { + design.showToast(DesignR.string.geofile_import_failed, ToastDuration.Long) + } + design.rebindAll() } - - design.rebindAll() } AccessControlDesign.Request.Export -> { diff --git a/app/src/main/java/com/github/kr328/clash/AppsStrategyActivity.kt b/app/src/main/java/com/github/kr328/clash/AppsStrategyActivity.kt index dc6ffd46e1..c9f62a05bf 100644 --- a/app/src/main/java/com/github/kr328/clash/AppsStrategyActivity.kt +++ b/app/src/main/java/com/github/kr328/clash/AppsStrategyActivity.kt @@ -3,7 +3,9 @@ package com.github.kr328.clash import com.github.kr328.clash.common.util.intent import com.github.kr328.clash.common.util.setUUID import com.github.kr328.clash.design.AppsStrategyDesign +import com.github.kr328.clash.design.util.showExceptionToast import com.github.kr328.clash.design.dialog.requestModelTextInput +import com.github.kr328.clash.design.ios.iosConfirm import com.github.kr328.clash.design.requestAppListsConfigEdit import com.github.kr328.clash.service.model.AccessControlMode import com.github.kr328.clash.service.model.AppsStrategyConfig @@ -12,6 +14,7 @@ import com.github.kr328.clash.service.webdav.WebdavAutoSync import com.github.kr328.clash.service.webdav.WebdavSettingsStore import com.github.kr328.clash.service.webdav.WebdavSync import com.github.kr328.clash.util.withProfile +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.isActive import kotlinx.coroutines.selects.select @@ -53,8 +56,19 @@ class AppsStrategyActivity : BaseActivity() { ) if (result != null) { if (result.deleted) { - deleteConfig(it.config.uuid) - design.fetch() + val confirmed = iosConfirm( + context = this@AppsStrategyActivity, + title = getString(com.github.kr328.clash.design.R.string.delete), + message = it.config.name.ifBlank { + getString(com.github.kr328.clash.design.R.string.delete) + }, + confirm = getString(com.github.kr328.clash.design.R.string.delete), + destructive = true, + ) + if (confirmed) { + deleteConfig(it.config.uuid) + design.fetch() + } } else if (result.name != it.config.name || result.mode != it.config.mode) { updateConfig(it.config.uuid, result.name, result.mode) design.fetch() @@ -170,15 +184,23 @@ class AppsStrategyActivity : BaseActivity() { if (!settings.autoSync || !settings.hasEndpoint() || settings.username.isBlank() || settings.password.isEmpty()) { return } - withProfile { - webdavPerform( - WebdavSync.ACTION_SYNC, - settings.url, - settings.lanUrl, - settings.username, - settings.password, - settings.remotePath, - ) + try { + withProfile { + webdavPerform( + WebdavSync.ACTION_SYNC, + settings.url, + settings.lanUrl, + settings.username, + settings.password, + settings.remotePath, + ) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + withContext(Dispatchers.Main) { + design?.showExceptionToast(e) + } } } } diff --git a/app/src/main/java/com/github/kr328/clash/ExternalControlActivity.kt b/app/src/main/java/com/github/kr328/clash/ExternalControlActivity.kt index e31d3d18b2..4e2731bf48 100644 --- a/app/src/main/java/com/github/kr328/clash/ExternalControlActivity.kt +++ b/app/src/main/java/com/github/kr328/clash/ExternalControlActivity.kt @@ -60,6 +60,7 @@ class ExternalControlActivity : Activity(), CoroutineScope by MainScope() { stopClash() } else { startClash() + return } } @@ -68,6 +69,7 @@ class ExternalControlActivity : Activity(), CoroutineScope by MainScope() { Toast.makeText(this, R.string.external_control_started, Toast.LENGTH_LONG).show() } else { startClash() + return } } @@ -83,16 +85,28 @@ class ExternalControlActivity : Activity(), CoroutineScope by MainScope() { } private fun startClash() { -// if (currentProfile == null) { -// Toast.makeText(this, R.string.no_profile_selected, Toast.LENGTH_LONG).show() -// return -// } - val vpnRequest = startClashService() - if (vpnRequest != null) { - Toast.makeText(this, R.string.unable_to_start_vpn, Toast.LENGTH_LONG).show() - return + val appContext = applicationContext + launch { + try { + val active = withProfile { queryActive() } + if (active == null || !active.imported) { + Toast.makeText(appContext, R.string.no_profile_selected, Toast.LENGTH_LONG).show() + return@launch + } + val vpnRequest = appContext.startClashService() + if (vpnRequest != null) { + try { + startActivity(VpnRequestActivity.consentIntent(this@ExternalControlActivity, vpnRequest)) + } catch (e: Exception) { + Toast.makeText(appContext, R.string.unable_to_start_vpn, Toast.LENGTH_LONG).show() + } + return@launch + } + Toast.makeText(appContext, R.string.external_control_started, Toast.LENGTH_LONG).show() + } finally { + finish() + } } - Toast.makeText(this, R.string.external_control_started, Toast.LENGTH_LONG).show() } private fun stopClash() { diff --git a/app/src/main/java/com/github/kr328/clash/FilesActivity.kt b/app/src/main/java/com/github/kr328/clash/FilesActivity.kt index 797c6c4b39..8fa820b813 100644 --- a/app/src/main/java/com/github/kr328/clash/FilesActivity.kt +++ b/app/src/main/java/com/github/kr328/clash/FilesActivity.kt @@ -7,6 +7,7 @@ import android.content.Intent import android.content.pm.PackageManager import android.net.Uri import android.os.Build +import androidx.activity.addCallback import androidx.activity.result.contract.ActivityResultContracts import androidx.core.content.ContextCompat import com.github.kr328.clash.common.util.grantPermissions @@ -30,6 +31,9 @@ class FilesActivity : BaseActivity() { val root = uuid.toString() val design = FilesDesign(this) + onBackPressedDispatcher.addCallback(this) { + design.requests.trySend(FilesDesign.Request.PopStack) + } val client = FilesClient(this) val stack = Stack() @@ -58,6 +62,7 @@ class FilesActivity : BaseActivity() { finish() } else { stack.pop() + if (stack.empty()) design.showAtRoot() } } is FilesDesign.Request.OpenDirectory -> { @@ -81,6 +86,7 @@ class FilesActivity : BaseActivity() { client.renameDocument(it.file.id, newName) } is FilesDesign.Request.ImportFile -> { + if (it.file == null && stack.empty()) return@onReceive val uri: Uri? = startActivityForResult( ActivityResultContracts.GetContent(), "*/*" @@ -122,10 +128,6 @@ class FilesActivity : BaseActivity() { } } - override fun onBackPressed() { - design?.requests?.trySend(FilesDesign.Request.PopStack) - } - private suspend fun FilesDesign.fetch(client: FilesClient, stack: Stack, root: String) { val documentId = stack.lastOrNull() ?: root val files = if (stack.empty()) { diff --git a/app/src/main/java/com/github/kr328/clash/LogcatActivity.kt b/app/src/main/java/com/github/kr328/clash/LogcatActivity.kt index 6f5d0883fb..714d7f74a5 100644 --- a/app/src/main/java/com/github/kr328/clash/LogcatActivity.kt +++ b/app/src/main/java/com/github/kr328/clash/LogcatActivity.kt @@ -53,7 +53,7 @@ class LogcatActivity : BaseActivity() { return showInvalid() } - val design = LogcatDesign(this, false) + val design = LogcatDesign(this, streaming = false, fileName = file.fileName) setContentDesign(design) diff --git a/app/src/main/java/com/github/kr328/clash/MainActivity.kt b/app/src/main/java/com/github/kr328/clash/MainActivity.kt index 19b4fee4f5..c399f21ffb 100644 --- a/app/src/main/java/com/github/kr328/clash/MainActivity.kt +++ b/app/src/main/java/com/github/kr328/clash/MainActivity.kt @@ -20,7 +20,10 @@ import com.github.kr328.clash.util.startClashService import com.github.kr328.clash.util.stopClashService import com.github.kr328.clash.util.withClash import com.github.kr328.clash.util.withProfile +import com.github.kr328.clash.core.Clash import com.github.kr328.clash.core.bridge.* +import com.github.kr328.clash.core.model.ProxySort +import com.github.kr328.clash.core.model.TunnelState import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.isActive import kotlinx.coroutines.selects.select @@ -57,8 +60,13 @@ class MainActivity : BaseActivity() { else design.startClash() } - MainDesign.Request.OpenProxy -> - startActivity(ProxyActivity::class.intent) + is MainDesign.Request.OpenProxy -> { + val open = ProxyActivity::class.intent + if (!it.group.isNullOrEmpty()) { + open.putExtra(ProxyActivity.EXTRA_PROXY_GROUP, it.group) + } + startActivity(open) + } MainDesign.Request.OpenProfiles -> startActivity(ProfilesActivity::class.intent) MainDesign.Request.OpenProviders -> @@ -78,6 +86,15 @@ class MainActivity : BaseActivity() { startActivity(HelpActivity::class.intent) MainDesign.Request.OpenAbout -> design.showAbout(queryAppVersionName()) + is MainDesign.Request.SetMode -> { + design.showToast(DesignR.string.mode_switch_tips, ToastDuration.Long) + withClash { + val override = queryOverride(Clash.OverrideSlot.Session) + override.mode = it.mode + patchOverride(Clash.OverrideSlot.Session, override) + } + design.fetch() + } } } if (clashRunning) { @@ -92,15 +109,21 @@ class MainActivity : BaseActivity() { private suspend fun MainDesign.fetch() { setClashRunning(clashRunning) - val state = withClash { - queryTunnelState() + val mode = withClash { + queryOverride(Clash.OverrideSlot.Session).mode ?: queryTunnelState().mode } val providers = withClash { queryProviders() } - setMode(state.mode) + setMode(mode) setHasProviders(providers.isNotEmpty()) + val (node, group) = if (clashRunning) queryCurrentNode(mode) else null to null + setCurrentNode(node, group) + + if (clashRunning) { + fetchTraffic() + } withProfile { setProfileName(queryActive()?.name) @@ -111,7 +134,22 @@ class MainActivity : BaseActivity() { private suspend fun MainDesign.fetchTraffic() { withClash { - setForwarded(queryTrafficTotal()) + setTraffic(queryTrafficNow(), queryTrafficTotal()) + } + } + + private suspend fun queryCurrentNode(mode: TunnelState.Mode): Pair { + if (mode == TunnelState.Mode.Direct) return null to null + return withClash { + val names = queryProxyGroupNames(true) + val group = when (mode) { + TunnelState.Mode.Global -> + names.firstOrNull { it.equals("GLOBAL", ignoreCase = true) } ?: names.firstOrNull() + else -> + names.firstOrNull { !it.equals("GLOBAL", ignoreCase = true) } ?: names.firstOrNull() + } ?: return@withClash null to null + val now = queryProxyGroup(group, ProxySort.Default).now.takeIf { it.isNotBlank() } + now to group } } diff --git a/app/src/main/java/com/github/kr328/clash/MainApplication.kt b/app/src/main/java/com/github/kr328/clash/MainApplication.kt index 9242d72299..40712c8716 100644 --- a/app/src/main/java/com/github/kr328/clash/MainApplication.kt +++ b/app/src/main/java/com/github/kr328/clash/MainApplication.kt @@ -8,8 +8,7 @@ import com.github.kr328.clash.common.log.Log import com.github.kr328.clash.remote.Remote import com.github.kr328.clash.service.util.sendServiceRecreated import com.github.kr328.clash.util.clashDir -import java.io.File -import java.io.FileOutputStream +import com.github.kr328.clash.util.installBundledGeoFile @Suppress("unused") class MainApplication : Application() { @@ -39,44 +38,17 @@ class MainApplication : Application() { clashDir.mkdirs() val updateDate = packageManager.getPackageInfo(packageName, 0).lastUpdateTime - val geoipFile = File(clashDir, "geoip.metadb") - if (geoipFile.exists() && geoipFile.lastModified() < updateDate) { - geoipFile.delete() + installBundledGeoFile(clashDir, "geoip.metadb", updateDate, true) { + assets.open("geoip.metadb") } - if (!geoipFile.exists()) { - FileOutputStream(geoipFile).use { - assets.open("geoip.metadb").copyTo(it) - } + installBundledGeoFile(clashDir, "geosite.dat", updateDate, true) { + assets.open("geosite.dat") } - - val geositeFile = File(clashDir, "geosite.dat") - if (geositeFile.exists() && geositeFile.lastModified() < updateDate) { - geositeFile.delete() - } - if (!geositeFile.exists()) { - FileOutputStream(geositeFile).use { - assets.open("geosite.dat").copyTo(it) - } - } - - val asnFile = File(clashDir, "ASN.mmdb") - if (asnFile.exists() && asnFile.lastModified() < updateDate) { - asnFile.delete() - } - if (!asnFile.exists()) { - FileOutputStream(asnFile).use { - assets.open("ASN.mmdb").copyTo(it) - } - } - - val bundleMRSFile = File(clashDir, "BundleMRS.7z") - if (bundleMRSFile.exists() && bundleMRSFile.lastModified() < updateDate) { - bundleMRSFile.delete() + installBundledGeoFile(clashDir, "ASN.mmdb", updateDate, true) { + assets.open("ASN.mmdb") } - if (!bundleMRSFile.exists()) { - FileOutputStream(bundleMRSFile).use { - assets.open("BundleMRS.7z").copyTo(it) - } + installBundledGeoFile(clashDir, "BundleMRS.7z", updateDate, false) { + assets.open("BundleMRS.7z") } } diff --git a/app/src/main/java/com/github/kr328/clash/MetaFeatureSettingsActivity.kt b/app/src/main/java/com/github/kr328/clash/MetaFeatureSettingsActivity.kt index 47f0c4c2aa..8410d95780 100644 --- a/app/src/main/java/com/github/kr328/clash/MetaFeatureSettingsActivity.kt +++ b/app/src/main/java/com/github/kr328/clash/MetaFeatureSettingsActivity.kt @@ -8,14 +8,15 @@ import androidx.activity.result.contract.ActivityResultContracts import com.github.kr328.clash.core.Clash import com.github.kr328.clash.design.MetaFeatureSettingsDesign import com.github.kr328.clash.util.clashDir +import com.github.kr328.clash.util.importReplacingGeoFile import com.github.kr328.clash.util.withClash import com.google.android.material.dialog.MaterialAlertDialogBuilder +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.isActive import kotlinx.coroutines.selects.select import kotlinx.coroutines.withContext -import java.io.File -import java.io.FileOutputStream +import java.io.IOException import com.github.kr328.clash.design.R @@ -103,7 +104,7 @@ class MetaFeatureSettingsActivity : BaseActivity() { .setTitle(R.string.geofile_unknown_db_format) .setMessage(getString(R.string.geofile_unknown_db_format_message, validDatabaseExtensions.joinToString("/"))) - .setPositiveButton("OK") { _, _ -> } + .setPositiveButton(R.string.ok) { _, _ -> } .show() return } @@ -119,16 +120,21 @@ class MetaFeatureSettingsActivity : BaseActivity() { else -> "" } - withContext(Dispatchers.IO) { - val outputFile = File(clashDir, outputFileName); - contentResolver.openInputStream(uri).use { ins -> - FileOutputStream(outputFile).use { outs -> - ins?.copyTo(outs) + try { + withContext(Dispatchers.IO) { + val input = contentResolver.openInputStream(uri) + ?: throw IOException("empty stream") + input.use { + importReplacingGeoFile(clashDir, outputFileName, it) } } + Toast.makeText(this, getString(R.string.geofile_imported, displayName), + Toast.LENGTH_LONG).show() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Toast.makeText(this, R.string.geofile_import_failed, Toast.LENGTH_LONG).show() } - Toast.makeText(this, getString(R.string.geofile_imported, displayName), - Toast.LENGTH_LONG).show() return } } diff --git a/app/src/main/java/com/github/kr328/clash/NewProfileActivity.kt b/app/src/main/java/com/github/kr328/clash/NewProfileActivity.kt index 99af8d5c76..bf7eb42a21 100644 --- a/app/src/main/java/com/github/kr328/clash/NewProfileActivity.kt +++ b/app/src/main/java/com/github/kr328/clash/NewProfileActivity.kt @@ -38,10 +38,10 @@ class NewProfileActivity : BaseActivity() { override suspend fun main() { val design = NewProfileDesign(this) - design.patchProviders(queryProfileProviders()) - setContentDesign(design) + design.patchProviders(queryProfileProviders()) + while (isActive) { select { events.onReceive { diff --git a/app/src/main/java/com/github/kr328/clash/PropertiesActivity.kt b/app/src/main/java/com/github/kr328/clash/PropertiesActivity.kt index a6fc07550a..4c65e456d3 100644 --- a/app/src/main/java/com/github/kr328/clash/PropertiesActivity.kt +++ b/app/src/main/java/com/github/kr328/clash/PropertiesActivity.kt @@ -1,5 +1,6 @@ package com.github.kr328.clash +import androidx.activity.addCallback import com.github.kr328.clash.common.util.intent import com.github.kr328.clash.common.util.setUUID import com.github.kr328.clash.common.util.uuid @@ -36,6 +37,16 @@ class PropertiesActivity : BaseActivity() { withProfile { release(uuid) } } + onBackPressedDispatcher.addCallback(this) { + this@PropertiesActivity.launch { + if (!design.progressing) { + if (original == design.profile || design.requestExitWithoutSaving()) { + finish() + } + } + } + } + while (isActive) { select { events.onReceive { @@ -69,17 +80,6 @@ class PropertiesActivity : BaseActivity() { } } - override fun onBackPressed() { - design?.apply { - launch { - if (!progressing) { - if (original == profile || requestExitWithoutSaving()) - finish() - } - } - } ?: return super.onBackPressed() - } - private suspend fun PropertiesDesign.verifyAndCommit() { when { profile.name.isBlank() -> { diff --git a/app/src/main/java/com/github/kr328/clash/ProxyActivity.kt b/app/src/main/java/com/github/kr328/clash/ProxyActivity.kt index 9136a3000d..566d037124 100644 --- a/app/src/main/java/com/github/kr328/clash/ProxyActivity.kt +++ b/app/src/main/java/com/github/kr328/clash/ProxyActivity.kt @@ -1,35 +1,90 @@ package com.github.kr328.clash +import android.os.Bundle import com.github.kr328.clash.common.util.intent import com.github.kr328.clash.core.Clash -import com.github.kr328.clash.core.model.Proxy import com.github.kr328.clash.design.ProxyDesign import com.github.kr328.clash.design.model.ProxyState import com.github.kr328.clash.util.withClash import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.selects.select +import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withPermit class ProxyActivity : BaseActivity() { + private var restoredGroup: String? = null + + override fun onCreate(savedInstanceState: Bundle?) { + restoredGroup = savedInstanceState?.getString(STATE_GROUP) + super.onCreate(savedInstanceState) + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + design?.currentGroupName?.let { outState.putString(STATE_GROUP, it) } + } + override suspend fun main() { - val mode = withClash { queryOverride(Clash.OverrideSlot.Session).mode } val names = withClash { queryProxyGroupNames(uiStore.proxyExcludeNotSelectable) } - val states = List(names.size) { ProxyState("?") } - val unorderedStates = names.indices.map { names[it] to states[it] }.toMap() + val states = List(names.size) { ProxyState("") } + val unorderedStates = names.indices.associate { names[it] to states[it] } val reloadLock = Semaphore(10) + val reloadSeq = IntArray(names.size) + val selectSeq = IntArray(names.size) + // Non-zero only for the reload a speed test itself starts. A dropped result must not clear it. + val clearOnTicket = IntArray(names.size) + val seqLock = Mutex() + val index = initialGroupIndex(names) val design = ProxyDesign( this, - mode, names, - uiStore - ) + index, + uiStore, + states, + ) { group -> + uiStore.proxyLastGroup = group + intent.putExtra(EXTRA_PROXY_GROUP, group) + } setContentDesign(design) - design.requests.send(ProxyDesign.Request.ReloadAll) + suspend fun startQuery(groupIndex: Int, fromUrlTest: Boolean) { + val (ticket, selectAtStart) = seqLock.withLock { + val next = ++reloadSeq[groupIndex] + if (fromUrlTest) clearOnTicket[groupIndex] = next + next to selectSeq[groupIndex] + } + val group = reloadLock.withPermit { + withClash { queryProxyGroup(names[groupIndex], uiStore.proxySort) } + } + val clearUrlTesting: Boolean = seqLock.withLock { + if (reloadSeq[groupIndex] != ticket) return@withLock null + if (selectSeq[groupIndex] == selectAtStart) { + states[groupIndex].now = group.now + } + val clear = clearOnTicket[groupIndex] == ticket + if (clear) clearOnTicket[groupIndex] = 0 + clear + } ?: return + design.updateGroup( + groupIndex, + group.proxies, + group.type == "Selector", + states[groupIndex], + unorderedStates, + group.type, + clearUrlTesting, + ) + } + + // Direct mode and a fully hidden list are empty. Do not Reload index 0. + if (index >= 0) { + design.requests.trySend(ProxyDesign.Request.Reload(index)) + } while (isActive) { select { @@ -41,9 +96,7 @@ class ProxyActivity : BaseActivity() { } if (newNames != names) { - startActivity(ProxyActivity::class.intent) - - finish() + relaunchShowingCurrent() } } else -> Unit @@ -51,37 +104,19 @@ class ProxyActivity : BaseActivity() { } design.requests.onReceive { when (it) { - ProxyDesign.Request.ReLaunch -> { - startActivity(ProxyActivity::class.intent) - - finish() - } + ProxyDesign.Request.ReLaunch -> relaunchShowingCurrent() ProxyDesign.Request.ReloadAll -> { names.indices.forEach { idx -> design.requests.trySend(ProxyDesign.Request.Reload(idx)) } } is ProxyDesign.Request.Reload -> { - launch { - val group = reloadLock.withPermit { - withClash { - queryProxyGroup(names[it.index], uiStore.proxySort) - } - } - val state = states[it.index] - - state.now = group.now - - design.updateGroup( - it.index, - group.proxies, - group.type == "Selector", - state, - unorderedStates - ) - } + if (it.index !in names.indices) return@onReceive + launch { startQuery(it.index, fromUrlTest = false) } } is ProxyDesign.Request.Select -> { + if (it.index !in names.indices) return@onReceive + seqLock.withLock { selectSeq[it.index]++ } withClash { patchSelector(names[it.index], it.name) @@ -91,12 +126,12 @@ class ProxyActivity : BaseActivity() { design.requestRedrawVisible() } is ProxyDesign.Request.UrlTest -> { + if (it.index !in names.indices) return@onReceive launch { withClash { healthCheck(names[it.index]) } - - design.requests.send(ProxyDesign.Request.Reload(it.index)) + startQuery(it.index, fromUrlTest = true) } } is ProxyDesign.Request.PatchMode -> { @@ -115,4 +150,32 @@ class ProxyActivity : BaseActivity() { } } } -} \ No newline at end of file + + private fun initialGroupIndex(names: List): Int { + if (names.isEmpty()) return -1 + // Rotation only keeps uiMode. Prefer the group that was on screen, then the launch extra. + restoredGroup?.takeIf { it.isNotEmpty() }?.let { saved -> + val savedIndex = names.indexOf(saved) + if (savedIndex >= 0) return savedIndex + } + intent.getStringExtra(EXTRA_PROXY_GROUP)?.takeIf { it.isNotEmpty() }?.let { extra -> + val extraIndex = names.indexOf(extra) + if (extraIndex >= 0) return extraIndex + } + val last = names.indexOf(uiStore.proxyLastGroup) + if (last >= 0) return last + return 0 + } + + private fun relaunchShowingCurrent() { + val next = ProxyActivity::class.intent + design?.currentGroupName?.let { next.putExtra(EXTRA_PROXY_GROUP, it) } + startActivity(next) + finish() + } + + companion object { + const val EXTRA_PROXY_GROUP = "proxy_group" + private const val STATE_GROUP = "proxy_shown_group" + } +} diff --git a/app/src/main/java/com/github/kr328/clash/TileService.kt b/app/src/main/java/com/github/kr328/clash/TileService.kt index d24abbb470..fe658f72d0 100644 --- a/app/src/main/java/com/github/kr328/clash/TileService.kt +++ b/app/src/main/java/com/github/kr328/clash/TileService.kt @@ -1,5 +1,6 @@ package com.github.kr328.clash +import android.app.PendingIntent import android.content.BroadcastReceiver import android.content.Context import android.content.Intent @@ -9,6 +10,7 @@ import android.os.Build import android.service.quicksettings.Tile import android.service.quicksettings.TileService import androidx.annotation.RequiresApi +import com.github.kr328.clash.common.compat.pendingIntentFlags import com.github.kr328.clash.common.compat.registerReceiverCompat import com.github.kr328.clash.common.constants.Intents import com.github.kr328.clash.common.constants.Permissions @@ -27,7 +29,10 @@ class TileService : TileService() { when (tile.state) { Tile.STATE_INACTIVE -> { - startClashService() + val prepare = startClashService() + if (prepare != null) { + openVpnConsent(prepare) + } } Tile.STATE_ACTIVE -> { stopClashService() @@ -35,6 +40,23 @@ class TileService : TileService() { } } + private fun openVpnConsent(prepare: Intent) { + val consent = VpnRequestActivity.consentIntent(this, prepare) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + val pending = PendingIntent.getActivity( + this, + 0, + consent, + pendingIntentFlags(PendingIntent.FLAG_UPDATE_CURRENT), + ) + startActivityAndCollapse(pending) + } else { + @Suppress("DEPRECATION") + startActivityAndCollapse(consent) + } + } + override fun onStartListening() { super.onStartListening() diff --git a/app/src/main/java/com/github/kr328/clash/VpnRequestActivity.kt b/app/src/main/java/com/github/kr328/clash/VpnRequestActivity.kt new file mode 100644 index 0000000000..b36f71418b --- /dev/null +++ b/app/src/main/java/com/github/kr328/clash/VpnRequestActivity.kt @@ -0,0 +1,58 @@ +package com.github.kr328.clash + +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.result.contract.ActivityResultContracts +import com.github.kr328.clash.util.startClashService + +class VpnRequestActivity : ComponentActivity() { + private val launcher = registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { result -> + if (result.resultCode == RESULT_OK) { + startClashService() + } + finish() + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + @Suppress("DEPRECATION") + overridePendingTransition(0, 0) + if (savedInstanceState != null) return + val prepare = readPrepare() + if (prepare == null) { + finish() + return + } + launcher.launch(prepare) + } + + override fun finish() { + super.finish() + @Suppress("DEPRECATION") + overridePendingTransition(0, 0) + } + + private fun readPrepare(): Intent? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra(EXTRA_PREPARE, Intent::class.java) + } else { + @Suppress("DEPRECATION") + intent.getParcelableExtra(EXTRA_PREPARE) + } + } + + companion object { + const val EXTRA_PREPARE = "vpn_prepare" + + fun consentIntent(context: Context, prepare: Intent): Intent { + return Intent(context, VpnRequestActivity::class.java) + .putExtra(EXTRA_PREPARE, prepare) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + } +} diff --git a/app/src/main/java/com/github/kr328/clash/WebdavSettingsActivity.kt b/app/src/main/java/com/github/kr328/clash/WebdavSettingsActivity.kt index 43359f22c7..f450491c23 100644 --- a/app/src/main/java/com/github/kr328/clash/WebdavSettingsActivity.kt +++ b/app/src/main/java/com/github/kr328/clash/WebdavSettingsActivity.kt @@ -47,6 +47,7 @@ class WebdavSettingsActivity : BaseActivity() { store: WebdavSettingsStore, action: String, ) { + // Queued work is received only after finally, when busy is already false. if (busy) return busy = true try { @@ -71,6 +72,7 @@ class WebdavSettingsActivity : BaseActivity() { design.showExceptionToast(e) } finally { busy = false + design.onActionFinished() } } } diff --git a/app/src/main/java/com/github/kr328/clash/util/GeoFiles.kt b/app/src/main/java/com/github/kr328/clash/util/GeoFiles.kt new file mode 100644 index 0000000000..6da55f851f --- /dev/null +++ b/app/src/main/java/com/github/kr328/clash/util/GeoFiles.kt @@ -0,0 +1,121 @@ +package com.github.kr328.clash.util + +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.io.InputStream + +internal val userGeoFileNames: Set = setOf( + "geoip.metadb", + "geosite.dat", + "ASN.mmdb", +) + +internal fun shouldInstallGeoAsset( + exists: Boolean, + lastModified: Long, + lastUpdateTime: Long, + userMarked: Boolean, +): Boolean { + if (userMarked) return false + if (!exists) return true + return lastModified < lastUpdateTime +} + +internal fun geoUserSidecar(clashDir: File): File { + val parent = clashDir.parentFile ?: throw IOException("missing parent") + return File(parent, "clash-user") +} + +internal fun isUserGeoMarked(clashDir: File, name: String): Boolean { + return File(geoUserSidecar(clashDir), name).isFile +} + +internal fun markUserGeoFile(clashDir: File, name: String) { + if (name !in userGeoFileNames) return + val dir = geoUserSidecar(clashDir) + if (!dir.exists() && !dir.mkdirs() && !dir.isDirectory) { + throw IOException("failed to create ${dir.path}") + } + val marker = File(dir, name) + if (!marker.exists()) { + marker.createNewFile() + } + if (!marker.isFile) { + throw IOException("failed to mark $name") + } +} + +internal fun clearUserGeoMark(clashDir: File, name: String) { + File(geoUserSidecar(clashDir), name).delete() +} + +internal fun replaceFileFromStream(target: File, input: InputStream) { + val parent = target.parentFile ?: throw IOException("missing parent") + if (!parent.exists() && !parent.mkdirs() && !parent.isDirectory) { + throw IOException("failed to create ${parent.path}") + } + val tmp = File.createTempFile(".${target.name}.", ".partial", parent) + var published = false + try { + FileOutputStream(tmp).use { output -> + input.copyTo(output) + } + published = tmp.renameTo(target) + if (!published) { + throw IOException("failed to replace ${target.name}") + } + } finally { + if (!published && tmp.exists()) tmp.delete() + } +} + +internal fun installBundledGeoFile( + clashDir: File, + name: String, + lastUpdateTime: Long, + userManaged: Boolean, + open: () -> InputStream, +) { + val target = File(clashDir, name) + val managed = userManaged && name in userGeoFileNames + withGeoInstallLock(clashDir, name) { + if (managed && !target.exists()) { + clearUserGeoMark(clashDir, name) + if (isUserGeoMarked(clashDir, name)) { + throw IOException("failed to clear mark $name") + } + } + val marked = managed && target.exists() && isUserGeoMarked(clashDir, name) + if (!shouldInstallGeoAsset(target.exists(), target.lastModified(), lastUpdateTime, marked)) { + return@withGeoInstallLock + } + open().use { input -> + replaceFileFromStream(target, input) + } + } +} + +internal fun importReplacingGeoFile(clashDir: File, name: String, input: InputStream) { + val target = File(clashDir, name) + withGeoInstallLock(clashDir, name) { + replaceFileFromStream(target, input) + markUserGeoFile(clashDir, name) + } +} + +private fun withGeoInstallLock(clashDir: File, name: String, block: () -> T): T { + val dir = geoUserSidecar(clashDir) + if (!dir.exists() && !dir.mkdirs() && !dir.isDirectory) { + throw IOException("failed to create ${dir.path}") + } + val lockFile = File(dir, ".$name.lock") + return FileOutputStream(lockFile).use { stream -> + val lock = stream.channel.lock() + try { + block() + } finally { + if (lock.isValid) lock.release() + } + } +} diff --git a/build.gradle.kts b/build.gradle.kts index 1cdf8b820f..5982fb3428 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -188,7 +188,7 @@ subprojects { isEnable = true isUniversalApk = true reset() - include("arm64-v8a", "armeabi-v7a", "x86", "x86_64") + include("arm64-v8a") } } } diff --git a/common/README.md b/common/README.md new file mode 100644 index 0000000000..cd8abc4329 --- /dev/null +++ b/common/README.md @@ -0,0 +1,16 @@ +--- +title: "common 模块" +type: overview +status: accepted +phase: N/A +updated: 2026-10-15 +summary: "跨模块常量、Store 与 Android 兼容工具" +--- + +# common + +被 `app`、`core`、`service`、`design` 共用的常量、偏好 Store 和兼容工具。对 `hideapi` 是 `compileOnly`。 + +导航:[docs/modules/common/INDEX.md](../docs/modules/common/INDEX.md) + +设计:[docs/INDEX.md](docs/INDEX.md) diff --git a/common/docs/INDEX.md b/common/docs/INDEX.md new file mode 100644 index 0000000000..284407dbdf --- /dev/null +++ b/common/docs/INDEX.md @@ -0,0 +1,73 @@ +--- +title: "common 模块设计" +type: index +status: accepted +phase: N/A +updated: 2026-10-15 +summary: "common 的包、依赖,以及 Store、常量以外的 Global、compat 和 util" +--- + +# common + +`app`、`core`、`service`、`design` 以 `implementation` 依赖本模块。本模块自己只 `compileOnly` [`hideapi`](../../hideapi/docs/INDEX.md),另外 `implementation` 了协程和 `androidx.core`。namespace 来自根 `build.gradle.kts`:模块名不是 `app` 时为 `com.github.kr328.clash.<模块名>`,因此是 `com.github.kr328.clash.common`。依赖图见 [架构概览](../../docs/architecture/overview.md)。 + +源码在 `common/src/main/java/com/github/kr328/clash/common/`。 + +| 包 | 作用 | +|:---|:-----| +| `store` | 带类型的偏好读写。见 [Store](store.md) | +| `constants` | 按运行时包名拼出的动作、组件、权限、authority。见 [常量](constants.md) | +| `compat` | 按 `SDK_INT` 分支的 Android API 包装 | +| `util` | 包名、Intent、分片 Parcel、定时 Channel | +| `Global.kt` | 进程内的 `Application` 和 IO 协程作用域 | +| `log` | 固定 tag 的 `android.util.Log` 包装 | +| `id` | 进程内递增整数。仓库里由 `ProfileWorker` 用作通知 id | + +本模块的 Manifest 只声明 `RECEIVE_BROADCASTS` 权限,见 [常量](constants.md)。外部控制动作怎样进 Manifest,见 [运行时](../../docs/systems/runtime/INDEX.md)。 + +## Global + +`Global` 是 `object`,用 `CoroutineScope(Dispatchers.IO)` 委托实现 `CoroutineScope`。`init` 保存 `Application`;`application` 在 `init` 之前访问会因为 `lateinit` 失败。`destroy` 取消这个作用域。 + +仓库里 `Global.init` 的调用在 `app` 的 `MainApplication.attachBaseContext`。`Global.destroy` 只出现在该类的 `finalize()` 方法里,没有 `override`,源码里也没有别的调用点。 + +`util` 的顶层 `packageName` 在第一次读取时取 `Global.application.packageName`,之后不再变。`constants` 里那些 `val` 在对应 `object` 第一次初始化时读取它。 + +## compat + +都在 `com.github.kr328.clash.common.compat`。版本判断以源码里的 `Build.VERSION.SDK_INT` 为准。 + +| 符号 | 行为 | +|:-----|:-----| +| `Application.currentProcessName` | API 28 起用 `Application.getProcessName()`。更低版本调用 `ActivityThread.currentProcessName()`;任何 `Throwable` 记 `Log.w` 后返回 `packageName`。存根见 [hideapi](../../hideapi/docs/INDEX.md) | +| `Drawable.foreground()` | API 26 起,且是 `AdaptiveIconDrawable`、`background == null` 时返回 `foreground`,否则返回自身 | +| `pendingIntentFlags` | `SDK_INT < 24` 时原样返回。`SDK_INT > 30` 且 `mutable` 为真时或上 `FLAG_MUTABLE`,否则在 `SDK_INT >= 24` 时或上 `FLAG_IMMUTABLE`。`mutable` 默认是 `false` | +| `PackageInfo.versionCodeCompat` | API 28 起读 `longVersionCode`,否则把 `versionCode` 转成 `Long` | +| `Configuration.preferredLocale` | API 24 起取 `locales[0]`,否则取 `locale` | +| `fromHtmlCompat` | API 24 起用 `FROM_HTML_MODE_COMPACT`,否则调用单参数 `Html.fromHtml` | +| `Context.getColorCompat` / `getDrawableCompat` | 转到 `ContextCompat` | +| `Context.registerReceiverCompat` | API 33 起:`permission == null` 时 flag 为 `RECEIVER_EXPORTED`,否则为 `RECEIVER_NOT_EXPORTED`。更低版本不传这个 flag | +| `Context.startForegroundServiceCompat` | API 26 起 `startForegroundService`,否则 `startService` | +| `Service.startForegroundCompat` | API 34 起带 `FOREGROUND_SERVICE_TYPE_SPECIAL_USE`,否则两参数的 `startForeground` | +| `TextView.textAppearance` | 只写。API 23 起 `setTextAppearance(value)`,否则 `setTextAppearance(context, value)`。读会抛 `UnsupportedOperationException` | +| `Window.isSystemBarsTranslucentCompat` | 只写。API 30 起 `setDecorFitsSystemWindows`;更低版本改 `systemUiVisibility` 的 layout 标志。API 28 起同时改 `layoutInDisplayCutoutMode` | +| `Window.isLightStatusBarsCompat` / `isLightNavigationBarCompat` | 只写。API 30 起走 `WindowInsetsController`,否则改对应的 `systemUiVisibility` 亮色标志 | +| `Window.isAllowForceDarkCompat` | API 29 起读写 `decorView.isForceDarkAllowed`。更低版本读出 `false`,写被忽略 | + +## util、log、id + +| 符号 | 行为 | +|:-----|:-----| +| `KClass.componentName` | `ComponentName(Global.application.packageName, java.name)` | +| `KClass.intent` | `Intent(Global.application, java)` | +| `Intent.grantPermissions` | 默认同时加上读、写 URI 权限 flag,返回 `this` | +| `Intent.fileName` | `data` 的 scheme 为 `file` 时取其 scheme-specific part。写入用 `Uri.fromParts("file", …)` | +| `Intent.uuid` | scheme 为 `uuid` 时把 scheme-specific part 解析成 `UUID`。另有 `setUUID` / `setFileName` | +| `List.writeToParcelSlice` | 先写列表长度,再写一个 `Binder`。对方用事务码 `10` 按 offset 和 chunk 取一段,逐个 `writeToParcel` | +| `Creator.createListFromParcelSlice` | 按块 `transact`。返回 `false`、读到长度为 0,或已取满时停止 | +| `PatternFileName` | `Regex("[^*&%\\n\\r/]+")` | +| `CoroutineScope.ticker` | `Channel.RENDEZVOUS`。循环里先 `send` 当前毫秒时间,再 `delay(period)`。捕获 `Exception` 后循环结束,没有 `close` | +| `Log` | tag 常量是 `ClashMetaForAndroid`,`i` / `w` / `e` / `d` / `v` 都带上它。`f` 调用的是 `android.util.Log.wtf(message, throwable)`,按这个两参数重载,第一个参数是 tag,不是上面的常量 | +| `UndefinedIds.next` | `@Synchronized`。`current = ((current and 0x00FFFFFF) + 1) or 0x14000000`。初始 `current` 为 0 | + +导航:[模块 README](../README.md)、[模块导航](../../docs/modules/common/INDEX.md)。 diff --git a/common/docs/constants.md b/common/docs/constants.md new file mode 100644 index 0000000000..662c002937 --- /dev/null +++ b/common/docs/constants.md @@ -0,0 +1,79 @@ +--- +title: "common 常量" +type: architecture +status: accepted +phase: N/A +updated: 2026-10-15 +summary: "Intents、Components、Permissions、Authorities、Metadata 实际拼出的字符串" +--- + +# common 常量 + +包是 `com.github.kr328.clash.common.constants`。除下面标明的 `const` 和类名外,字符串都是运行时 `packageName` 加上固定后缀。 + +`packageName` 定义在 `util/Global.kt`,第一次读取时等于 `Global.application.packageName`。这些 `object` 的 `val` 在该 `object` 第一次初始化时求值,所以要发生在 `Global.init` 之后。它读的是进程里的应用包名,不是本模块的 namespace,也不是写死的 flavor。包名和 namespace 的差别见 [术语表](../../docs/glossary.md)。 + +动作字符串怎样和导出组件的 Manifest 对齐,见 [运行时](../../docs/systems/runtime/INDEX.md)。下文只列出常量本身。 + +## Intents + +源码把前四个标为 Public,其余标为 Self。Public 的后缀是 `.action.`,Self 的后缀是 `.intent.action.`。Kotlin 名字和后缀并不总是同一段文字:`ACTION_PROFILE_REQUEST_UPDATE` 的后缀是 `REQUEST_UPDATE`,不是 `PROFILE_REQUEST_UPDATE`。 + +| 常量 | 值 | +|:-----|:---| +| `ACTION_PROVIDE_URL` | `$packageName.action.PROVIDE_URL` | +| `ACTION_START_CLASH` | `$packageName.action.START_CLASH` | +| `ACTION_STOP_CLASH` | `$packageName.action.STOP_CLASH` | +| `ACTION_TOGGLE_CLASH` | `$packageName.action.TOGGLE_CLASH` | +| `ACTION_SERVICE_RECREATED` | `$packageName.intent.action.CLASH_RECREATED` | +| `ACTION_CLASH_STARTED` | `$packageName.intent.action.CLASH_STARTED` | +| `ACTION_CLASH_STOPPED` | `$packageName.intent.action.CLASH_STOPPED` | +| `ACTION_CLASH_REQUEST_STOP` | `$packageName.intent.action.CLASH_REQUEST_STOP` | +| `ACTION_CLASH_REQUEST_RELOAD` | `$packageName.intent.action.CLASH_REQUEST_RELOAD` | +| `ACTION_PROFILE_CHANGED` | `$packageName.intent.action.PROFILE_CHANGED` | +| `ACTION_PROFILE_UPDATE_COMPLETED` | `$packageName.intent.action.PROFILE_UPDATE_COMPLETED` | +| `ACTION_PROFILE_UPDATE_FAILED` | `$packageName.intent.action.PROFILE_UPDATE_FAILED` | +| `ACTION_PROFILE_REQUEST_UPDATE` | `$packageName.intent.action.REQUEST_UPDATE` | +| `ACTION_PROFILE_SCHEDULE_UPDATES` | `$packageName.intent.action.SCHEDULE_UPDATES` | +| `ACTION_PROFILE_LOADED` | `$packageName.intent.action.PROFILE_LOADED` | +| `ACTION_OVERRIDE_CHANGED` | `$packageName.intent.action.OVERRIDE_CHANGED` | + +Extra 键是 `const`,不加包名前缀: + +| 常量 | 值 | +|:-----|:---| +| `EXTRA_NAME` | `name` | +| `EXTRA_STOP_REASON` | `stop_reason` | +| `EXTRA_UUID` | `uuid` | +| `EXTRA_FAIL_REASON` | `fail_reason` | + +## Components + +类名包是源码里的私有常量 `com.github.kr328.clash`。`ComponentName` 的第一个参数才是运行时 `packageName`。 + +| 常量 | 类名 | +|:-----|:-----| +| `MAIN_ACTIVITY` | `com.github.kr328.clash.MainActivity` | +| `PROPERTIES_ACTIVITY` | `com.github.kr328.clash.PropertiesActivity` | + +## Permissions + +`RECEIVE_SELF_BROADCASTS` 是 `$packageName.permission.RECEIVE_BROADCASTS`。 + +同一字符串由本模块 Manifest 声明:`${applicationId}.permission.RECEIVE_BROADCASTS`,`protectionLevel` 为 `privileged|signature`,同时有一条 `uses-permission`。label 是 `@string/receive_clash_broadcasts`,description 是 `@string/receive_broadcasts_of_clash`。这两条字符串在 `common/src/main/res/` 的 `values`、`values-zh`、`values-zh-rTW`、`values-ru`。 + +## Authorities + +| 常量 | 值 | +|:-----|:---| +| `STATUS_PROVIDER` | `$packageName.status` | +| `SETTINGS_PROVIDER` | `$packageName.settings` | +| `FILES_PROVIDER` | `$packageName.files` | + +`service/src/main/AndroidManifest.xml` 里 `StatusProvider`、`PreferenceProvider`、`FilesProvider` 的 `android:authorities` 分别是 `${applicationId}.status`、`${applicationId}.settings`、`${applicationId}.files`。 + +## Metadata + +`GEOIP_FILE_NAME` 是 `$packageName.GEOIP_FILE_NAME`。在本仓库的 Kotlin、Java 和 XML 里,只有这一处定义,没有别的引用。 + +返回:[common 设计索引](INDEX.md)。 diff --git a/common/docs/store.md b/common/docs/store.md new file mode 100644 index 0000000000..12bb33cf92 --- /dev/null +++ b/common/docs/store.md @@ -0,0 +1,56 @@ +--- +title: "Store 与 StoreProvider" +type: architecture +status: accepted +phase: N/A +updated: 2026-10-15 +summary: "Store 用属性委托按给定的键读写 StoreProvider,本模块只有 SharedPreferences 这一份实现" +--- + +# Store 与 StoreProvider + +`common` 的 `store` 包有三个类型:`StoreProvider`、`Store`、`SharedPreferenceProvider`。这里没有缓存,没有变更监听,也没有跨进程逻辑。每次读和写都进到当时的 `provider`。 + +## StoreProvider + +接口按键提供五种值,读都带默认值: + +| 方法 | 值 | +|:-----|:---| +| `getInt` / `setInt` | `Int` | +| `getLong` / `setLong` | `Long` | +| `getString` / `setString` | `String` | +| `getStringSet` / `setStringSet` | `Set` | +| `getBoolean` / `setBoolean` | `Boolean` | + +本模块里唯一的实现是 `SharedPreferenceProvider`。`SharedPreferences.asStoreProvider()` 返回它。写入使用 `androidx.core.content.edit` 的 `preferences.edit { }`,调用处没有传 `commit = true`。`getString` 和 `getStringSet` 对平台返回值做了 `!!`,平台给出 `null` 时会在这里抛空指针。 + +## Store + +`Store` 只保存构造时传入的 `StoreProvider`。每种工厂方法返回 `Store.Delegate`,实现 `getValue` / `setValue`,所以调用方写成 `var field by store.xxx(...)`。 + +键是工厂参数里的字符串,不是 Kotlin 属性名。委托不读 `KProperty.name`。 + +| 工厂 | 存储 | 读到不能用的值时 | +|:-----|:-----|:-----------------| +| `int` / `long` / `boolean` | 同类型 | 键不存在时用传入的默认值 | +| `string` / `stringSet` | 同类型 | 同上;经过上面的 `!!` | +| `enum` | `value.name` 字符串 | `getString` 的默认值是 `defaultValue.name`。`values` 里没有同名项时返回 `defaultValue` | +| `typedString` | 字符串,类型是 `T?` | 读:`from(provider.getString(key, to(null)))`。写:`setString(key, to(value))`。缺键时没有单独的 `T` 默认值,而是先 `to(null)` 再 `from` | + +`enum` 的 `values` 由调用方传入,通常是某个枚举的 `values()`。 + +## 谁在用 + +本模块不定义具体偏好项。下面这些类自己 `new Store(...)`,字段和默认值留在各类里: + +| 类 | `StoreProvider` 从哪来 | +|:---|:------------------------| +| `app` 的 `AppStore` | `getSharedPreferences("app", MODE_PRIVATE)` | +| `app` 的 `TipsStore` | `getSharedPreferences("tips", MODE_PRIVATE)` | +| `design` 的 `UiStore` | `getSharedPreferences("ui", MODE_PRIVATE)` | +| `service` 的 `ServiceStore` | `PreferenceProvider.createSharedPreferencesFromContext(context)`,再 `asStoreProvider()` | + +`ServiceStore` 里还有不经过 `Store` 的字段,直接写 `settings` 目录下的文件。那部分不属于本模块。 + +返回:[common 设计索引](INDEX.md)。 diff --git a/common/src/main/java/com/github/kr328/clash/common/compat/Context.kt b/common/src/main/java/com/github/kr328/clash/common/compat/Context.kt index b58ce0dcf4..cd2cf230c2 100644 --- a/common/src/main/java/com/github/kr328/clash/common/compat/Context.kt +++ b/common/src/main/java/com/github/kr328/clash/common/compat/Context.kt @@ -26,11 +26,12 @@ fun Context.registerReceiverCompat( receiver: BroadcastReceiver, filter: IntentFilter, permission: String? = null, - handler: Handler? = null + handler: Handler? = null, + exported: Boolean = false ) = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) registerReceiver(receiver, filter, permission, handler, - if (permission == null) Context.RECEIVER_EXPORTED else Context.RECEIVER_NOT_EXPORTED + if (exported) Context.RECEIVER_EXPORTED else Context.RECEIVER_NOT_EXPORTED ) else registerReceiver(receiver, filter, permission, handler) diff --git a/common/src/main/java/com/github/kr328/clash/common/compat/Intents.kt b/common/src/main/java/com/github/kr328/clash/common/compat/Intents.kt index dae7463089..2a8b6d7af6 100644 --- a/common/src/main/java/com/github/kr328/clash/common/compat/Intents.kt +++ b/common/src/main/java/com/github/kr328/clash/common/compat/Intents.kt @@ -4,12 +4,18 @@ import android.app.PendingIntent import android.os.Build fun pendingIntentFlags(flags: Int, mutable: Boolean = false): Int { - return if (Build.VERSION.SDK_INT >= 24) { - if (Build.VERSION.SDK_INT > 30 && mutable) { + return if (Build.VERSION.SDK_INT >= 31) { + if (mutable) { flags or PendingIntent.FLAG_MUTABLE } else { flags or PendingIntent.FLAG_IMMUTABLE } + } else if (Build.VERSION.SDK_INT >= 24) { + if (mutable) { + flags + } else { + flags or PendingIntent.FLAG_IMMUTABLE + } } else { flags } diff --git a/common/src/main/java/com/github/kr328/clash/common/compat/UI.kt b/common/src/main/java/com/github/kr328/clash/common/compat/UI.kt index 3ce2d64d80..d5d8bd726f 100644 --- a/common/src/main/java/com/github/kr328/clash/common/compat/UI.kt +++ b/common/src/main/java/com/github/kr328/clash/common/compat/UI.kt @@ -8,9 +8,8 @@ import android.view.View import android.view.View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR import android.view.View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR import android.view.Window -import android.view.WindowInsetsController.APPEARANCE_LIGHT_NAVIGATION_BARS -import android.view.WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS import android.view.WindowManager +import androidx.core.view.WindowInsetsControllerCompat var Window.isSystemBarsTranslucentCompat: Boolean get() { @@ -42,6 +41,7 @@ var Window.isSystemBarsTranslucentCompat: Boolean attributes.layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT } + setAttributes(attributes) } } @@ -51,30 +51,14 @@ var Window.isLightStatusBarsCompat: Boolean } @TargetApi(23) set(value) { - if (value) { - if (Build.VERSION.SDK_INT >= 30) { - decorView.windowInsetsController?.apply { - setSystemBarsAppearance( - APPEARANCE_LIGHT_STATUS_BARS, - APPEARANCE_LIGHT_STATUS_BARS - ) - } - } else { - decorView.systemUiVisibility = - decorView.systemUiVisibility or SYSTEM_UI_FLAG_LIGHT_STATUS_BAR - } + if (Build.VERSION.SDK_INT >= 30) { + WindowInsetsControllerCompat(this, decorView).isAppearanceLightStatusBars = value + } else if (value) { + decorView.systemUiVisibility = + decorView.systemUiVisibility or SYSTEM_UI_FLAG_LIGHT_STATUS_BAR } else { - if (Build.VERSION.SDK_INT >= 30) { - decorView.windowInsetsController?.apply { - setSystemBarsAppearance( - 0, - APPEARANCE_LIGHT_STATUS_BARS - ) - } - } else { - decorView.systemUiVisibility = - decorView.systemUiVisibility and SYSTEM_UI_FLAG_LIGHT_STATUS_BAR.inv() - } + decorView.systemUiVisibility = + decorView.systemUiVisibility and SYSTEM_UI_FLAG_LIGHT_STATUS_BAR.inv() } } @@ -84,30 +68,14 @@ var Window.isLightNavigationBarCompat: Boolean } @TargetApi(27) set(value) { - if (value) { - if (Build.VERSION.SDK_INT >= 30) { - decorView.windowInsetsController?.apply { - setSystemBarsAppearance( - APPEARANCE_LIGHT_NAVIGATION_BARS, - APPEARANCE_LIGHT_NAVIGATION_BARS - ) - } - } else { - decorView.systemUiVisibility = - decorView.systemUiVisibility or SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR - } + if (Build.VERSION.SDK_INT >= 30) { + WindowInsetsControllerCompat(this, decorView).isAppearanceLightNavigationBars = value + } else if (value) { + decorView.systemUiVisibility = + decorView.systemUiVisibility or SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR } else { - if (Build.VERSION.SDK_INT >= 30) { - decorView.windowInsetsController?.apply { - setSystemBarsAppearance( - 0, - APPEARANCE_LIGHT_NAVIGATION_BARS - ) - } - } else { - decorView.systemUiVisibility = - decorView.systemUiVisibility and SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR.inv() - } + decorView.systemUiVisibility = + decorView.systemUiVisibility and SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR.inv() } } diff --git a/common/src/main/java/com/github/kr328/clash/common/util/Ticker.kt b/common/src/main/java/com/github/kr328/clash/common/util/Ticker.kt index 01cab403b9..79aa6fe487 100644 --- a/common/src/main/java/com/github/kr328/clash/common/util/Ticker.kt +++ b/common/src/main/java/com/github/kr328/clash/common/util/Ticker.kt @@ -16,8 +16,8 @@ fun CoroutineScope.ticker(period: Long): Channel { delay(period) } - } catch (ignored: Exception) { - + } finally { + channel.close() } } diff --git a/core/README.md b/core/README.md new file mode 100644 index 0000000000..517c553d48 --- /dev/null +++ b/core/README.md @@ -0,0 +1,16 @@ +--- +title: "core 模块" +type: overview +status: accepted +phase: N/A +updated: 2026-10-15 +summary: "把 mihomo 编成 libclash.so 的 JNI 与 Go 桥" +--- + +# core + +Kotlin 模型、JNI 和 Go 桥。mihomo 在 `src/foss/golang/clash`。`src/main/golang/go.mod` 用 `replace` 指向该目录。 + +导航:[docs/modules/core/INDEX.md](../docs/modules/core/INDEX.md)。构建见 [构建指南](../docs/guides/getting-started/build.md)。 + +模块设计见 [docs/INDEX.md](docs/INDEX.md)。 diff --git a/core/docs/INDEX.md b/core/docs/INDEX.md new file mode 100644 index 0000000000..2cb818e578 --- /dev/null +++ b/core/docs/INDEX.md @@ -0,0 +1,22 @@ +--- +title: "core 模块设计" +type: index +status: accepted +phase: N/A +updated: 2026-10-15 +summary: "Kotlin 桥、JNI 边界和 golang/native 包布局" +--- + +# core 设计 + +这里只写 `core` 自己的桥:Kotlin 对象、JNI,以及 `core/src/main/golang/native`。mihomo 协议在子模块里,不在这里展开。 + +进程里谁加载这条桥,见 [运行时](../../docs/systems/runtime/INDEX.md)。构建、flavor 和子模块 pin 见 [构建指南](../../docs/guides/getting-started/build.md)。模块入口表见 [modules/core](../../docs/modules/core/INDEX.md)。 + +| 文档 | 内容 | +|:-----|:-----| +| [桥接边界](bridge.md) | `Clash`、`Bridge`、JNI 与 Go 导出函数的分界 | +| [Go 包布局](native-layout.md) | `golang/native` 各目录做什么 | +| [Kotlin 模型](models.md) | `model` 包的公开类型 | + +源码位置仍以 [README](../README.md) 为准。 diff --git a/core/docs/bridge.md b/core/docs/bridge.md new file mode 100644 index 0000000000..8ff489214a --- /dev/null +++ b/core/docs/bridge.md @@ -0,0 +1,130 @@ +--- +title: "core 桥接边界" +type: architecture +status: accepted +phase: N/A +updated: 2026-10-15 +summary: "Clash 与 Bridge 暴露的调用,以及 JNI 如何接到 Go native" +--- + +# core 桥接边界 + +> 适用模块:`core` +> Go 包职责:[native-layout.md](native-layout.md) +> 运行时角色:[运行时](../../docs/systems/runtime/INDEX.md) + +`core` 对上层只暴露两个 Kotlin 单例。`Clash` 把参数收成模型或回调。`Bridge` 是 JNI。C 侧把 Java 调用转成 cgo 导出函数,再把 Go 需要的回调收成函数指针。 + +## 两个本地库 + +`Bridge` 的 `init` 调用 `System.loadLibrary("bridge")`。CMake 目标 `bridge` 由 `main.c`、`jni_helper.c`、`bridge_helper.c` 组成,并链接 `log` 和 `clash`(`core/src/main/cpp/CMakeLists.txt`)。`core/build.gradle.kts` 把 Go 产物文件名设为 `libclash.so`,包名 `cfa/native`。`main.c` 包含构建产出的 `libclash.h`,调用的是 Go `//export` 函数,而不是在 C 里实现核心逻辑。 + +怎么编、子模块 pin 见 [构建指南](../../docs/guides/getting-started/build.md)。 + +## 调用方向 + +``` +Clash / 直接调用 Bridge + │ external fun + ▼ +libbridge.so JNI:Java_com_github_kr328_clash_core_bridge_Bridge_native* + │ coreInit / reset / startTun / … + ▼ +libclash.so package main 的 //export + │ + ▼ +cfa/native/* 再调用 mihomo +``` + +反向回调不走新的 JNI 方法名。`JNI_OnLoad` 缓存 `TunInterface`、`FetchCallback`、`LogcatInterface`、`CompletableDeferred`、`ClashException`、`Content.open` 的 method ID,并把实现赋给 `native/bridge.h` 里的函数指针。Go 通过 cgo 调用 `mark_socket`、`query_socket_uid`、`complete`、`fetch_report`、`fetch_complete`、`logcat_received`、`open_content`、`release_object`。这些符号的 C 实现在 `native/bridge.c`,里面再调用 `JNI_OnLoad` 装上的函数指针。`jni_helper.h` 的 `ATTACH_JNI` 在当前线程没有 JNIEnv 时 `AttachCurrentThread`,离开作用域再按需 detach。 + +`bridge.h` 里还有 `log_info` / `log_error` / `log_warn` / `log_debug` / `log_verbose`,转到 Android log,标签 `ClashMetaForAndroid`。 + +字符串用 `String.getBytes()` 和 `String(byte[])` 传递,没有在 C 里指定 charset。`scoped_string` 在作用域结束时 `free`。Go 回调带上的 C 字符串由 `bridge.c` 在调用函数指针之后释放。 + +`trace.h` 里 `ENABLE_TRACE` 为 0,`TRACE_METHOD` 是空宏。 + +## 初始化 + +`Bridge` 加载库之后做三件事: + +1. 以只读方式打开 `packageCodePath`,`detachFd()` 的返回值被丢掉。 +2. 把 `filesDir/clash` 建出来,作为 home。 +3. 调用 `nativeInit(home, versionName, SDK_INT)`。`versionName` 缺失时用 `"unknown"`。 + +`nativeInit` 另外把 CMake 生成的 `GIT_VERSION` 传给 `coreInit`。`version.h` 来自 `version.h.in`。CMake 用子模块的 `git log -1` 取 7 位提交、`git branch -r --contains` 取远程分支名,再拼上 `%y%m%d`,中间用 `_` 连接并去掉空格。检出约束见构建指南里的 CMake 说明,这里不重复。 + +`nativeCoreVersion` 直接返回同一条 `GIT_VERSION`,不经过 Go。`Clash` 没有包装它;`app` 的 `MainActivity` 直接调 `Bridge.nativeCoreVersion()`。 + +`coreInit` 进入 `delegate.Init` 后立刻 `reset()`。`reset` 加载空配置、清流量统计、关掉全部连接,并做一次 GC。细节见 [native-layout.md](native-layout.md)。 + +## Clash 对上层暴露什么 + +`Clash` 是普通 Kotlin 调用。跨边界的结构化数据几乎都是 JSON 字符串。流量是例外,见下一节。 + +| 分组 | `Clash` | 过 JNI 之后 | +|:-----|:--------|:------------| +| 生命周期 | `reset`、`forceGc`、`suspendCore` | `reset`、`forceGc`、`suspend` | +| 状态 | `queryTunnelState`、`queryTrafficNow`、`queryTrafficTotal` | JSON `mode`;流量是两个 `uint64` | +| 通知 | `notifyDnsChanged`、`notifyTimeZoneChanged`、`notifyInstalledAppsChanged` | 字符串或 `(name, offset)` | +| 隧道与 HTTP | `startTun`、`stopTun`、`startHttp`、`stopHttp` | fd、栈名、地址串;HTTP 返回实际监听地址或 null | +| 代理组 | `queryGroupNames`、`queryGroup`、`healthCheck`、`healthCheckAll`、`patchSelector` | 组名 JSON;排序用 `ProxySort.name` | +| 配置 | `fetchAndValid`、`load`、`queryProviders`、`updateProvider` | 路径、URL、provider 类型名 | +| 覆盖 | `queryOverride`、`patchOverride`、`clearOverride` | slot 用枚举 `ordinal` | +| 界面配置 | `queryConfiguration` | 见下面的空对象 | +| 日志 | `subscribeLogcat` | 返回 `Channel(32)` | +| age | `setAgeSecretKey`、`genX25519KeyPair`、`genHybridKeyPair`、`veritySecretKeys`、`toPublicKeys`、`verityPublicKeys` | 密钥字符串或 JSON | + +通知的编码: + +- DNS:去重后用逗号拼接。 +- 已安装应用:`uid:包名`,逗号拼接。 +- 覆盖槽:`OverrideSlot.Persist`、`Session` 的序数与 Go `OverrideSlotPersist`、`OverrideSlotSession` 的 iota 对齐,都是 0 和 1。 +- Provider 更新:`Provider.Type.toString()`,Go 只认 `"Proxy"` 和 `"Rule"`。 +- 组排序:`ProxySort` 的 `Title`、`Delay`;其他字符串在 Go 里落到 `Default`。 + +`startTun` 把 `markSocket` 和 `querySocketUid` 收成 `TunInterface`。UID 查询的地址是字符串,`Clash` 用 `parseInetSocketAddress` 转成 `InetSocketAddress`(按 `https://<地址>` 解析 host 和 port)。 + +异步完成: + +- `load`、`healthCheck`、`updateProvider` 新建 `CompletableDeferred` 交给 native。成功走 `complete(Unit)`,失败走 `completeExceptionally(ClashException)`。 +- `fetchAndValid` 用 `FetchCallback`:`report` 是 `FetchStatus` JSON,`complete` 的 error 为 null 表示成功。 +- `subscribeLogcat` 把 `received` 的 JSON 解成 `LogMessage` 再 `trySend`。 + +`queryGroup` 得到 null 时,`Clash` 返回 `ProxyGroup("Unknown", emptyList(), "")`。`queryOverride` 解码失败时返回空的 `ConfigurationOverride()`。 + +`queryConfiguration` 在 Go 里编码一个空结构体,也就是 `{}`。`UiConfiguration` 没有任何属性,解码结果是空对象,不是当前已加载的配置。 + +`veritySecretKeys`、`verityPublicKeys`、`toPublicKeys` 的 Kotlin 签名是 `vararg`,但只把第一个字符串传过 JNI;没有参数时传空串。这三处以及 Go、JNI 的名字都是 `verity`,不是 `verify`。`setAgeSecretKey(null)` 在 JNI 里把 NULL 传给 Go,Go 调用不带参数的 `SetGlobalSecretKeys`。 + +## 流量字 + +`nativeQueryTrafficNow` 和 `nativeQueryTrafficTotal` 返回一个 `jlong`。高 32 位是上传,低 32 位是下载。每一侧先经过 `bridge_helper.c` 的 `down_scale_traffic`:数值放在低 30 位,单位放在 bit 30–31(0 字节、1 KiB、2 MiB、3 GiB),超过字节时存的是百分之一单位。Kotlin 的 `Traffic` 是 `Long` 的 typealias。`util/Traffic.kt` 按这个布局还原后再格式化。 + +## 模型 + +`model/` 里和这条桥直接对应的 JSON 类型: + +| 类型 | 方向 | +|:-----|:-----| +| `TunnelState` | `mode`:`direct` / `global` / `rule` / `script` | +| `Proxy`、`ProxyGroup` | 查询组 | +| `ProxySort` | 只在 Kotlin 侧,过桥时变成枚举名 | +| `Provider` | `name`、`type`、`vehicleType`、`updatedAt`(毫秒) | +| `FetchStatus` | `FetchConfiguration`、`FetchProviders`、`SubscriptionInfo`、`Verifying` | +| `LogMessage` | `level`、`message`、`time`;`time` 经 `DateSerializer` 按 `java.util.Date` 的毫秒读 | +| `ConfigurationOverride` | 覆盖 JSON,字段名对齐 mihomo raw config | +| `AgeKeyPair` | `secretKey`、`publicKey` | +| `UiConfiguration` | 空类型,见上 | + +`ProviderList` 和 `ProxyGroup.SliceProxyList` 只是 Parcelable 切片,不参与 JNI。`Clash` 自己不做 Parcel。 + +`core` 的 manifest 只声明 `INTERNET`。 + +## 边界上容易看错的地方 + +- `startTun` 的 Go 返回值是 `C.int`(失败为 1)。JNI 声明是 `void`,不把这个值返回给 Kotlin。失败时 Go 会关掉刚建的回调。 +- `load`、`fetchAndValid`、`updateProvider` 完成后调用 `release_object` 删掉 global ref。`healthCheck` 只 `complete`,源码里没有 `release_object`。`subscribeLogcat` 仅在 `logcat_received` 返回非 0(JNI 抛异常)时释放并退出订阅循环。 +- `suspendCore` 会进到 Go,但 `tunnel.Suspend` 的函数体是空的,注释写明不要调用 mihomo 的 `OnSuspend` / `OnRunning`。 +- `Content.open` 只接受 `content:` URI,把只读 fd 交给 Go。其他 scheme 在 Kotlin 抛 `UnsupportedOperationException`。 +- `Bridge`、`TunInterface`、`FetchCallback`、`LogcatInterface`、`ClashException`、`Content` 都标了 `@Keep`。 diff --git a/core/docs/models.md b/core/docs/models.md new file mode 100644 index 0000000000..05462c5d6c --- /dev/null +++ b/core/docs/models.md @@ -0,0 +1,300 @@ +--- +title: "Kotlin 模型" +type: architecture +status: accepted +phase: N/A +updated: 2026-10-15 +summary: "model 包的公开类型:字段、枚举,以及 Clash 与 Go 各在桥的哪一侧使用" +--- + +# Kotlin 模型 + +> 适用模块:`core` +> JNI 分界:[bridge.md](bridge.md) +> Go 包:[native-layout.md](native-layout.md) +> 谁加载这条桥:[运行时](../../docs/systems/runtime/INDEX.md) + +公开类型都在 `com.github.kr328.clash.core.model`。目录里是 12 个文件。嵌套类型写在所属文件里,没有额外文件。 + +过桥的调用在 `Clash`。JNI 方法名、回调和流量打包不在这里重复。Go 侧没有一份同名模型包,JSON 由 `native` 里的函数现编。 + +带 `@Serializable` 的是 `AgeKeyPair`、`ConfigurationOverride` 及其嵌套类型、`FetchStatus`、`LogMessage`(含 `Level`)、`Provider`、`Proxy`、`ProxyGroup`、`TunnelState`(含 `Mode`)、`UiConfiguration`。`FetchStatus.Action`、`Provider.Type`、`Provider.VehicleType` 没有这个注解。`ProxySort`、`Traffic`、`ProviderList`、`SliceProxyList` 不编 JSON。 + +`AgeKeyPair`、`ProxySort`、`Traffic` 不能 Parcel。其余顶层类型可以。`Clash` 自己不做 Parcel。 + +## 桥的两侧 + +| 类型 | Kotlin 侧 | Go 侧 | +|:-----|:----------|:------| +| `AgeKeyPair` | `genX25519KeyPair`、`genHybridKeyPair` 解码 | `package main` 的 `ageKeyPair` | +| `ConfigurationOverride` | `queryOverride`、`patchOverride` | `config.ReadOverride`、`WriteOverride` 存整段 JSON | +| `FetchStatus` | `fetchAndValid` 的 `report` | `config.Status` | +| `LogMessage` | `subscribeLogcat` | `log.go` 的 `message` | +| `Provider` | `queryProviders`、`updateProvider` | `tunnel.Provider` | +| `ProviderList` | 不进 JNI | 无 | +| `Proxy` | `queryGroup` 解出的元素 | `tunnel.Proxy` | +| `ProxyGroup` | `queryGroup` | `tunnel.ProxyGroup` | +| `ProxySort` | `queryGroup` 只传 `name` | 不接收这个枚举 | +| `Traffic` | `queryTrafficNow`、`queryTrafficTotal` | `tunnel.Now`、`tunnel.Total` | +| `TunnelState` | `queryTunnelState` | `queryTunnelState` 只写 `mode` | +| `UiConfiguration` | `queryConfiguration` | `queryConfiguration` 编码空对象 | + +上表的 Kotlin 侧都是 `Clash` 的函数。过 JNI 的方式见 [bridge.md](bridge.md)。 + +## AgeKeyPair + +一对 age 密钥。字段只有 `secretKey` 和 `publicKey`,都是 `String`。 + +只有生成密钥对的两条返回这个类型。校验和导出公钥走的是字符串,见 [bridge.md](bridge.md)。 + +## ConfigurationOverride + +一份覆盖 JSON。属性名和 `@SerialName` 都抄自这个类。Go 不声明同名结构体,整段字符串放进覆盖槽。贴进 raw config 的顺序见 [native-layout.md](native-layout.md)。槽位序数、解码失败时退回空对象,见 [bridge.md](bridge.md)。 + +`Clash` 编解码它时用单独的 `Json`:`ignoreUnknownKeys = true`,`encodeDefaults = false`。 + +可空属性默认 `null`。`externalControllerCors`、`dns`、`app`、`sniffer`、`geoxurl` 不是可空。后四个是 `val`,构造时就是空对象。 + +| 属性 | JSON | 类型 | +|:-----|:-----|:-----| +| `httpPort` | `port` | `Int?` | +| `socksPort` | `socks-port` | `Int?` | +| `redirectPort` | `redir-port` | `Int?` | +| `tproxyPort` | `tproxy-port` | `Int?` | +| `mixedPort` | `mixed-port` | `Int?` | +| `authentication` | `authentication` | `List?` | +| `allowLan` | `allow-lan` | `Boolean?` | +| `bindAddress` | `bind-address` | `String?` | +| `mode` | `mode` | `TunnelState.Mode?` | +| `logLevel` | `log-level` | `LogMessage.Level?` | +| `ipv6` | `ipv6` | `Boolean?` | +| `externalController` | `external-controller` | `String?` | +| `externalControllerTLS` | `external-controller-tls` | `String?` | +| `externalControllerCors` | `external-controller-cors` | `ExternalControllerCors` | +| `secret` | `secret` | `String?` | +| `hosts` | `hosts` | `Map?` | +| `unifiedDelay` | `unified-delay` | `Boolean?` | +| `geodataMode` | `geodata-mode` | `Boolean?` | +| `tcpConcurrent` | `tcp-concurrent` | `Boolean?` | +| `findProcessMode` | `find-process-mode` | `FindProcessMode?` | +| `dns` | `dns` | `Dns` | +| `app` | `clash-for-android` | `App` | +| `sniffer` | `sniffer` | `Sniffer` | +| `geoxurl` | `geox-url` | `GeoXUrl` | + +### Dns + +`fallbackFilter` 是 `val`,默认 `DnsFallbackFilter()`。其余属性是 `var`,默认可空。 + +| 属性 | JSON | 类型 | +|:-----|:-----|:-----| +| `enable` | `enable` | `Boolean?` | +| `preferH3` | `prefer-h3` | `Boolean?` | +| `listen` | `listen` | `String?` | +| `ipv6` | `ipv6` | `Boolean?` | +| `useHosts` | `use-hosts` | `Boolean?` | +| `enhancedMode` | `enhanced-mode` | `DnsEnhancedMode?` | +| `nameServer` | `nameserver` | `List?` | +| `fallback` | `fallback` | `List?` | +| `defaultServer` | `default-nameserver` | `List?` | +| `fakeIpFilter` | `fake-ip-filter` | `List?` | +| `fakeIPFilterMode` | `fake-ip-filter-mode` | `FilterMode?` | +| `fallbackFilter` | `fallback-filter` | `DnsFallbackFilter` | +| `nameserverPolicy` | `nameserver-policy` | `Map?` | + +### DnsFallbackFilter + +| 属性 | JSON | 类型 | +|:-----|:-----|:-----| +| `geoIp` | `geoip` | `Boolean?` | +| `geoIpCode` | `geoip-code` | `String?` | +| `ipcidr` | `ipcidr` | `List?` | +| `domain` | `domain` | `List?` | + +### App + +只有 `appendSystemDns`(JSON `append-system-dns`,`Boolean?`)。 + +### FindProcessMode + +| 常量 | JSON | +|:-----|:-----| +| `Off` | `off` | +| `Strict` | `strict` | +| `Always` | `always` | + +### DnsEnhancedMode + +`None` 的 JSON 名是 `normal`,不是 `none`。`Mapping` 是 `redir-host`。 + +| 常量 | JSON | +|:-----|:-----| +| `None` | `normal` | +| `Mapping` | `redir-host` | +| `FakeIp` | `fake-ip` | + +### FilterMode + +| 常量 | JSON | +|:-----|:-----| +| `BlackList` | `blacklist` | +| `WhiteList` | `whitelist` | +| `Rule` | `rule` | + +### Sniffer + +`sniff` 是 `val`,默认 `Sniff()`。 + +| 属性 | JSON | 类型 | +|:-----|:-----|:-----| +| `enable` | `enable` | `Boolean?` | +| `sniff` | `sniff` | `Sniff` | +| `forceDnsMapping` | `force-dns-mapping` | `Boolean?` | +| `parsePureIp` | `parse-pure-ip` | `Boolean?` | +| `overrideDestination` | `override-destination` | `Boolean?` | +| `forceDomain` | `force-domain` | `List?` | +| `skipDomain` | `skip-domain` | `List?` | +| `skipSrcAddress` | `skip-src-address` | `List?` | +| `skipDstAddress` | `skip-dst-address` | `List?` | + +### Sniff + +三个属性默认都是 `ProtocolConig()`。类名是 `ProtocolConig`,源码如此。 + +| 属性 | JSON | 类型 | +|:-----|:-----|:-----| +| `http` | `HTTP` | `ProtocolConig` | +| `tls` | `TLS` | `ProtocolConig` | +| `quic` | `QUIC` | `ProtocolConig` | + +### ProtocolConig + +| 属性 | JSON | 类型 | +|:-----|:-----|:-----| +| `ports` | `ports` | `List?` | +| `overrideDestination` | `override-destination` | `Boolean?` | + +### GeoXUrl + +| 属性 | JSON | 类型 | +|:-----|:-----|:-----| +| `geoip` | `geoip` | `String?` | +| `mmdb` | `mmdb` | `String?` | +| `geosite` | `geosite` | `String?` | + +### ExternalControllerCors + +| 属性 | JSON | 类型 | +|:-----|:-----|:-----| +| `allowOrigins` | `allow-origins` | `List?` | +| `allowPrivateNetwork` | `allow-private-network` | `Boolean?` | + +## FetchStatus + +拉取配置时的一条进度。Go 侧字段同名,定义是 `config.Status`。什么时候上报哪个 `action`,见 [native-layout.md](native-layout.md)。 + +| 属性 | 类型 | +|:-----|:-----| +| `action` | `Action` | +| `args` | `List` | +| `progress` | `Int` | +| `max` | `Int` | +| `subUpload` | `Long?` | +| `subDownload` | `Long?` | +| `subTotal` | `Long?` | +| `subExpire` | `Long?` | +| `subUpdateInterval` | `Long?` | + +订阅相关的五个 `Long?` 默认 `null`。 + +`Action`:`FetchConfiguration`、`FetchProviders`、`SubscriptionInfo`、`Verifying`。没有 `@SerialName`,JSON 用枚举名。 + +## LogMessage + +一条核心日志。字段是 `level`、`message`、`time`。`time` 的类型是 `Date`。毫秒怎么读写见 [bridge.md](bridge.md)。 + +`Level` 的 JSON 名: + +| 常量 | JSON | +|:-----|:-----| +| `Debug` | `debug` | +| `Info` | `info` | +| `Warning` | `warning` | +| `Error` | `error` | +| `Silent` | `silent` | +| `Unknown` | `unknown` | + +## Provider + +一个 proxy 或 rule provider。`compareTo` 先比 `type`,再比 `name`。 + +| 属性 | 类型 | +|:-----|:-----| +| `name` | `String` | +| `type` | `Type` | +| `vehicleType` | `VehicleType` | +| `updatedAt` | `Long` | + +`updatedAt` 的单位见 [bridge.md](bridge.md)。 + +`Type`:`Proxy`、`Rule`。`updateProvider` 把 `Type.toString()` 送过桥。Go 只认这两个名字,见 [bridge.md](bridge.md)。 + +`VehicleType`:`HTTP`、`File`、`Inline`、`Compatible`。两个枚举都没有 `@SerialName`。列出时跳过 `Compatible`,见 [native-layout.md](native-layout.md)。 + +## ProviderList + +`List` 的 Parcel 包装。不参与 JNI。`Clash.queryProviders` 返回的是普通列表。 + +从 Parcel 恢复时按块读,块大小是 20。 + +## Proxy + +组里的一个节点。 + +| 属性 | 类型 | +|:-----|:-----| +| `name` | `String` | +| `title` | `String` | +| `subtitle` | `String` | +| `type` | `String` | +| `delay` | `Int` | +| `isGroup` | `Boolean` | + +`isGroup` 是 `var`。其余是 `val`。 + +## ProxyGroup + +一个代理组。JSON 字段是 `type`、`proxies`、`now`。`proxies` 的元素是 `Proxy`。 + +`queryGroup` 得到 null 时,`Clash` 返回的占位值见 [bridge.md](bridge.md)。 + +`SliceProxyList` 只用于 Parcel,不进 JSON。它按块读 `Proxy`,块大小是 50。`writeToParcel` 先写 `type`,再写这个切片,再写 `now`。 + +## ProxySort + +只在 Kotlin 侧。常量是 `Default`、`Title`、`Delay`。 + +`queryGroup` 把 `name` 传过桥。Go 怎么把字符串收成排序,见 [bridge.md](bridge.md)。 + +## Traffic + +`typealias Traffic = Long`。没有自己的字段。 + +高低位怎么装进这一个 `Long`,见 [bridge.md](bridge.md)。 + +## TunnelState + +当前隧道模式。只有字段 `mode`。 + +Go 写入 `tunnel.Mode().String()`。Kotlin 按下面的 JSON 名解码。 + +| 常量 | JSON | +|:-----|:-----| +| `Direct` | `direct` | +| `Global` | `global` | +| `Rule` | `rule` | +| `Script` | `script` | + +## UiConfiguration + +没有属性。`queryConfiguration` 解出来的是空对象,不是当前已加载的配置。见 [bridge.md](bridge.md)。 diff --git a/core/docs/native-layout.md b/core/docs/native-layout.md new file mode 100644 index 0000000000..b1c284a4a2 --- /dev/null +++ b/core/docs/native-layout.md @@ -0,0 +1,158 @@ +--- +title: "golang/native 包布局" +type: architecture +status: accepted +phase: N/A +updated: 2026-10-15 +summary: "Android 桥的 Go 包:app、config、tun、tunnel、platform,不是 mihomo 本体" +--- + +# golang/native 包布局 + +> 适用模块:`core` +> JNI 与 `Clash`:[bridge.md](bridge.md) +> 运行时角色:[运行时](../../docs/systems/runtime/INDEX.md) + +`core/src/main/golang/native` 是 Android 桥。它把 cgo 导出收成对 mihomo 的少量调用:加载配置、TUN fd、查询代理和流量、HTTP 入站、平台回调。协议实现在子模块 `core/src/foss/golang/clash`,这里不按协议罗列。 + +`go.mod` 的 module 是 `cfa`。`github.com/metacubex/mihomo` 被 replace 到 `../../foss/golang/clash`。同一文件还有一行把 `google.golang.org/protobuf` replace 到 `github.com/metacubex/protobuf-go`。版本 pin、构建 tag 和命令见 [构建指南](../../docs/guides/getting-started/build.md)。`core/build.gradle.kts` 把这个目录的包名设为 `cfa/native`,alpha 与 meta 的 tags 都是 `foss`、`with_gvisor`、`cmfa`。 + +根目录的 Go 文件是 `package main`。`main` 直接 `panic("Stub!")`,真正入口是 `//export`。 + +## 目录 + +| 路径 | 包 | 职责 | +|:-----|:---|:-----| +| `native/*.go` | `main` | cgo 导出,把 C 字符串转给子包 | +| `native/bridge.c`、`bridge.h` | C | 函数指针,见 [bridge.md](bridge.md) | +| `native/trace.c`、`trace.h` | C | `ENABLE_TRACE` 为 0 | +| `native/app` | `app` | 版本、DNS、时区、已装应用、套接字回调、`content:` fd、副标题正则 | +| `native/config` | `config` | 拉取、修补、校验、加载配置;覆盖槽;age 密钥 | +| `native/tun` | `tun` | 用已有 fd 启动 sing-tun | +| `native/tunnel` | `tunnel` | 模式、流量、代理组、健康检查、provider、连接 | +| `native/proxy` | `proxy` | 本机 HTTP 入站 | +| `native/platform` | `platform` | Linux/Android:fd 上限、旧系统的 `/proc/net` UID | +| `native/delegate` | `delegate` | `Init`:home、版本号、包名解析、拨号 hook | +| `native/common` | `common` | 把 provider 路径收成相对根的形式 | +| `native/all` | `all` | 空白导入;`native/` 内没有其他文件引用它 | +| `native/debug.go` | `main` | 仅 `// +build debug`;默认 tags 不含 `debug` | + +`package main` 直接导入 `app`、`config`、`delegate`、`proxy`、`tun`、`tunnel`。`platform` 由 `delegate` 和 `app` 导入,`common` 由 `config` 导入。 + +## package main + +| 文件 | 导出 | 转到 | +|:-----|:-----|:-----| +| `main.go` | `coreInit`、`reset`、`forceGc` | `delegate.Init`;`config.LoadDefault`、清统计、关连接;异步 GC | +| `app.go` | `notifyDnsChanged`、`notifyInstalledAppsChanged`、`notifyTimeZoneChanged`、`queryConfiguration` | `app`;最后一项编码空结构体 | +| `config.go` | `fetchAndValid`、`load`、覆盖槽读写、age 密钥 | `config`,异步完成回调 | +| `tun.go` | `startTun`、`stopTun` | `tun.Start`,并把 mark / UID 回调装进 `app` | +| `tunnel.go` | 状态、流量、组、健康检查、selector、provider、`suspend` | `tunnel` | +| `proxy.go` | `startHttp`、`stopHttp` | `proxy` | +| `log.go` | `subscribeLogcat` | mihomo `log.Subscribe` | +| `utils.go` | 无导出 | `marshalJson`、`marshalString`(`error` 取 `Error()`,`nil` 得到 C NULL) | + +`log.go` 的 `init` 另起一个永远不退的订阅,把 mihomo 日志送到 `log_info` 等 Android log。`subscribeLogcat` 再开一条订阅:低于当前日志级别的行丢掉,除非正文以 `[APP]` 开头。JSON 字段是 `level`、`message`、`time`。`time` 是 `UnixNano / 1000 / 1000`,即毫秒。 + +`debug.go` 在 `debug` tag 下监听 `0.0.0.0:8888` 的 pprof。当前 flavor tags 不会编进它。 + +`startTun` 用权重为 4 的信号量包住回调,避免 `close` 与仍在进行的 mark / UID 查询交叉。`portal` 只出现在调试日志里,`tun.Start` 没有使用它。失败返回 1,JNI 不把这个整数交回 Kotlin。 + +`queryGroup` 只把 `"Title"`、`"Delay"` 映射成排序模式。`healthCheck` 在 goroutine 里同步等健康检查结束后 `complete`。`updateProvider` 把错误字符串交给 `complete`。 + +## app + +没有包注释。状态是包级变量。 + +| 文件 | 行为 | +|:-----|:-----| +| `app.go` | 保存 versionName 和 SDK 整数。`NotifyInstallAppsChanged` 解析 `uid:包名`。`NotifyTimeZoneChanged` 把 `time.Local` 设成 `FixedZone` | +| `dns.go` | `NotifyDnsChanged` 把逗号分隔的地址交给 `dns.UpdateSystemDNS`,再 `FlushCacheWithDefaultResolver` | +| `tun.go` | `ApplyTunContext` 安装 mark 和 UID 回调;未安装时 mark 为空函数,UID 返回 -1。`QuerySocketUid` 按源地址网络选 TCP 或 UDP。SDK 低于 29 时改走 `platform.QuerySocketUidFromProcFs`,不调用 Java | +| `content.go` | `OpenContent` 经注入的函数拿 fd,设非阻塞后包成 `*os.File`。默认实现返回 `"not implement"`。`app.go` 的 `init` 注入 `openRemoteContent`,后者调用 C 的 `open_content` | +| `ui.go` | 编译 `ui-subtitle-pattern`。失败则清掉正则并打警告 | + +## config + +加载一条配置要经过固定修补链。`process` 按这个顺序调用,注释要求 `patchExternalController` 在 `patchOverride` 之前,这样只有覆盖 JSON 里的外部控制器会留下: + +| 顺序 | 函数 | 对 raw config 做什么 | +|:-----|:-----|:---------------------| +| 1 | `patchExternalController` | 清空 `ExternalController` 和 `ExternalControllerTLS` | +| 2 | `patchOverride` | 先解码持久覆盖,再解码会话覆盖。失败只打日志 | +| 3 | `patchGeneral` | 清空 `Interface`,`RoutingMark` 置 0。若外部控制器非空,`ExternalUI` 设为 `/ui` | +| 4 | `patchProfile` | `StoreSelected = false`,`StoreFakeIP = true` | +| 5 | `patchDns` | DNS 未启用时换成默认 DNS 并打开 fake-ip,同时 `AppendSystemDNS = true`。需要时在 nameserver 末尾加 `system://` | +| 6 | `patchTun` | `Enable`、`AutoRoute`、`AutoDetectInterface` 都设为 false | +| 7 | `patchListeners` | 丢掉 `tproxy`、`redir`、`tun` | +| 8 | `patchProviders` | 路径收到 `/providers/` 下。无 path 但有 url 时用 url 的哈希做文件名;两者都空则当作内联 provider 跳过 | +| 9 | `validConfig` | 必须有 `proxies` 或 `proxy-providers`。`ui-subtitle-pattern` 必须能编译 | + +DNS 未启用时写入的 nameserver 是 `223.5.5.5`、`119.29.29.29`、`8.8.4.4`、`1.0.0.1`,fake-ip 范围是 `28.0.0.0/8`,过滤器是 `defaults.go` 里的那组域名。 + +| 文件 | 行为 | +|:-----|:-----| +| `load.go` | 读 `/config.yaml`,修补后 `ParseRawConfig`,`hub.ApplyConfig`,再把副标题正则交给 `app`。`LoadDefault` 用空字节解析并应用 | +| `fetch.go` | `http`/`https` 用 mihomo HTTP 客户端,User-Agent 为 `ClashMetaForAndroid/` 加 versionName,超时 60 秒。`content` 走 `app.OpenContent`。本地已有 `config.yaml` 且 `force` 为 false 时不重新下载。接着按 provider 拉缺失文件,规则 provider 若带 `path-in-bundle` 则用 `rules/bundle.Open` 写出。最后解析并 `Close` provider,不 `ApplyConfig`。进度 JSON 的 `action` 为 `FetchConfiguration`、`FetchProviders`、`SubscriptionInfo`、`Verifying` | +| `override.go` | 持久槽是 home 下的 `override.json`(`constant.Path.Resolve`),模式 `0600`。会话槽是内存字符串。缺省都是 `{}` | +| `age.go` | 转到 `mihomo/component/age` 的同名函数 | +| `provider.go` | 遍历 proxy / rule provider;`destroyProviders` 关闭实现了 `io.Closer` 的 provider | + +`FetchAndValid` 在验证阶段把进度写成 `0xffff`。订阅头 `subscription-userinfo` 和 `profile-update-interval` 放进 `SubscriptionInfo`。更新间隔按小时解析,再换成毫秒;大于 0 且小于 15 分钟时抬到 15 分钟。 + +## tunnel + +查询和改选择都走 mihomo 当前隧道里的代理表,不自己保存一份。 + +| 文件 | 行为 | +|:-----|:-----| +| `state.go` | `QueryMode` 返回 `tunnel.Mode().String()` | +| `statistic.go` | `Now`、`Total`、`ResetStatistic` 转到 `statistic.DefaultManager` | +| `suspend.go` | 空函数。注释写明调用方来自亮屏/灭屏,但不能调用核心的 `OnSuspend` / `OnRunning`,否则锁屏后不再处理新连接 | +| `conn.go` | `CloseAllConnections` 关闭统计管理器里的连接。`closeConnByGroup` 关掉链上出现该名字的连接 | +| `connectivity.go` | 对一个代理组的每个 provider 做 `HealthCheck` 并等待。`HealthCheckAll` 为 `QueryProxyGroupNames(false)` 的每个名字各起一个 goroutine | +| `providers.go` | 列出规则和代理 provider,跳过 `Compatible`。`UpdatedAt` 是毫秒。更新只接受类型名 `Rule` 或 `Proxy` | +| `proxies.go` | 组列表与排序,见下 | +| `loopback.go` | 只声明 `127.0.0.1`。包内没有引用 | + +`QueryProxyGroupNames`:模式是 direct 时返回空切片。从名为 `GLOBAL` 的组的第一个 provider 取代理,只保留代理组,并跳过 `Hidden()`。`excludeNotSelectable` 为真时再要求类型是 `Selector`。模式是 global 时在列表前加 `"GLOBAL"`。 + +`QueryProxyGroup` 用该组的 `Proxies()`,不是 `collectProviders`(那次调用在源码里被注释掉)。`Title` 按标题字符串排序,`Delay` 按延迟排序。非组节点若匹配副标题正则,匹配段变成 `subtitle`,其余变成 `title`。延迟取该节点 `ExtraDelayHistories` 里第一个非空测试 URL,没有则用 `https://www.gstatic.com/generate_204`。 + +`PatchSelector` 要求目标实现 `SelectAble`。`Set` 失败只打日志,函数仍然返回 true,并按组名关连接。找不到或类型不对才返回 false。 + +## tun + +`tun.Start` 把栈名用 `C.StackTypeMapping` 转成 mihomo 栈,未知名字用 `C.TunSystem`。`gateway` 是逗号分隔的前缀(注释示例 `172.19.0.1/30` 或再加一个 IPv6 前缀),按地址族放进 `Inet4Address` / `Inet6Address`。`dns` 是逗号分隔的地址,每个加上端口 53 放进 `DNSHijack`。 + +监听选项里 `Enable` 为 true,`Device` 为 `sing_tun.InterfaceName`,`FileDescriptor` 为传入的 fd,`MTU` 为 9000。`AutoRoute` 和 `AutoDetectInterface` 为 false;源码注释分别指向 `TunService.kt` 里设置的路由和 `VpnService` 的 protect。然后 `sing_tun.New(options, tunnel.Tunnel)`。 + +这与 `config.patchTun` 不冲突:配置文件里的 tun 段被关掉,运行中的 TUN 只来自这条 fd。 + +## proxy + +`proxy.Start` 在互斥锁里先停掉旧的 `http.Listener`,再 `http.NewWithAuthenticate(listen, tunnel.Tunnel, false)`。成功时返回 `listener.Address()`。`Stop` 关闭并清空监听器。 + +## platform + +两个文件都带 `// +build linux`。 + +`limit.go` 打开 `/dev/null`,把 `RLIMIT_NOFILE` 的软限制乘 3/4 当作 `maxFdCount`(读不到限制时用 1024)。`ShouldBlockConnection` 对这个 fd 做 `Dup`:dup 失败,或新 fd 大于 `maxFdCount`,则返回 true。dup 出的 fd 会关掉。 + +`procfs.go` 读 `/proc/net/tcp` 的表头,记下 `local_address` 和 `uid` 的列号(表头若同时有 `tx_queue` 与 `rx_queue`,或同时有 `tr` 与 `tm->when`,后面的列号减一)。查询时按源地址在 `/proc/net/6` 和 IPv4 文件里找 UID,找不到返回 -1。 + +## delegate + +`Init` 是进程内只从 `coreInit` 进来的装配点: + +- `constant.SetHomeDir(home)`。 +- `gitVersion` 被 `_` 分成三段时,`constant.Version` 设为 `<分支小写>-<7位哈希>-CMFA-`,`BuildTime` 为第三段。否则 `Version` 就是原字符串。最后再整体转成小写。 +- 把 versionName 和 SDK 整数交给 `app`。 +- `process.DefaultPackageNameResolver` 用连接的源、目标地址查 UID,再查包名。地址缺失时返回 `process.ErrInvalidNetwork`。 +- `dialer.DefaultSocketHook` 先问 `platform.ShouldBlockConnection`,为真则返回 `"blocked"`。否则在 `Control` 里 `app.MarkSocket`。 + +## common 与 all + +`common.ResolveAsRoot` 按 `/` 拆开路径,丢掉空段和 `.`,`..` 弹出上一段,再拼回去。`patchProviders` 用它处理配置里已有的 path。 + +`all/imports.go` 空白导入 `app`、`common`、`config`、`delegate`、`platform`、`proxy`、`tun`、`tunnel`,以及 `golang.org/x/sync/semaphore` 和 `mihomo/log`。`native/` 下没有文件导入 `cfa/native/all`。上面各包的 `init` 是因为 `package main` 的导入链才会运行。 diff --git a/core/src/main/cpp/main.c b/core/src/main/cpp/main.c index 61e431c8a1..ef8f85af9b 100644 --- a/core/src/main/cpp/main.c +++ b/core/src/main/cpp/main.c @@ -109,7 +109,7 @@ Java_com_github_kr328_clash_core_bridge_Bridge_nativeNotifyInstalledAppChanged(J notifyInstalledAppsChanged(_uid_list); } -JNIEXPORT void JNICALL +JNIEXPORT jboolean JNICALL Java_com_github_kr328_clash_core_bridge_Bridge_nativeStartTun(JNIEnv *env, jobject thiz, jint fd, jstring stack, @@ -125,7 +125,10 @@ Java_com_github_kr328_clash_core_bridge_Bridge_nativeStartTun(JNIEnv *env, jobje scoped_string _dns = get_string(dns); jobject _interface = new_global(cb); - startTun(fd, _stack, _gateway, _portal, _dns, _interface); + if (startTun(fd, _stack, _gateway, _portal, _dns, _interface) != 0) + return JNI_FALSE; + + return JNI_TRUE; } JNIEXPORT void JNICALL @@ -353,7 +356,7 @@ Java_com_github_kr328_clash_core_bridge_Bridge_nativeReadOverride(JNIEnv *env, j return new_string(response); } -JNIEXPORT void JNICALL +JNIEXPORT jstring JNICALL Java_com_github_kr328_clash_core_bridge_Bridge_nativeWriteOverride(JNIEnv *env, jobject thiz, jint slot, jstring content) { @@ -361,7 +364,12 @@ Java_com_github_kr328_clash_core_bridge_Bridge_nativeWriteOverride(JNIEnv *env, scoped_string _content = get_string(content); - writeOverride(slot, _content); + scoped_string error = writeOverride(slot, _content); + + if (error == NULL) + return NULL; + + return new_string(error); } JNIEXPORT void JNICALL diff --git a/core/src/main/golang/native/config.go b/core/src/main/golang/native/config.go index 43e099b683..25ba61e225 100644 --- a/core/src/main/golang/native/config.go +++ b/core/src/main/golang/native/config.go @@ -51,14 +51,19 @@ func load(completable unsafe.Pointer, path C.c_string) { //export readOverride func readOverride(slot C.int) *C.char { - return C.CString(config.ReadOverride(config.OverrideSlot(slot))) + content, err := config.ReadOverride(config.OverrideSlot(slot)) + if err != nil { + return C.CString("") + } + + return C.CString(content) } //export writeOverride -func writeOverride(slot C.int, content C.c_string) { +func writeOverride(slot C.int, content C.c_string) *C.char { c := C.GoString(content) - config.WriteOverride(config.OverrideSlot(slot), c) + return marshalString(config.WriteOverride(config.OverrideSlot(slot), c)) } //export clearOverride diff --git a/core/src/main/golang/native/config/fetch.go b/core/src/main/golang/native/config/fetch.go index c89cf4a847..6dd18b3c24 100644 --- a/core/src/main/golang/native/config/fetch.go +++ b/core/src/main/golang/native/config/fetch.go @@ -84,19 +84,29 @@ func fetch(url *U.URL, file string) (fetchHeader, error) { func writeFile(file string, reader io.Reader) error { _ = os.MkdirAll(P.Dir(file), 0700) - f, err := os.OpenFile(file, os.O_WRONLY|os.O_TRUNC|os.O_CREATE, 0600) + tmp, err := os.CreateTemp(P.Dir(file), ".cmfa-*") if err != nil { return err } - defer f.Close() + tmpName := tmp.Name() + _, copyErr := io.Copy(tmp, reader) + closeErr := tmp.Close() + if copyErr != nil { + _ = os.Remove(tmpName) + return copyErr + } + if closeErr != nil { + _ = os.Remove(tmpName) + return closeErr + } - _, err = io.Copy(f, reader) - if err != nil { - _ = os.Remove(file) + if err = os.Rename(tmpName, file); err != nil { + _ = os.Remove(tmpName) + return err } - return err + return nil } func parseProfileUpdateInterval(value string) (int64, bool) { @@ -186,7 +196,7 @@ func FetchAndValid( return err } - forEachProviders(rawCfg, func(index int, total int, name string, provider map[string]any, prefix string) { + err = forEachProviders(rawCfg, func(index int, total int, name string, provider map[string]any, prefix string) error { bytes, _ := json.Marshal(&Status{ Action: "FetchProviders", Args: []string{name}, @@ -200,23 +210,25 @@ func FetchAndValid( p, pok := provider["path"] if !uok || !pok { - return + return nil } us, uok := u.(string) ps, pok := p.(string) if !uok || !pok { - return + return nil } - if _, err := os.Stat(ps); err == nil { - return + if !force { + if _, err := os.Stat(ps); err == nil { + return nil + } } url, err := U.Parse(us) if err != nil { - return + return err } if prefix == RULES { @@ -228,15 +240,22 @@ func FetchAndValid( if file, err := RB.Open(pib); err == nil { defer file.Close() if err := writeFile(ps, file); err == nil { - return + return nil } } } } } - _, _ = fetch(url, ps) + if _, err := fetch(url, ps); err != nil { + return err + } + + return nil }) + if err != nil { + return err + } bytes, _ := json.Marshal(&Status{ Action: "Verifying", diff --git a/core/src/main/golang/native/config/override.go b/core/src/main/golang/native/config/override.go index cf0ae94213..9425096d74 100644 --- a/core/src/main/golang/native/config/override.go +++ b/core/src/main/golang/native/config/override.go @@ -3,6 +3,7 @@ package config import ( "io" "os" + "path" "github.com/metacubex/mihomo/constant" ) @@ -23,39 +24,65 @@ func overridePersistPath() string { return constant.Path.Resolve("override.json") } -func ReadOverride(slot OverrideSlot) string { +func ReadOverride(slot OverrideSlot) (string, error) { switch slot { case OverrideSlotPersist: file, err := os.OpenFile(overridePersistPath(), os.O_RDONLY, 0600) if err != nil { - return defaultPersistOverride + return defaultPersistOverride, nil } + defer file.Close() buf, err := io.ReadAll(file) if err != nil { - return defaultPersistOverride + return "", err } - return string(buf) + return string(buf), nil case OverrideSlotSession: - return sessionOverride + return sessionOverride, nil } - return "" + return "", nil } -func WriteOverride(slot OverrideSlot, content string) { +func WriteOverride(slot OverrideSlot, content string) error { switch slot { case OverrideSlotPersist: - file, err := os.OpenFile(overridePersistPath(), os.O_WRONLY|os.O_TRUNC|os.O_CREATE, 0600) + dest := overridePersistPath() + tmp, err := os.CreateTemp(path.Dir(dest), "override-*.json") if err != nil { - return + return err } - _, err = file.Write([]byte(content)) + tmpName := tmp.Name() + payload := []byte(content) + n, werr := tmp.Write(payload) + cerr := tmp.Close() + if werr != nil { + _ = os.Remove(tmpName) + return werr + } + if n != len(payload) { + _ = os.Remove(tmpName) + return io.ErrShortWrite + } + if cerr != nil { + _ = os.Remove(tmpName) + return cerr + } + if err = os.Rename(tmpName, dest); err != nil { + _ = os.Remove(tmpName) + return err + } + + return nil case OverrideSlotSession: sessionOverride = content + return nil } + + return nil } func ClearOverride(slot OverrideSlot) { diff --git a/core/src/main/golang/native/config/process.go b/core/src/main/golang/native/config/process.go index 0b60b1c520..158306499d 100644 --- a/core/src/main/golang/native/config/process.go +++ b/core/src/main/golang/native/config/process.go @@ -31,10 +31,16 @@ var processors = []processor{ type processor func(cfg *config.RawConfig, profileDir string) error func patchOverride(cfg *config.RawConfig, _ string) error { - if err := json.NewDecoder(strings.NewReader(ReadOverride(OverrideSlotPersist))).Decode(cfg); err != nil { - log.Warnln("Apply persist override: %s", err.Error()) + content, err := ReadOverride(OverrideSlotPersist) + if err != nil { + return err } - if err := json.NewDecoder(strings.NewReader(ReadOverride(OverrideSlotSession))).Decode(cfg); err != nil { + if err := json.NewDecoder(strings.NewReader(content)).Decode(cfg); err != nil { + return err + } + + session, _ := ReadOverride(OverrideSlotSession) + if err := json.NewDecoder(strings.NewReader(session)).Decode(cfg); err != nil { log.Warnln("Apply session override: %s", err.Error()) } @@ -107,19 +113,19 @@ func patchListeners(cfg *config.RawConfig, _ string) error { } func patchProviders(cfg *config.RawConfig, profileDir string) error { - forEachProviders(cfg, func(index int, total int, key string, provider map[string]any, prefix string) { + return forEachProviders(cfg, func(index int, total int, key string, provider map[string]any, prefix string) error { path, _ := provider["path"].(string) if len(path) > 0 { path = common.ResolveAsRoot(path) } else if url, ok := provider["url"].(string); ok { path = prefix + "/" + utils.MakeHash([]byte(url)).String() // same as C.GetPathByHash } else { - return // both path and url are empty, maybe inline provider + return nil // both path and url are empty, maybe inline provider } provider["path"] = profileDir + "/providers/" + path - }) - return nil + return nil + }) } func validConfig(cfg *config.RawConfig, _ string) error { diff --git a/core/src/main/golang/native/config/provider.go b/core/src/main/golang/native/config/provider.go index ebf4f34af5..2d32f70d1e 100644 --- a/core/src/main/golang/native/config/provider.go +++ b/core/src/main/golang/native/config/provider.go @@ -11,21 +11,27 @@ const ( RULES = "rules" ) -func forEachProviders(rawCfg *config.RawConfig, fun func(index int, total int, key string, provider map[string]any, prefix string)) { +func forEachProviders(rawCfg *config.RawConfig, fun func(index int, total int, key string, provider map[string]any, prefix string) error) error { total := len(rawCfg.ProxyProvider) + len(rawCfg.RuleProvider) index := 0 for k, v := range rawCfg.ProxyProvider { - fun(index, total, k, v, PROXIES) + if err := fun(index, total, k, v, PROXIES); err != nil { + return err + } index++ } for k, v := range rawCfg.RuleProvider { - fun(index, total, k, v, RULES) + if err := fun(index, total, k, v, RULES); err != nil { + return err + } index++ } + + return nil } func destroyProviders(cfg *config.Config) { diff --git a/core/src/main/golang/native/tun.go b/core/src/main/golang/native/tun.go index 860a1fdc21..2693ab9ba7 100644 --- a/core/src/main/golang/native/tun.go +++ b/core/src/main/golang/native/tun.go @@ -68,11 +68,6 @@ func startTun(fd C.int, stack, gateway, portal, dns C.c_string, callback unsafe. rTunLock.Lock() defer rTunLock.Unlock() - if rTun != nil { - rTun.close() - rTun = nil - } - f := int(fd) s := C.GoString(stack) g := C.GoString(gateway) @@ -87,11 +82,21 @@ func startTun(fd C.int, stack, gateway, portal, dns C.c_string, callback unsafe. if err != nil { remote.close() + if rTun != nil { + app.ApplyTunContext(rTun.markSocket, rTun.querySocketUid) + } + return 1 } remote.closer = closer + if rTun != nil { + rTun.close() + } + + app.ApplyTunContext(remote.markSocket, remote.querySocketUid) + rTun = remote return 0 diff --git a/core/src/main/golang/native/tun/tun.go b/core/src/main/golang/native/tun/tun.go index ee2c2a9124..355512168a 100644 --- a/core/src/main/golang/native/tun/tun.go +++ b/core/src/main/golang/native/tun/tun.go @@ -6,6 +6,7 @@ import ( "net" "net/netip" "strings" + "syscall" C "github.com/metacubex/mihomo/constant" LC "github.com/metacubex/mihomo/listener/config" @@ -32,6 +33,7 @@ func Start(fd int, stack, gateway, portal, dns string) (io.Closer, error) { prefix, err := netip.ParsePrefix(gatewayStr) if err != nil { log.Errorln("TUN:", err) + _ = syscall.Close(fd) return nil, err } diff --git a/core/src/main/golang/native/tunnel/proxies.go b/core/src/main/golang/native/tunnel/proxies.go index e750b7adde..16c2d6acc9 100644 --- a/core/src/main/golang/native/tunnel/proxies.go +++ b/core/src/main/golang/native/tunnel/proxies.go @@ -156,6 +156,8 @@ func PatchSelector(selector, name string) bool { if err := s.Set(name); err != nil { log.Warnln("Patch selector `%s`: %s", selector, err.Error()) + + return false } log.Infoln("Patch selector %s -> %s", selector, name) diff --git a/core/src/main/java/com/github/kr328/clash/core/Clash.kt b/core/src/main/java/com/github/kr328/clash/core/Clash.kt index edba62bed0..9bfd987bfa 100644 --- a/core/src/main/java/com/github/kr328/clash/core/Clash.kt +++ b/core/src/main/java/com/github/kr328/clash/core/Clash.kt @@ -73,7 +73,7 @@ object Clash { markSocket: (Int) -> Boolean, querySocketUid: (protocol: Int, source: InetSocketAddress, target: InetSocketAddress) -> Int ) { - Bridge.nativeStartTun(fd, stack, gateway, portal, dns, object : TunInterface { + val started = Bridge.nativeStartTun(fd, stack, gateway, portal, dns, object : TunInterface { override fun markSocket(fd: Int) { markSocket(fd) } @@ -86,6 +86,9 @@ object Clash { ) } }) + if (!started) { + throw ClashException("startTun failed") + } } fun stopTun() { @@ -198,13 +201,16 @@ object Clash { } fun patchOverride(slot: OverrideSlot, configuration: ConfigurationOverride) { - Bridge.nativeWriteOverride( + val error = Bridge.nativeWriteOverride( slot.ordinal, ConfigurationOverrideJson.encodeToString( ConfigurationOverride.serializer(), configuration ) ) + if (error != null) { + throw ClashException(error) + } } fun clearOverride(slot: OverrideSlot) { diff --git a/core/src/main/java/com/github/kr328/clash/core/bridge/Bridge.kt b/core/src/main/java/com/github/kr328/clash/core/bridge/Bridge.kt index fa3b9247ef..aaed125517 100644 --- a/core/src/main/java/com/github/kr328/clash/core/bridge/Bridge.kt +++ b/core/src/main/java/com/github/kr328/clash/core/bridge/Bridge.kt @@ -19,7 +19,7 @@ object Bridge { external fun nativeNotifyDnsChanged(dnsList: String) external fun nativeNotifyTimeZoneChanged(name: String, offset: Int) external fun nativeNotifyInstalledAppChanged(uidList: String) - external fun nativeStartTun(fd: Int, stack: String, gateway: String, portal: String, dns: String, cb: TunInterface) + external fun nativeStartTun(fd: Int, stack: String, gateway: String, portal: String, dns: String, cb: TunInterface): Boolean external fun nativeStopTun() external fun nativeStartHttp(listenAt: String): String? external fun nativeStopHttp() @@ -44,7 +44,7 @@ object Bridge { ) external fun nativeReadOverride(slot: Int): String - external fun nativeWriteOverride(slot: Int, content: String) + external fun nativeWriteOverride(slot: Int, content: String): String? external fun nativeClearOverride(slot: Int) external fun nativeQueryConfiguration(): String external fun nativeSubscribeLogcat(callback: LogcatInterface) diff --git a/core/src/main/java/com/github/kr328/clash/core/util/Traffic.kt b/core/src/main/java/com/github/kr328/clash/core/util/Traffic.kt index 050eb506b5..84ef23d3ae 100644 --- a/core/src/main/java/com/github/kr328/clash/core/util/Traffic.kt +++ b/core/src/main/java/com/github/kr328/clash/core/util/Traffic.kt @@ -2,8 +2,12 @@ package com.github.kr328.clash.core.util import com.github.kr328.clash.core.model.Traffic +fun Traffic.uploadBytes(): Long = scaleTraffic(this ushr 32) + +fun Traffic.downloadBytes(): Long = scaleTraffic(this and 0xFFFFFFFF) + fun Traffic.trafficUpload(): String { - return trafficString(scaleTraffic(this ushr 32)) + return trafficString(uploadBytes()) } fun Traffic.trafficDownload(): String { diff --git a/design/README.md b/design/README.md new file mode 100644 index 0000000000..680030fab2 --- /dev/null +++ b/design/README.md @@ -0,0 +1,18 @@ +--- +title: "design 模块" +type: overview +status: accepted +phase: N/A +updated: 2026-10-16 +summary: "Compose 屏幕,以及从 AgenticUILibrary 复制的 iOS 控件" +--- + +# design + +界面实现。Activity 在 `app`。本模块的 `*Design` 用 Jetpack Compose 画屏幕,文件在 `src/main/java/.../design/ios/`。`res/layout` 只留下关于对话框、文本和列表编辑器、订阅二维码、age 密钥和拉取进度。 + +列表行、分组偏好行、开关和搜索框复制自 AgenticUILibrary(Apache-2.0),源码在 `src/main/java/nota/ui` 和 `src/main/java/universe/agentic/ui/search`。屏幕只调用 `design/ios` 里的 `Ios*`。没有把这个库加进 Gradle。见仓库根目录 `NOTICE`。 + +导航:[docs/modules/design/INDEX.md](../docs/modules/design/INDEX.md) + +模块设计:[docs/INDEX.md](docs/INDEX.md) diff --git a/design/build.gradle.kts b/design/build.gradle.kts index afe0796e13..f76a95c74a 100644 --- a/design/build.gradle.kts +++ b/design/build.gradle.kts @@ -2,6 +2,11 @@ plugins { kotlin("android") kotlin("kapt") id("com.android.library") + alias(libs.plugins.compose.compiler) +} + +android { + buildFeatures.compose = true } dependencies { @@ -19,6 +24,11 @@ dependencies { implementation(libs.androidx.viewpager) implementation(libs.google.material) implementation(libs.zxing.core) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.foundation) + implementation(libs.androidx.compose.icons) + implementation(libs.androidx.compose.material) testImplementation(libs.junit) } diff --git a/design/docs/INDEX.md b/design/docs/INDEX.md new file mode 100644 index 0000000000..75cf8fa3ab --- /dev/null +++ b/design/docs/INDEX.md @@ -0,0 +1,23 @@ +--- +title: "design 模块设计" +type: index +status: accepted +phase: N/A +updated: 2026-10-16 +summary: "Design 请求通道、屏幕与 Activity 的对应,以及仍留给编辑器的 preference 控件" +--- + +# design 模块设计 + +`app` 与 `design` 的分工见 [界面](../../docs/systems/ui/INDEX.md)。这里只写本模块的结构。 + +| 文档 | 内容 | +|:-----|:-----| +| [屏幕与请求通道](screens.md) | `Design` 基类、请求如何回到 Activity、每个 `*Design` 对应哪个 Activity、`model` 包 | +| [设置项控件](preferences.md) | `preference` 包里每种控件做什么 | +| [Compose 屏幕](compose.md) | `*Design` 用 `ComposeView` 接上原来的请求通道;`ios` 包的主题和控件 | +| [列表适配器](adapters.md) | 还留在 `adapter` 包里的三个适配器,服务 preference 弹层 | + +应用集合的点选规则见 [访问控制](../../docs/systems/access-control/INDEX.md),不在这里重写。 + +模块导航:[docs/modules/design](../../docs/modules/design/INDEX.md)。介绍:[README](../README.md)。 diff --git a/design/docs/adapters.md b/design/docs/adapters.md new file mode 100644 index 0000000000..08c75c09ac --- /dev/null +++ b/design/docs/adapters.md @@ -0,0 +1,18 @@ +--- +title: "列表适配器" +type: architecture +status: accepted +phase: N/A +updated: 2026-10-16 +summary: "adapter 包只留下 preference 弹层用的三个适配器。屏幕列表改由 Compose 绘制" +--- + +# 列表适配器 + +包路径是 `design/.../adapter/`。屏幕上的列表由 `ios` 包的 Compose 绘制,不再经过这里。应用集合的点选见 [访问控制](../../docs/systems/access-control/INDEX.md)。弹层里的确定、重置和取消见 [设置项控件](preferences.md)。 + +`EditableTextListAdapter` 给 `editableTextList` 的全屏列表。它绑定 `MutableList`,经 `TextAdapter` 显示,删除按钮移走该项。 + +`EditableTextMapAdapter` 给 `editableTextMap`。它绑定 `MutableList>`,键和值各有一个 `TextAdapter`。 + +`PopupListAdapter` 继承 `BaseAdapter`,给 `selectableList` 的模态 `ListPopupWindow`。它显示一组文案,并高亮 `selected` 下标。前两个是 `RecyclerView.Adapter`。 diff --git a/design/docs/compose.md b/design/docs/compose.md new file mode 100644 index 0000000000..8cd1be0657 --- /dev/null +++ b/design/docs/compose.md @@ -0,0 +1,40 @@ +--- +title: "Compose 屏幕" +type: architecture +status: accepted +phase: N/A +updated: 2026-10-17 +summary: "屏幕用 ComposeView 把点击送进 Design.requests。颜色读 UiStore.darkMode,没有 Cupertino 库" +--- + +# Compose 屏幕 + +首页画什么、设置怎样分组,见 [界面](../../docs/systems/ui/INDEX.md)。取舍见 [ADR-002](../../dev/decisions/002-compose-ios-home.md)。请求成员和 Activity 的对应见 [屏幕与请求通道](screens.md)。 + +## 依赖 + +Compose 只加在 `design`。`design/build.gradle.kts` 打开 `buildFeatures.compose`,并应用 `libs.plugins.compose.compiler`(`org.jetbrains.kotlin.plugin.compose`,版本与 Kotlin `2.1.0` 相同)。BOM 是 `platform(libs.androidx.compose.bom)`。版本不在该 Gradle 文件里,而在 `gradle/libs.versions.toml` 的 `composeBom`,当前为 `2025.02.00`。 + +直接依赖是 `androidx.compose.ui:ui`、`androidx.compose.foundation:foundation`、`androidx.compose.material:material-icons-core`、`androidx.compose.material:material`。没有 Material3,也没有第三方 Cupertino 库。源码里没有 `AndroidView`。 + +## 放进请求通道 + +每个 `*Design` 仍是 `Design` 的子类。`root` 是 `ComposeView`,Activity 继续 `setContentView(design.root)`。组合策略是 `ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed`。`setContent` 外包一层 `ClashMetaXTheme`。 + +点击不直接调 Activity。回调进入各自的 `request`,再 `requests.trySend`。Activity 仍从这条无界通道接收。 + +`MainDesign` 用 `mutableStateOf` 保存 `HomeUiState` 和 `Insets`。`setProfileName`、`setAppsStrategyName`、`setClashRunning`、`setTraffic`、`setCurrentNode`、`setMode`、`setHasProviders` 都在 `Dispatchers.Main` 上替换 `state`。停止时清掉 `currentNode`,并把 `traffic` 换成空的 `TrafficRates`。`setTraffic` 追加一个 `TrafficSample`,只留最后 36 个。系统栏监听同时写 `surface.insets` 和 Compose 的 `insets`。关于对话框仍是 `AlertDialog` 加 `DesignAboutBinding`,不是 Compose。 + +`HomeScreen` 的回调对应这些请求:`onToggle` 发 `ToggleStatus`,`onMode` 发 `SetMode`,`onProfiles` 发 `OpenProfiles`,`onApps` 发 `OpenAppsStrategy`,`onProxy` 发 `OpenProxy(group)`,`onProviders` 发 `OpenProviders`,`onLogs` 发 `OpenLogs`,`onSettings` 发 `OpenSettings`,`onHelp` 发 `OpenHelp`,`onAbout` 发 `OpenAbout`。`group` 是首页当前节点选中的组名,没有节点时为空。 + +`SettingsDesign` 同样用 `mutableStateOf` 保存 `Insets`。标题优先用 Activity 的 `title`,空白则用 `R.string.settings`。返回走 `onBackPressedDispatcher`,不发 `Request`。`onApp` 发 `StartApp`,`onNetwork` 发 `StartNetwork`,`onOverride` 发 `StartOverride`,`onMeta` 发 `StartMetaFeature`,`onWebdav` 发 `StartWebdav`。 + +## `ios` 包 + +路径是 `design/src/main/java/com/github/kr328/clash/design/ios/`。首页和设置顶层是 `HomeScreen.kt`、`SettingsScreen.kt`。其余屏幕各有一个 `*Screen.kt`。共享控件在 `IosComponents.kt`、`IosControls.kt` 和 `IosTheme.kt`。列表行、分组偏好行、开关和搜索框的实现复制在 `nota/ui` 和 `universe/agentic/ui/search`,屏幕只调用 `Ios*`。 + +`ClashMetaXTheme` 读 `UiStore(context).darkMode`。`ForceDark` 提供 `DarkColors`,`ForceLight` 提供 `LightColors`,`Auto` 看 `UI_MODE_NIGHT_YES`。颜色经 `LocalIosColors` 交给 `IosTheme.colors`,并同时提供 `LocalShellIosDark` 和 `LocalShellPressTint`。`IosType` 的字号是固定 sp,不随暗色改变:`largeTitle` 34,`title2` 22,`headline` 与 `body` 17,`subhead` 15,`footnote` 与 `segment` 13。大标题只用于首页。 + +两屏都用 `LocalDensity` 把 `Insets` 换成 dp,再垫在根布局上。 + +控件在 `IosComponents.kt` 和 `IosControls.kt`,建在 Compose foundation 上。`IosInsetGroup` 接收 `List`,逐行调用 `IosPreferenceRow`。行在 `enabled` 且有 `onClick` 时才可点。`disclosure` 默认 true,这时画 13dp 箭头;选择行设 false,只留 `IosSelection` 的勾或短横。`IosSheet` 是底部卡片,底边距是 8dp 加上对话框窗口自己的导航栏。`IosActionSheet` 的动作之间有 0.5dp 分隔线,`cancelLabel` 非空时下面再放一张取消卡。`IosNavIcon` 的点击区域是 44dp,图标 24dp。`IosFractionBar` 高 4dp。`iosConfirm` 的破坏性红色按 `UiStore.darkMode` 取色,判断与 `ClashMetaXTheme` 相同。`HomeScreen` 使用 `IosButton`、`IosModePicker`、`TrafficSparkline` 和 `IosInsetGroup`。连接卡和流量卡圆角 10dp,模式按钮保持 12dp。`SettingsScreen` 使用 `IosNavBar` 和 `IosInsetGroup`。分组行和 `IosModePicker` 的最小高度是 44dp。导航栏高 44dp,背景与页面相同,返回键的点击区域也是 44dp,内容描述是 `back`。`IosScreenScaffold` 把内容裁在导航栏下方,导航栏后画,滚动时不盖住标题。设置顶层也走这个脚手架。`IosButton` 的最小高度是 50dp。`IosSegmentedControl` 高 32dp,上述两个屏幕都没有调用它。 diff --git a/design/docs/preferences.md b/design/docs/preferences.md new file mode 100644 index 0000000000..aae42871cb --- /dev/null +++ b/design/docs/preferences.md @@ -0,0 +1,44 @@ +--- +title: "设置项控件" +type: architecture +status: accepted +phase: N/A +updated: 2026-10-16 +summary: "preference 包仍提供文本、列表和映射编辑器。设置子页已改成 Compose,不再调用 preferenceScreen" +--- + +# 设置项控件 + +包路径是 `design/.../preference/`。这些控件改绑定的 `KMutableProperty0`,不往 `Design.requests` 发请求。设置子页已经改成 Compose,不再调用 `preferenceScreen`。覆写和 Meta 的单行文本、列表和映射仍打开这里的编辑器。哪个屏幕使用它们,见 [屏幕与请求通道](screens.md)。 + +## 屏幕 + +`preferenceScreen` 建一个垂直的 `LinearLayout`,并把它做成 `PreferenceScreen`。这个接口同时是调用方的 `CoroutineScope`,在 `*Design` 里就是 `Design` 的 `Unconfined` 作用域。`addElement` 把控件视图加进去,宽 `MATCH_PARENT`,高 `WRAP_CONTENT`。 + +`Preference` 有 `view` 和 `enabled`。默认的 `enabled` 会改 `isEnabled`、`isClickable`、`isFocusable`;禁用时 `alpha` 为 0.33,启用时为 1。`OnChangedListener` 只有 `onChanged()`。 + +## 控件 + +`category` 把字符串资源填进分类布局,没有点击,也没有属性。它的 `enabled` 只改 `isEnabled`。 + +`tips` 用 `context.getHtml` 把字符串资源设成提示文字,返回 `TipsPreference`。它的 `enabled` 同样只改 `isEnabled`。 + +`clickable` 是一行标题。图标为 `null` 时图标隐藏,摘要为 `null` 时摘要隐藏。`clicked` 设置这行的点击监听。它自己不读写属性。 + +`switch` 绑定 `KMutableProperty0`,可选图标、标题、摘要。先在 `Dispatchers.IO` 读初值,再在主线程勾上开关并装点击。点击会翻转勾选,在 IO 写回属性,然后调用 `listener`。点击监听在初值读完之后才装上。 + +`selectableList` 建在 `clickable` 上,绑定一个属性、取值数组和字符串资源数组。摘要是当前下标对应的文案。点击后弹出模态 `ListPopupWindow`,选中一项就关掉弹层,在 IO 写入 `values[position]`,更新下标并调用 `listener`。初值同样先在 IO 读取,下标是 `values.indexOf` 的结果。 + +`editableText` 也建在 `clickable` 上,用 `NullableTextAdapter` 做转换。摘要:值为 `null` 时用 `placeholder`,空字符串用 `empty`,否则显示文本。点击打开 `requestModelTextInput`,并带上「重置」。确定且校验通过时返回输入;校验失败、取消或对话框关闭时返回打开前的文本;重置返回 `null`。返回值经 `adapter.to` 后在 IO 写回属性,再用 `adapter.from` 刷新摘要。取消也会走这次写回。 + +`editableTextList` 绑定 `List?`,用 `TextAdapter`。摘要:`null` 用 `placeholder`,空列表用 `R.string.empty`,否则用 `R.string.format_elements` 和元素个数。点击打开全屏列表对话框。新增一项时再要一次文本输入,空白文本不加入。确定采用列表里的当前值,重置得到 `null`,取消或直接关掉得到打开前的列表。三种结果都会在 IO 写回属性。 + +`editableTextMap` 与列表相同,绑定的是 `Map?`,键和值各有一个 `TextAdapter`。新增时对话框收集键和值,去掉空白后两者都非空才加入。确定把当前条目收成 `Map`,重置得到 `null`,取消得到打开前的映射,然后写回属性。 + +全屏列表对话框在 `requestEditableListOverlay`。新增按钮调用传入的挂起函数。重置以 `Reset` 结束,确定以 `Apply` 结束,取消按钮或尚未结束就被关掉时是 `Cancel`。协程取消时对话框关闭。 + +## 文本转换 + +`NullableTextAdapter.String` 和 `TextAdapter.String` 原样来回。 + +`NullableTextAdapter.Port`:`from` 在 `null` 时返回 `null`,大于 0 时返回十进制字符串,否则返回空字符串。`to` 在 `null` 时返回 `null`,否则 `toIntOrNull`,解析不了就是 0。 diff --git a/design/docs/screens.md b/design/docs/screens.md new file mode 100644 index 0000000000..71a46930ea --- /dev/null +++ b/design/docs/screens.md @@ -0,0 +1,79 @@ +--- +title: "屏幕与请求通道" +type: architecture +status: accepted +phase: N/A +updated: 2026-10-16 +summary: "Design 用无界 Channel 把请求交给 Activity。每个 *Design 的根视图是 ComposeView,由对应 Activity 构造并 setContentDesign" +--- + +# 屏幕与请求通道 + +界面分工见 [界面](../../docs/systems/ui/INDEX.md)。设置页里的控件见 [设置项控件](preferences.md)。 + +## Design 基类 + +`design/.../Design.kt` 的 `Design` 持有: + +- `root`:子类提供的根视图。 +- `requests`:`Channel(Channel.UNLIMITED)`。 +- `surface`:可观察的 `Surface`,字段是系统栏 `insets`。 +- 协程作用域:`Dispatchers.Unconfined`。 + +`context` 是 `AppCompatActivity` 时,基类给 `window.decorView` 装系统栏 insets 监听,有变化就写入 `surface.insets`。每个 Compose 屏幕在自己的 `init` 里再次调用 `setOnInsertsChangedListener`。该函数设置的是单个 `OnApplyWindowInsetsListener`,所以以子类这次为准。子类这次仍会更新 `surface.insets`,并另存一份给 Compose。Compose 不再套一层 `WindowInsets.safeDrawing`。 + +界面侧把请求放进通道:Kotlin 里是 `requests.trySend`。`showToast` 在主线程用根视图上的 `Snackbar`;`ToastDuration` 的 `Short`、`Long`、`Indefinite` 对应 Snackbar 的同名时长。 + +Activity 不靠回调注册。`BaseActivity.setContentDesign` 在 `decorView` 投递之后把 `design` 设为该实例,setter 调用 `setContentView(design.root)`。`main` 再从通道取出请求:多数屏幕在 `select` 里用 `design.requests.onReceive`,和 `events` 一起等;`ApkBrokenActivity`,以及 `LogcatActivity` 打开本地文件的那条路径,用 `design.requests.receive()`。`onDestroy` 调用 `design?.cancel()`,取消这个 `Design` 的作用域。 + +通道也可以由 Activity 写入。已看到的有:`AccessControlActivity` 发送 `ReloadApps`,`ProxyActivity` 在组下标有效时发送一次 `Reload`,`FilesActivity.onBackPressed` 发送 `PopStack`。`ProxyDesign.Request.ReloadAll` 和 `PatchMode` 仍留在类型里,界面不再发送它们。 + +`HelpDesign` 和 `AppCrashedDesign` 是 `Design`。对应 Activity 只循环接收 `events`,不读 `design.requests`。`HelpDesign` 的链接点击直接调用构造时传入的 `openLink`。 + +设置项控件写的是绑定的属性,不是这条 `requests` 通道。需要 Activity 接着做事时,控件的 `OnChangedListener` 再 `trySend`。 + +## 屏幕与 Activity + +下表每一行都能在 `app` 里看到构造该 `Design` 并调用 `setContentDesign`。请求成员是 `*Design` 里声明的类型,不是 Activity 的处理分支。 + +| Design | Activity | 请求 | +|:-------|:---------|:-----| +| `AccessControlDesign` | `AccessControlActivity` | `ReloadApps`、`SelectAll`、`SelectNone`、`SelectInvert`、`Import`、`Export` | +| `ApkBrokenDesign` | `ApkBrokenActivity` | `Request(url)` | +| `AppCrashedDesign` | `AppCrashedActivity` | 无。类型参数是 `Unit` | +| `AppSettingsDesign` | `AppSettingsActivity` | `ReCreateAllActivities` | +| `AppsStrategyDesign` | `AppsStrategyActivity` | `Create`、`Edit`、`Active`、`Choose` | +| `FilesDesign` | `FilesActivity` | `OpenFile`、`OpenDirectory`、`RenameFile`、`DeleteFile`、`ImportFile`、`ExportFile`、`PopStack` | +| `HelpDesign` | `HelpActivity` | 无。类型参数是 `Unit` | +| `LogcatDesign` | `LogcatActivity` | `Close`、`Delete`、`Export`。同一 Activity 构造两次:本地文件传 `streaming = false`,实时日志传 `true` | +| `LogsDesign` | `LogsActivity` | `StartLogcat`、`DeleteAll`、`OpenFile` | +| `MainDesign` | `MainActivity` | `ToggleStatus`、`OpenProxy`、`OpenProfiles`、`OpenProviders`、`OpenAppsStrategy`、`OpenLogs`、`OpenSettings`、`OpenHelp`、`OpenAbout`、`SetMode` | +| `MetaFeatureSettingsDesign` | `MetaFeatureSettingsActivity` | `ResetOverride`、`ImportGeoIp`、`ImportGeoSite`、`ImportCountry`、`ImportASN` | +| `NetworkSettingsDesign` | `NetworkSettingsActivity` | `StartAccessControlList`、`AccessControlChanged` | +| `NewProfileDesign` | `NewProfileActivity` | `Create`、`OpenDetail`、`LaunchScanner` | +| `OverrideSettingsDesign` | `OverrideSettingsActivity` | `ResetOverride` | +| `ProfilesDesign` | `ProfilesActivity` | `UpdateAll`、`Create`、`Active`、`Update`、`Edit`、`Duplicate`、`Delete` | +| `PropertiesDesign` | `PropertiesActivity` | `Commit`、`BrowseFiles` | +| `ProvidersDesign` | `ProvidersActivity` | `Update(index, provider)` | +| `ProxyDesign` | `ProxyActivity` | `ReloadAll`、`ReLaunch`、`PatchMode`、`Reload`、`Select`、`UrlTest` | +| `SettingsDesign` | `SettingsActivity` | `StartApp`、`StartNetwork`、`StartOverride`、`StartMetaFeature`、`StartWebdav` | +| `WebdavSettingsDesign` | `WebdavSettingsActivity` | `Test`、`Sync`、`Pull`、`Push` | + +上表每个 `*Design` 的根视图都是 `ComposeView`,组合策略是 `DisposeOnViewTreeLifecycleDestroyed`,内容包在 `ClashMetaXTheme` 里。屏幕文件在 `design/.../ios/`。关于对话框仍 inflate `design_about.xml`。单行文本、列表、映射、age 密钥、订阅二维码、拉取进度和策略编辑对话框仍用原来的布局。设置子页不再调用 `preferenceScreen`;覆写和 Meta 的列表编辑器仍用 `preference` 包。 + +## model + +`design/.../model/` 里除应用集合以外的类型: + +| 类型 | 源码里的内容 | +|:-----|:-------------| +| `AppInfo` | `packageName`、`label`、`icon`、`installTime`、`updateDate` | +| `AppInfoSort` | `Label`、`PackageName`、`InstallTime`、`UpdateTime`,各自按对应字段比较。访问控制页把它写入 `UiStore.accessControlSort` | +| `Behavior` | `autoRestart`。`AppSettingsActivity` 实现该接口并传给 `AppSettingsDesign` | +| `DarkMode` | `Auto`、`ForceLight`、`ForceDark`。存在 `UiStore.darkMode`。`BaseActivity` 用它选择日间或夜间主题 | +| `File` | `id`、`name`、`size`、`lastModified`、`isDirectory`。`FilesDesign` 的列表项 | +| `LogFile` | `fileName` 与 `date`。`parseFromFileName` 用正则 `clash-(\d+).log` 做整段匹配,时间取第一个捕获组。`generate` 用格式 `clash-%d.log` 和当前时间 | +| `ProfileProvider` | `File`、`Url`、`QR`,以及带 `name`、`summary`、`icon`、`intent` 的 `External`。`NewProfileDesign` 的列表 | +| `ProxyState` | `now`。`ProxyActivity` 为每个代理组建一个,再交给 `ProxyDesign` | + +`AppSet.kt` 里的集合类型和 `applyAppSetToggle` 不在这里写。点选规则见 [访问控制](../../docs/systems/access-control/INDEX.md)。 diff --git a/design/src/main/java/com/github/kr328/clash/design/AccessControlDesign.kt b/design/src/main/java/com/github/kr328/clash/design/AccessControlDesign.kt index 061fa9bc0d..b8b27f96fc 100644 --- a/design/src/main/java/com/github/kr328/clash/design/AccessControlDesign.kt +++ b/design/src/main/java/com/github/kr328/clash/design/AccessControlDesign.kt @@ -1,29 +1,34 @@ package com.github.kr328.clash.design +import android.app.Activity import android.content.Context import android.view.View -import androidx.core.widget.addTextChangedListener -import androidx.recyclerview.widget.ConcatAdapter -import com.github.kr328.clash.design.adapter.AppAdapter -import com.github.kr328.clash.design.adapter.AppSetAdapter -import com.github.kr328.clash.design.adapter.AppSetRow -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 androidx.appcompat.app.AppCompatActivity +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import com.github.kr328.clash.common.util.uuid +import com.github.kr328.clash.design.ios.AccessControlScreen +import com.github.kr328.clash.design.ios.ClashMetaXTheme import com.github.kr328.clash.design.model.AppInfo +import com.github.kr328.clash.design.model.AppInfoSort import com.github.kr328.clash.design.model.AppSetIds -import com.github.kr328.clash.design.model.appSetMembership -import com.github.kr328.clash.design.model.applyAppSetToggle import com.github.kr328.clash.design.store.UiStore -import com.github.kr328.clash.design.util.* -import kotlinx.coroutines.* -import kotlinx.coroutines.channels.Channel +import com.github.kr328.clash.design.ui.Insets +import com.github.kr328.clash.design.util.setOnInsertsChangedListener +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext class AccessControlDesign( context: Context, - uiStore: UiStore, + private val uiStore: UiStore, private val selected: MutableSet, + private val toggleMembers: (Set) -> Unit, ) : Design(context) { enum class Request { ReloadApps, @@ -34,171 +39,169 @@ class AccessControlDesign( Export, } - private val binding = DesignAccessControlBinding - .inflate(context.layoutInflater, context.root, false) + /** Unfiltered `loadAccessControlApps` rows. Search must not replace this. */ + var apps by mutableStateOf>(emptyList()) + private set - private val setAdapter = AppSetAdapter(context) { id -> toggleAppSet(id) } + private var searchQuery by mutableStateOf("") - private val adapter = AppAdapter(context, selected) { refreshSetRows() } + private var keyword by mutableStateOf("") - private var systemPackages: Set = emptySet() + private var selection by mutableStateOf(selected.toSet()) - private var domesticPackages: Set = emptySet() + private var systemPackages by mutableStateOf>(emptySet()) - private val menu: AccessControlMenu by lazy { - AccessControlMenu(context, binding.menuView, uiStore, requests) - } + private var domesticPackages by mutableStateOf>(emptySet()) + + private var loading by mutableStateOf(true) + + private var hideSystemApps by mutableStateOf(!uiStore.accessControlSystemApp) + + private var sort by mutableStateOf(uiStore.accessControlSort) + + private var reversed by mutableStateOf(uiStore.accessControlReverse) + + private var insets by mutableStateOf(Insets.EMPTY) - val apps: List - get() = adapter.apps + private var appsReady = false - override val root: View - get() = binding.root + private var setsReady = false + + private var searchDebounce: Job? = null + + override val root: View = ComposeView(context).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + val activity = context as? Activity + val currentTitle = activity?.title?.toString()?.takeIf { it.isNotBlank() } + val legacyTitle = context.getString(R.string.access_control_packages) + val openedFromStrategy = activity?.intent?.uuid != null + val title = if (!openedFromStrategy && (currentTitle == null || currentTitle == legacyTitle)) { + context.getString(R.string.global_app_list) + } else { + currentTitle ?: legacyTitle + } + ClashMetaXTheme { + AccessControlScreen( + insets = insets, + title = title, + query = searchQuery, + keyword = keyword, + apps = apps, + selection = selection, + systemPackages = systemPackages, + domesticPackages = domesticPackages, + loading = loading, + hideSystem = hideSystemApps, + sort = sort, + reversed = reversed, + onBack = { + (context as? AppCompatActivity)?.onBackPressedDispatcher?.onBackPressed() + }, + onQueryChange = ::onSearchQueryChange, + onHideSystem = ::applyHideSystemApps, + onToggleApp = ::toggleApp, + onToggleSet = ::toggleSet, + onSelectAll = { request(Request.SelectAll) }, + onSelectNone = { request(Request.SelectNone) }, + onSelectInvert = { request(Request.SelectInvert) }, + onSort = ::chooseSort, + onReverse = ::toggleReverse, + onImport = { request(Request.Import) }, + onExport = { request(Request.Export) }, + ) + } + } + } suspend fun patchApps(apps: List) { - adapter.swapDataSet(adapter::apps, apps, false) + withContext(Dispatchers.Main) { + this@AccessControlDesign.apps = apps + appsReady = true + if (appsReady && setsReady) loading = false + } } suspend fun patchBuiltinSets(systemPackages: Set, domesticPackages: Set) { - this.systemPackages = systemPackages - this.domesticPackages = domesticPackages withContext(Dispatchers.Main) { - setAdapter.submit(buildRows()) + this@AccessControlDesign.systemPackages = systemPackages + this@AccessControlDesign.domesticPackages = domesticPackages + setsReady = true + if (appsReady && setsReady) loading = false } } suspend fun rebindAll() { withContext(Dispatchers.Main) { - refreshSetRows() - adapter.rebindAll() + selection = selected.toSet() } } - init { - binding.self = this - - binding.activityBarLayout.applyFrom(context) - - binding.mainList.recyclerList.also { - it.bindAppBarElevation(binding.activityBarLayout) - it.applyLinearAdapter(context, ConcatAdapter(setAdapter, adapter)) - } - - binding.menuView.setOnClickListener { - menu.show() - } - - binding.searchView.setOnClickListener { - launch { - try { - requestSearch() - } finally { - withContext(NonCancellable) { - rebindAll() - } - } + private fun onSearchQueryChange(value: String) { + searchQuery = value + searchDebounce?.cancel() + searchDebounce = launch { + delay(SEARCH_DEBOUNCE_MS) + withContext(Dispatchers.Main) { + if (keyword != value) keyword = value } } } - private suspend fun requestSearch() { - coroutineScope { - val binding = DialogSearchBinding - .inflate(context.layoutInflater, context.root, false) - val adapter = AppAdapter(context, selected) { refreshSetRows() } - val dialog = FullScreenDialog(context) - val filter = Channel(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 = if (keyword.isEmpty()) { - emptyList() - } else { - withContext(Dispatchers.Default) { - apps.filter { - it.label.contains(keyword, ignoreCase = true) || - it.packageName.contains(keyword, ignoreCase = true) - } - } - } + private fun applyHideSystemApps(hide: Boolean) { + // Stored false means system rows stay hidden. The switch is the opposite. + uiStore.accessControlSystemApp = !hide + hideSystemApps = hide + request(Request.ReloadApps) + } - adapter.patchDataSet(adapter::apps, apps, false, AppInfo::packageName) + private fun chooseSort(value: AppInfoSort) { + uiStore.accessControlSort = value + sort = value + request(Request.ReloadApps) + } - delay(200) - } - } + private fun toggleReverse() { + val next = !uiStore.accessControlReverse + uiStore.accessControlReverse = next + reversed = next + request(Request.ReloadApps) } - private fun refreshSetRows() { - setAdapter.submit(buildRows()) + private fun toggleApp(packageName: String) { + if (packageName in selected) { + selected.remove(packageName) + } else { + selected.add(packageName) + } + selection = selected.toSet() } - private fun toggleAppSet(id: String) { + private fun toggleSet(id: String) { val members = when (id) { AppSetIds.SYSTEM -> systemPackages AppSetIds.DOMESTIC -> domesticPackages else -> return } - applyAppSetToggle(members, selected) - refreshSetRows() - adapter.rebindAll() + if (members.isEmpty()) return + toggleMembers(members) + selection = selected.toSet() } - private fun buildRows(): List { - return listOf( - row( - AppSetIds.SYSTEM, - context.getString(R.string.system_apps), - R.drawable.ic_baseline_apps, - systemPackages, - ), - row( - AppSetIds.DOMESTIC, - context.getString(R.string.app_set_domestic), - R.drawable.ic_baseline_domain, - domesticPackages, - ), - ) + private fun request(request: Request) { + requests.trySend(request) } - private fun row(id: String, title: String, iconRes: Int, members: Set): AppSetRow { - val membership = appSetMembership(members, selected) - val summary = if (members.isEmpty()) { - context.getString(R.string.app_set_summary_empty) - } else { - context.getString(R.string.app_set_summary, membership.total, membership.selectedCount) + init { + if (context is AppCompatActivity) { + context.window.decorView.setOnInsertsChangedListener { + if (surface.insets != it) surface.insets = it + insets = it + } } - return AppSetRow( - id = id, - title = title, - summary = summary, - iconRes = iconRes, - checkState = membership.state, - enabled = members.isNotEmpty(), - ) } -} \ No newline at end of file + + private companion object { + const val SEARCH_DEBOUNCE_MS = 200L + } +} diff --git a/design/src/main/java/com/github/kr328/clash/design/ApkBrokenDesign.kt b/design/src/main/java/com/github/kr328/clash/design/ApkBrokenDesign.kt index 2657e4b1aa..abc8877457 100644 --- a/design/src/main/java/com/github/kr328/clash/design/ApkBrokenDesign.kt +++ b/design/src/main/java/com/github/kr328/clash/design/ApkBrokenDesign.kt @@ -1,48 +1,51 @@ package com.github.kr328.clash.design +import android.app.Activity 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 +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import com.github.kr328.clash.design.ios.ApkBrokenScreen +import com.github.kr328.clash.design.ios.ClashMetaXTheme +import com.github.kr328.clash.design.ui.Insets +import com.github.kr328.clash.design.util.setOnInsertsChangedListener class ApkBrokenDesign(context: Context) : Design(context) { data class Request(val url: String) - private val binding = DesignSettingsCommonBinding - .inflate(context.layoutInflater, context.root, false) - - override val root: View - get() = binding.root + private var insets by mutableStateOf(Insets.EMPTY) + + override val root: View = ComposeView(context).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + ClashMetaXTheme { + ApkBrokenScreen( + insets = insets, + title = context.pushedTitle(R.string.application_broken), + onBack = { (context as? AppCompatActivity)?.onBackPressedDispatcher?.onBackPressed() }, + onReleases = { + requests.trySend(Request(context.getString(R.string.meta_github_url))) + }, + ) + } + } + } 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))) - } + if (context is AppCompatActivity) { + context.window.decorView.setOnInsertsChangedListener { + if (surface.insets != it) surface.insets = it + insets = it } } - - binding.content.addView(screen.root) } -} \ No newline at end of file +} + +private fun Context.pushedTitle(fallback: Int): String { + val current = (this as? Activity)?.title?.toString()?.ifBlank { null } + return current ?: getString(fallback) +} diff --git a/design/src/main/java/com/github/kr328/clash/design/AppCrashedDesign.kt b/design/src/main/java/com/github/kr328/clash/design/AppCrashedDesign.kt index 4e530b0a1b..bc70fa523a 100644 --- a/design/src/main/java/com/github/kr328/clash/design/AppCrashedDesign.kt +++ b/design/src/main/java/com/github/kr328/clash/design/AppCrashedDesign.kt @@ -1,29 +1,69 @@ package com.github.kr328.clash.design +import android.app.Activity +import android.content.ClipData +import android.content.ClipboardManager 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 +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.core.content.getSystemService +import com.github.kr328.clash.design.ios.AppCrashedScreen +import com.github.kr328.clash.design.ios.ClashMetaXTheme +import com.github.kr328.clash.design.ui.Insets +import com.github.kr328.clash.design.ui.ToastDuration +import com.github.kr328.clash.design.util.setOnInsertsChangedListener +import kotlinx.coroutines.launch class AppCrashedDesign(context: Context) : Design(context) { - private val binding = DesignAppCrashedBinding - .inflate(context.layoutInflater, context.root, false) + private var insets by mutableStateOf(Insets.EMPTY) + private var logs by mutableStateOf(null) - override val root: View - get() = binding.root + override val root: View = ComposeView(context).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + ClashMetaXTheme { + AppCrashedScreen( + insets = insets, + title = context.pushedTitle(R.string.application_crashed), + logs = logs, + onBack = { (context as? AppCompatActivity)?.onBackPressedDispatcher?.onBackPressed() }, + onCopy = { copyLogs() }, + ) + } + } + } fun setAppLogs(logs: String) { - binding.logsView.text = logs + this.logs = logs } - init { - binding.self = this - - binding.activityBarLayout.applyFrom(context) + private fun copyLogs() { + val text = logs ?: return + if (text.isEmpty()) return + launch { + val data = ClipData.newPlainText("crash", text) + val clipboard = context.getSystemService() ?: return@launch + clipboard.setPrimaryClip(data) + showToast(R.string.copied, ToastDuration.Short) + } + } - binding.scrollRoot.bindAppBarElevation(binding.activityBarLayout) + init { + if (context is AppCompatActivity) { + context.window.decorView.setOnInsertsChangedListener { + if (surface.insets != it) surface.insets = it + insets = it + } + } } -} \ No newline at end of file +} + +private fun Context.pushedTitle(fallback: Int): String { + val current = (this as? Activity)?.title?.toString()?.ifBlank { null } + return current ?: getString(fallback) +} diff --git a/design/src/main/java/com/github/kr328/clash/design/AppSettingsDesign.kt b/design/src/main/java/com/github/kr328/clash/design/AppSettingsDesign.kt index dc43d00a29..58bdae7622 100644 --- a/design/src/main/java/com/github/kr328/clash/design/AppSettingsDesign.kt +++ b/design/src/main/java/com/github/kr328/clash/design/AppSettingsDesign.kt @@ -1,105 +1,133 @@ package com.github.kr328.clash.design +import android.app.Activity import android.content.Context import android.view.View -import com.github.kr328.clash.design.databinding.DesignSettingsCommonBinding +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import com.github.kr328.clash.design.ios.AppSettingsScreen +import com.github.kr328.clash.design.ios.AppSettingsUiState +import com.github.kr328.clash.design.ios.ClashMetaXTheme 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.design.ui.Insets +import com.github.kr328.clash.design.util.setOnInsertsChangedListener import com.github.kr328.clash.service.store.ServiceStore +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext class AppSettingsDesign( context: Context, - uiStore: UiStore, - srvStore: ServiceStore, - behavior: Behavior, + private val uiStore: UiStore, + private val srvStore: ServiceStore, + private val behavior: Behavior, running: Boolean, - onHideIconChange: (hide: Boolean) -> Unit, + private val onHideIconChange: (hide: Boolean) -> Unit, ) : Design(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_) + private var insets by mutableStateOf(Insets.EMPTY) + private var state by mutableStateOf( + AppSettingsUiState( + autoRestart = behavior.autoRestart, + darkMode = uiStore.darkMode, + hideAppIcon = uiStore.hideAppIcon, + hideFromRecents = uiStore.hideFromRecents, + dynamicNotification = srvStore.dynamicNotification, + running = running, + ) + ) + + override val root: View = ComposeView(context).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + val title = (context as? Activity)?.title?.toString() + ?.ifBlank { null } + ?: context.getString(R.string.app) + setContent { + ClashMetaXTheme { + AppSettingsScreen( + insets = insets, + title = title, + state = state, + onBack = { (context as? AppCompatActivity)?.onBackPressedDispatcher?.onBackPressed() }, + onAutoRestart = ::setAutoRestart, + onDarkMode = ::setDarkMode, + onHideIcon = ::setHideIcon, + onHideFromRecents = ::setHideFromRecents, + onShowTraffic = ::setDynamicNotification, + ) + } + } + } - 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) - } + private fun setAutoRestart(value: Boolean) { + launch(Dispatchers.Main) { + val stored = withContext(Dispatchers.IO) { + behavior.autoRestart = value + behavior.autoRestart } + state = state.copy(autoRestart = stored) + } + } - switch( - value = uiStore::hideAppIcon, - icon = R.drawable.ic_baseline_hide, - title = R.string.hide_app_icon_title, - summary = R.string.hide_app_icon_desc, - ) { - listener = OnChangedListener { - onHideIconChange(uiStore::hideAppIcon.get()) - } + private fun setDarkMode(value: DarkMode) { + launch(Dispatchers.Main) { + val stored = withContext(Dispatchers.IO) { + uiStore.darkMode = value + uiStore.darkMode } + state = state.copy(darkMode = stored) + requests.trySend(Request.ReCreateAllActivities) + } + } - switch( - value = uiStore::hideFromRecents, - icon = R.drawable.ic_baseline_stack, - title = R.string.hide_from_recents_title, - summary = R.string.hide_from_recents_desc, - ) { - listener = OnChangedListener { - requests.trySend(Request.ReCreateAllActivities) - } + private fun setHideIcon(value: Boolean) { + launch(Dispatchers.Main) { + val stored = withContext(Dispatchers.IO) { + uiStore.hideAppIcon = value + uiStore.hideAppIcon } + state = state.copy(hideAppIcon = stored) + onHideIconChange(stored) + } + } - category(R.string.service) + private fun setHideFromRecents(value: Boolean) { + launch(Dispatchers.Main) { + val stored = withContext(Dispatchers.IO) { + uiStore.hideFromRecents = value + uiStore.hideFromRecents + } + state = state.copy(hideFromRecents = stored) + requests.trySend(Request.ReCreateAllActivities) + } + } - switch( - value = srvStore::dynamicNotification, - icon = R.drawable.ic_baseline_domain, - title = R.string.show_traffic, - summary = R.string.show_traffic_summary - ) { - enabled = !running + private fun setDynamicNotification(value: Boolean) { + if (!state.showTrafficEnabled) return + launch(Dispatchers.Main) { + val stored = withContext(Dispatchers.IO) { + srvStore.dynamicNotification = value + srvStore.dynamicNotification } + state = state.copy(dynamicNotification = stored) } + } - binding.content.addView(screen.root) + init { + if (context is AppCompatActivity) { + context.window.decorView.setOnInsertsChangedListener { + if (surface.insets != it) surface.insets = it + insets = it + } + } } -} \ No newline at end of file +} diff --git a/design/src/main/java/com/github/kr328/clash/design/AppsStrategyDesign.kt b/design/src/main/java/com/github/kr328/clash/design/AppsStrategyDesign.kt index e609640b01..7834917f37 100644 --- a/design/src/main/java/com/github/kr328/clash/design/AppsStrategyDesign.kt +++ b/design/src/main/java/com/github/kr328/clash/design/AppsStrategyDesign.kt @@ -3,26 +3,30 @@ package com.github.kr328.clash.design import android.content.Context import android.view.View import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.core.widget.doOnTextChanged -import com.github.kr328.clash.design.adapter.AppsStrategyConfigAdapter -import com.github.kr328.clash.design.databinding.DesignAppsStrategyBinding import com.github.kr328.clash.design.databinding.DialogAppsStrategyConfigEditBinding +import com.github.kr328.clash.design.ios.AppsStrategyScreen +import com.github.kr328.clash.design.ios.ClashMetaXTheme +import com.github.kr328.clash.design.ui.Insets import com.github.kr328.clash.design.util.Validator import com.github.kr328.clash.design.util.ValidatorAcceptAll -import com.github.kr328.clash.design.util.applyFrom -import com.github.kr328.clash.design.util.applyLinearAdapter -import com.github.kr328.clash.design.util.bindAppBarElevation import com.github.kr328.clash.design.util.layoutInflater -import com.github.kr328.clash.design.util.patchDataSet import com.github.kr328.clash.design.util.requestTextInput import com.github.kr328.clash.design.util.root +import com.github.kr328.clash.design.util.setOnInsertsChangedListener import com.github.kr328.clash.service.model.AccessControlMode import com.github.kr328.clash.service.model.AppsStrategyConfig import com.google.android.material.dialog.MaterialAlertDialogBuilder import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext -import java.util.* +import java.util.UUID import kotlin.coroutines.resume class AppsStrategyDesign(context: Context) : Design(context) { @@ -34,49 +38,58 @@ class AppsStrategyDesign(context: Context) : Design( data class Choose(val config: AppsStrategyConfig) : Request() } - private val binding = DesignAppsStrategyBinding - .inflate(context.layoutInflater, context.root, false) - - private val adapter = AppsStrategyConfigAdapter( - context, - onClicked = { config -> - requests.trySend(Request.Active(config)) - }, - onEditClicked = { config -> - requests.trySend(Request.Edit(config)) - }, - onChooseClicked = { config -> - requests.trySend(Request.Choose(config)) - } - ) - - override val root: View - get() = binding.root - - init { - binding.self = this - - binding.activityBarLayout.applyFrom(context) - - binding.mainList.recyclerList.also { - it.bindAppBarElevation(binding.activityBarLayout) - it.applyLinearAdapter(context, adapter) + var configs by mutableStateOf>(emptyList()) + private set + + var activeUuid by mutableStateOf(null) + private set + + private var insets by mutableStateOf(Insets.EMPTY) + + override val root: View = ComposeView(context).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + ClashMetaXTheme { + AppsStrategyScreen( + insets = insets, + title = context.getString(R.string.apps_strategy), + configs = configs, + activeUuid = activeUuid, + onBack = { + (context as? AppCompatActivity)?.onBackPressedDispatcher?.onBackPressed() + }, + onCreate = { requestCreate() }, + onActive = { request(Request.Active(it)) }, + onChoose = { request(Request.Choose(it)) }, + onEdit = { request(Request.Edit(it)) }, + ) + } } } suspend fun patchConfigs(configs: List, activeUuid: UUID?) { withContext(Dispatchers.Main) { - adapter.apply { - patchDataSet(this::configs, configs, id = { it.uuid }) - this.activeUuid = activeUuid - notifyItemRangeChanged(0, configs.size) - } + this@AppsStrategyDesign.configs = configs + this@AppsStrategyDesign.activeUuid = activeUuid } } fun requestCreate() { requests.trySend(Request.Create) } + + private fun request(request: Request) { + requests.trySend(request) + } + + init { + if (context is AppCompatActivity) { + context.window.decorView.setOnInsertsChangedListener { + if (surface.insets != it) surface.insets = it + insets = it + } + } + } } data class AppListsConfigEditResult( diff --git a/design/src/main/java/com/github/kr328/clash/design/Design.kt b/design/src/main/java/com/github/kr328/clash/design/Design.kt index 61d09f5698..b2ee738502 100644 --- a/design/src/main/java/com/github/kr328/clash/design/Design.kt +++ b/design/src/main/java/com/github/kr328/clash/design/Design.kt @@ -1,8 +1,11 @@ package com.github.kr328.clash.design import android.content.Context +import android.content.res.Configuration import android.view.View import androidx.appcompat.app.AppCompatActivity +import com.github.kr328.clash.design.model.DarkMode +import com.github.kr328.clash.design.store.UiStore import com.github.kr328.clash.design.ui.Surface import com.github.kr328.clash.design.ui.ToastDuration import com.github.kr328.clash.design.util.setOnInsertsChangedListener @@ -41,7 +44,21 @@ abstract class Design(val context: Context) : ToastDuration.Long -> Snackbar.LENGTH_LONG ToastDuration.Indefinite -> Snackbar.LENGTH_INDEFINITE } - ).apply(configure).show() + ).apply(configure).apply { + val dark = isDarkMode() + setBackgroundTint(if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt()) + setTextColor(if (dark) 0xFFFFFFFF.toInt() else 0xFF000000.toInt()) + setActionTextColor(if (dark) 0xFF0A84FF.toInt() else 0xFF007AFF.toInt()) + }.show() + } + } + + private fun isDarkMode(): Boolean { + val nightFlag = context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK + return when (UiStore(context).darkMode) { + DarkMode.ForceDark -> true + DarkMode.ForceLight -> false + DarkMode.Auto -> nightFlag == Configuration.UI_MODE_NIGHT_YES } } diff --git a/design/src/main/java/com/github/kr328/clash/design/FilesDesign.kt b/design/src/main/java/com/github/kr328/clash/design/FilesDesign.kt index cab7c2d926..78ed67be31 100644 --- a/design/src/main/java/com/github/kr328/clash/design/FilesDesign.kt +++ b/design/src/main/java/com/github/kr328/clash/design/FilesDesign.kt @@ -3,14 +3,22 @@ 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 androidx.appcompat.app.AppCompatActivity +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy import com.github.kr328.clash.design.dialog.requestModelTextInput +import com.github.kr328.clash.design.ios.ClashMetaXTheme +import com.github.kr328.clash.design.ios.FilesScreen +import com.github.kr328.clash.design.ios.iosConfirm import com.github.kr328.clash.design.model.File -import com.github.kr328.clash.design.util.* +import com.github.kr328.clash.design.ui.Insets +import com.github.kr328.clash.design.util.ValidatorFileName +import com.github.kr328.clash.design.util.setOnInsertsChangedListener import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext class FilesDesign(context: Context) : Design(context) { @@ -25,28 +33,49 @@ class FilesDesign(context: Context) : Design(context) { 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 + private var insets by mutableStateOf(Insets.EMPTY) + private var files by mutableStateOf>(emptyList()) + private var currentInBaseDir by mutableStateOf(true) + private var now by mutableStateOf(System.currentTimeMillis()) + + var configurationEditable by mutableStateOf(false) + + override val root: View = ComposeView(context).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + ClashMetaXTheme { + FilesScreen( + insets = insets, + title = context.getString(R.string.files), + files = files, + currentInBaseDir = currentInBaseDir, + configurationEditable = configurationEditable, + now = now, + onBack = { (context as? AppCompatActivity)?.onBackPressedDispatcher?.onBackPressed() }, + onNew = { requestNew() }, + onOpen = { requestOpen(it) }, + onImport = { requestImport(it) }, + onExport = { requestExport(it) }, + onRename = { requestRename(it) }, + onDelete = { requestDelete(it) }, + ) + } } + } - suspend fun swapFiles(files: List, currentInBaseDir: Boolean) { + suspend fun swapFiles(next: List, inBase: Boolean) { withContext(Dispatchers.Main) { - adapter.swapDataSet(adapter::files, files) - binding.currentInBaseDir = currentInBaseDir + files = next + currentInBaseDir = inBase } } + fun showAtRoot() { + currentInBaseDir = true + } + fun updateElapsed() { - adapter.updateElapsed() + now = System.currentTimeMillis() } suspend fun requestFileName(name: String): String { @@ -59,15 +88,30 @@ class FilesDesign(context: Context) : Design(context) { ) } - init { - binding.self = this + fun requestRename(dialog: Dialog, file: File) { + dialog.dismiss() + requestRename(file) + } - binding.activityBarLayout.applyFrom(context) + fun requestImport(dialog: Dialog, file: File) { + dialog.dismiss() + requestImport(file) + } - binding.mainList.recyclerList.also { - it.applyLinearAdapter(context, adapter) - it.bindAppBarElevation(binding.activityBarLayout) - } + fun requestExport(dialog: Dialog, file: File) { + dialog.dismiss() + requestExport(file) + } + + fun requestDelete(dialog: Dialog, file: File) { + dialog.dismiss() + requestDelete(file) + } + + fun requestNew() { + // Root stack is empty. ImportFile(null) calls stack.last() and throws. + if (currentInBaseDir) return + requests.trySend(Request.ImportFile(null)) } private fun requestOpen(file: File) { @@ -78,46 +122,47 @@ class FilesDesign(context: Context) : Design(context) { } } - fun requestRename(dialog: Dialog, file: File) { + private fun requestRename(file: File) { requests.trySend(Request.RenameFile(file)) - - dialog.dismiss() } - fun requestImport(dialog: Dialog, file: File) { - requests.trySend(Request.ImportFile(file)) - - dialog.dismiss() + private fun requestImport(file: File) { + confirm(context.getString(R.string.import_), file.name, destructive = true) { + requests.trySend(Request.ImportFile(file)) + } } - fun requestExport(dialog: Dialog, file: File) { + private fun requestExport(file: File) { requests.trySend(Request.ExportFile(file)) - - dialog.dismiss() } - fun requestDelete(dialog: Dialog, file: File) { - requests.trySend(Request.DeleteFile(file)) - - dialog.dismiss() + private fun requestDelete(file: File) { + confirm(context.getString(R.string.delete), file.name, destructive = true) { + requests.trySend(Request.DeleteFile(file)) + } } - fun requestNew() { - requests.trySend(Request.ImportFile(null)) + private fun confirm(title: String, message: String, destructive: Boolean, block: () -> Unit) { + launch { + val accepted = withContext(Dispatchers.Main) { + iosConfirm( + context, + title, + message, + context.getString(R.string.ok), + destructive, + ) + } + if (accepted) block() + } } - 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() + init { + if (context is AppCompatActivity) { + context.window.decorView.setOnInsertsChangedListener { + if (surface.insets != it) surface.insets = it + insets = it + } + } } } diff --git a/design/src/main/java/com/github/kr328/clash/design/HelpDesign.kt b/design/src/main/java/com/github/kr328/clash/design/HelpDesign.kt index 6a3cc38558..c0d9c5b30a 100644 --- a/design/src/main/java/com/github/kr328/clash/design/HelpDesign.kt +++ b/design/src/main/java/com/github/kr328/clash/design/HelpDesign.kt @@ -1,79 +1,58 @@ package com.github.kr328.clash.design +import android.app.Activity 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 +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import com.github.kr328.clash.design.ios.ClashMetaXTheme +import com.github.kr328.clash.design.ios.HelpScreen +import com.github.kr328.clash.design.ui.Insets +import com.github.kr328.clash.design.util.setOnInsertsChangedListener class HelpDesign( context: Context, - openLink: (Uri) -> Unit, + private val openLink: (Uri) -> Unit, ) : Design(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) - - 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))) - } + private var insets by mutableStateOf(Insets.EMPTY) + + override val root: View = ComposeView(context).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + ClashMetaXTheme { + HelpScreen( + insets = insets, + title = context.pushedTitle(R.string.help), + onBack = { (context as? AppCompatActivity)?.onBackPressedDispatcher?.onBackPressed() }, + onClashWiki = { open(R.string.clash_wiki_url) }, + onClashMetaWiki = { open(R.string.clash_meta_wiki_url) }, + onClashMetaCore = { open(R.string.clash_meta_core_url) }, + onRepository = { open(R.string.meta_github_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))) - } - } + private fun open(url: Int) { + openLink(Uri.parse(context.getString(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))) - } + init { + if (context is AppCompatActivity) { + context.window.decorView.setOnInsertsChangedListener { + if (surface.insets != it) surface.insets = it + insets = it } } - - binding.content.addView(screen.root) } -} \ No newline at end of file +} + +private fun Context.pushedTitle(fallback: Int): String { + val current = (this as? Activity)?.title?.toString()?.ifBlank { null } + return current ?: getString(fallback) +} diff --git a/design/src/main/java/com/github/kr328/clash/design/LogcatDesign.kt b/design/src/main/java/com/github/kr328/clash/design/LogcatDesign.kt index fe06bbf932..4afc4655ea 100644 --- a/design/src/main/java/com/github/kr328/clash/design/LogcatDesign.kt +++ b/design/src/main/java/com/github/kr328/clash/design/LogcatDesign.kt @@ -1,16 +1,24 @@ package com.github.kr328.clash.design +import android.app.Activity import android.content.ClipData import android.content.ClipboardManager import android.content.Context import android.view.View +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy 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.ios.ClashMetaXTheme +import com.github.kr328.clash.design.ios.LogcatScreen +import com.github.kr328.clash.design.ios.iosConfirm +import com.github.kr328.clash.design.ui.Insets import com.github.kr328.clash.design.ui.ToastDuration -import com.github.kr328.clash.design.util.* +import com.github.kr328.clash.design.util.setOnInsertsChangedListener import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -18,53 +26,87 @@ import kotlinx.coroutines.withContext class LogcatDesign( context: Context, private val streaming: Boolean, + private val fileName: String? = null, ) : Design(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()?.setPrimaryClip(data) + private var insets by mutableStateOf(Insets.EMPTY) + private var messages by mutableStateOf>(emptyList()) + private var level by mutableStateOf(null) + private var keyword by mutableStateOf("") + private var confirmingDelete = false - showToast(R.string.copied, ToastDuration.Short) + override val root: View = ComposeView(context).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + val title = (context as? Activity)?.title?.toString() + ?.ifBlank { null } + ?: context.getString(R.string.logcat) + setContent { + ClashMetaXTheme { + LogcatScreen( + insets = insets, + title = title, + streaming = streaming, + messages = messages, + level = level, + keyword = keyword, + onBack = { (context as? AppCompatActivity)?.onBackPressedDispatcher?.onBackPressed() }, + onKeyword = { keyword = it }, + onLevel = { level = it }, + onClose = { requests.trySend(Request.Close) }, + onDelete = { requestDelete() }, + onExport = { requests.trySend(Request.Export) }, + onCopy = { copyMessage(it) }, + ) + } } } + // messages is the whole buffer, index 0 oldest. removed/appended are not a patch. + @Suppress("UNUSED_PARAMETER") suspend fun patchMessages(messages: List, removed: Int, appended: Int) { withContext(Dispatchers.Main) { - adapter.messages = messages - - adapter.notifyItemRangeInserted(adapter.messages.size, appended) - adapter.notifyItemRangeRemoved(0, removed) + this@LogcatDesign.messages = messages + } + } - if (streaming && binding.recyclerList.isTop) { - binding.recyclerList.scrollToPosition(messages.size - 1) + private fun requestDelete() { + if (streaming || confirmingDelete) return + confirmingDelete = true + launch { + try { + val confirmed = withContext(Dispatchers.Main) { + iosConfirm( + context = context, + title = context.getString(R.string.delete), + message = fileName ?: context.getString(R.string.delete), + confirm = context.getString(R.string.delete), + destructive = true, + ) + } + if (confirmed) requests.trySend(Request.Delete) + } finally { + confirmingDelete = false } } } - override val root: View - get() = binding.root + private fun copyMessage(message: LogMessage) { + launch { + val data = ClipData.newPlainText("log_message", message.message) + context.getSystemService()?.setPrimaryClip(data) + showToast(R.string.copied, ToastDuration.Short) + } + } 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 + if (context is AppCompatActivity) { + context.window.decorView.setOnInsertsChangedListener { + if (surface.insets != it) surface.insets = it + insets = it } } - binding.recyclerList.adapter = adapter } -} \ No newline at end of file +} diff --git a/design/src/main/java/com/github/kr328/clash/design/LogsDesign.kt b/design/src/main/java/com/github/kr328/clash/design/LogsDesign.kt index 64a17e60f5..78bfda183a 100644 --- a/design/src/main/java/com/github/kr328/clash/design/LogsDesign.kt +++ b/design/src/main/java/com/github/kr328/clash/design/LogsDesign.kt @@ -1,16 +1,22 @@ package com.github.kr328.clash.design +import android.app.Activity 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 androidx.appcompat.app.AppCompatActivity +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import com.github.kr328.clash.design.ios.ClashMetaXTheme +import com.github.kr328.clash.design.ios.LogsScreen +import com.github.kr328.clash.design.ios.iosConfirm import com.github.kr328.clash.design.model.LogFile -import com.github.kr328.clash.design.util.* -import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.github.kr328.clash.design.ui.Insets +import com.github.kr328.clash.design.util.setOnInsertsChangedListener import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext -import kotlin.coroutines.resume class LogsDesign(context: Context) : Design(context) { sealed class Request { @@ -20,38 +26,53 @@ class LogsDesign(context: Context) : Design(context) { 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)) - } + private var insets by mutableStateOf(Insets.EMPTY) + private var files by mutableStateOf>(emptyList()) - override val root: View - get() = binding.root + override val root: View = ComposeView(context).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + val title = (context as? Activity)?.title?.toString() + ?.ifBlank { null } + ?: context.getString(R.string.logs) + setContent { + ClashMetaXTheme { + LogsScreen( + insets = insets, + title = title, + files = files, + onBack = { (context as? AppCompatActivity)?.onBackPressedDispatcher?.onBackPressed() }, + onStart = { requests.trySend(Request.StartLogcat) }, + onDeleteAll = { requests.trySend(Request.DeleteAll) }, + onOpen = { requests.trySend(Request.OpenFile(it)) }, + ) + } + } + } suspend fun patchLogs(logs: List) { - adapter.patchDataSet(adapter::logs, logs, false, LogFile::fileName) + withContext(Dispatchers.Main) { + files = logs + } } 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) } - } + iosConfirm( + context = context, + title = context.getString(R.string.delete_all_logs), + message = context.getString(R.string.delete_all_logs_warn), + confirm = context.getString(R.string.ok), + destructive = true, + ) } } init { - binding.self = this - - binding.activityBarLayout.applyFrom(context) - - binding.recyclerList.applyLinearAdapter(context, adapter) + if (context is AppCompatActivity) { + context.window.decorView.setOnInsertsChangedListener { + if (surface.insets != it) surface.insets = it + insets = it + } + } } -} \ No newline at end of file +} diff --git a/design/src/main/java/com/github/kr328/clash/design/MainDesign.kt b/design/src/main/java/com/github/kr328/clash/design/MainDesign.kt index d699cc6545..47684a8876 100644 --- a/design/src/main/java/com/github/kr328/clash/design/MainDesign.kt +++ b/design/src/main/java/com/github/kr328/clash/design/MainDesign.kt @@ -2,74 +2,126 @@ package com.github.kr328.clash.design import android.content.Context import android.view.View +import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AlertDialog +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy import com.github.kr328.clash.core.model.TunnelState +import com.github.kr328.clash.core.util.downloadBytes +import com.github.kr328.clash.core.util.trafficDownload import com.github.kr328.clash.core.util.trafficTotal +import com.github.kr328.clash.core.util.trafficUpload +import com.github.kr328.clash.core.util.uploadBytes import com.github.kr328.clash.design.databinding.DesignAboutBinding -import com.github.kr328.clash.design.databinding.DesignMainBinding +import com.github.kr328.clash.design.ios.ClashMetaXTheme +import com.github.kr328.clash.design.ios.HomeScreen +import com.github.kr328.clash.design.ios.HomeUiState +import com.github.kr328.clash.design.ios.TrafficRates +import com.github.kr328.clash.design.ios.TrafficSample +import com.github.kr328.clash.design.ui.Insets import com.github.kr328.clash.design.util.layoutInflater -import com.github.kr328.clash.design.util.resolveThemedColor -import com.github.kr328.clash.design.util.root +import com.github.kr328.clash.design.util.setOnInsertsChangedListener import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext class MainDesign(context: Context) : Design(context) { - enum class Request { - ToggleStatus, - OpenProxy, - OpenProfiles, - OpenProviders, - OpenAppsStrategy, - OpenLogs, - OpenSettings, - OpenHelp, - OpenAbout, + sealed interface Request { + data object ToggleStatus : Request + data class OpenProxy(val group: String?) : Request + data object OpenProfiles : Request + data object OpenProviders : Request + data object OpenAppsStrategy : Request + data object OpenLogs : Request + data object OpenSettings : Request + data object OpenHelp : Request + data object OpenAbout : Request + data class SetMode(val mode: TunnelState.Mode) : Request } - private val binding = DesignMainBinding - .inflate(context.layoutInflater, context.root, false) + private var state by mutableStateOf(HomeUiState()) + private var insets by mutableStateOf(Insets.EMPTY) - override val root: View - get() = binding.root + override val root: View = ComposeView(context).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + ClashMetaXTheme { + HomeScreen( + insets = insets, + state = state, + onToggle = { request(Request.ToggleStatus) }, + onMode = { request(Request.SetMode(it)) }, + onProfiles = { request(Request.OpenProfiles) }, + onApps = { request(Request.OpenAppsStrategy) }, + onProxy = { group -> request(Request.OpenProxy(group)) }, + onProviders = { request(Request.OpenProviders) }, + onLogs = { request(Request.OpenLogs) }, + onSettings = { request(Request.OpenSettings) }, + onHelp = { request(Request.OpenHelp) }, + onAbout = { request(Request.OpenAbout) }, + ) + } + } + } suspend fun setProfileName(name: String?) { withContext(Dispatchers.Main) { - binding.profileName = name + state = state.copy(profileName = name) } } suspend fun setAppsStrategyName(name: String?) { withContext(Dispatchers.Main) { - binding.appsStrategyName = name + state = state.copy(appsStrategyName = name) } } suspend fun setClashRunning(running: Boolean) { withContext(Dispatchers.Main) { - binding.clashRunning = running + state = if (running) { + state.copy(running = true) + } else { + state.copy(running = false, currentNode = null, currentGroup = null, traffic = TrafficRates()) + } + } + } + + suspend fun setTraffic(now: Long, total: Long) { + withContext(Dispatchers.Main) { + val sample = TrafficSample( + up = now.uploadBytes().toFloat(), + down = now.downloadBytes().toFloat(), + ) + state = state.copy( + forwarded = total.trafficTotal(), + traffic = state.traffic.copy( + upload = now.trafficUpload(), + download = now.trafficDownload(), + uploadTotal = total.trafficUpload(), + downloadTotal = total.trafficDownload(), + samples = (state.traffic.samples + sample).takeLast(36), + ), + ) } } - suspend fun setForwarded(value: Long) { + suspend fun setCurrentNode(name: String?, group: String?) { withContext(Dispatchers.Main) { - binding.forwarded = value.trafficTotal() + state = state.copy(currentNode = name, currentGroup = group) } } 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) - } + state = state.copy(mode = mode) } } suspend fun setHasProviders(has: Boolean) { withContext(Dispatchers.Main) { - binding.hasProviders = has + state = state.copy(hasProviders = has) } } @@ -81,18 +133,21 @@ class MainDesign(context: Context) : Design(context) { AlertDialog.Builder(context) .setView(binding.root) + .setPositiveButton(context.getString(R.string.ok), null) .show() } } - init { - binding.self = this - - binding.colorClashStarted = context.resolveThemedColor(com.google.android.material.R.attr.colorPrimary) - binding.colorClashStopped = context.resolveThemedColor(R.attr.colorClashStopped) + private fun request(request: Request) { + requests.trySend(request) } - fun request(request: Request) { - requests.trySend(request) + init { + if (context is AppCompatActivity) { + context.window.decorView.setOnInsertsChangedListener { + if (surface.insets != it) surface.insets = it + insets = it + } + } } -} \ No newline at end of file +} diff --git a/design/src/main/java/com/github/kr328/clash/design/MetaFeatureSettingsDesign.kt b/design/src/main/java/com/github/kr328/clash/design/MetaFeatureSettingsDesign.kt index d2b634f1d4..cdb119dfa8 100644 --- a/design/src/main/java/com/github/kr328/clash/design/MetaFeatureSettingsDesign.kt +++ b/design/src/main/java/com/github/kr328/clash/design/MetaFeatureSettingsDesign.kt @@ -1,349 +1,384 @@ package com.github.kr328.clash.design +import android.app.Activity import android.content.ClipData import android.content.ClipboardManager import android.content.Context import android.view.View +import androidx.annotation.StringRes +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.core.content.getSystemService import androidx.core.widget.doOnTextChanged import com.github.kr328.clash.core.Clash import com.github.kr328.clash.core.model.ConfigurationOverride -import com.github.kr328.clash.design.databinding.DesignSettingsMetaFeatureBinding +import com.github.kr328.clash.design.adapter.EditableTextListAdapter import com.github.kr328.clash.design.databinding.DialogAgeKeyHelperBinding -import com.github.kr328.clash.design.preference.* +import com.github.kr328.clash.design.dialog.requestModelTextInput +import com.github.kr328.clash.design.ios.ClashMetaXTheme +import com.github.kr328.clash.design.ios.MetaFeatureSettingsScreen +import com.github.kr328.clash.design.ios.SettingGroup +import com.github.kr328.clash.design.ios.SettingRow +import com.github.kr328.clash.design.ios.iosConfirm +import com.github.kr328.clash.design.preference.EditableListOverlayResult +import com.github.kr328.clash.design.preference.TextAdapter +import com.github.kr328.clash.design.preference.requestEditableListOverlay +import com.github.kr328.clash.design.ui.Insets import com.github.kr328.clash.design.ui.ToastDuration -import com.github.kr328.clash.design.util.* +import com.github.kr328.clash.design.util.layoutInflater +import com.github.kr328.clash.design.util.root +import com.github.kr328.clash.design.util.setOnInsertsChangedListener import com.google.android.material.dialog.MaterialAlertDialogBuilder import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlin.coroutines.resume +import kotlinx.coroutines.withContext class MetaFeatureSettingsDesign( context: Context, - configuration: ConfigurationOverride + private val configuration: ConfigurationOverride, ) : Design(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() + private val booleanValues: Array = arrayOf(null, true, false) + private val booleanTexts: Array = arrayOf( + R.string.dont_modify, + R.string.enabled, + R.string.disabled, + ) + + private val findProcessValues: Array = arrayOf( + null, + ConfigurationOverride.FindProcessMode.Off, + ConfigurationOverride.FindProcessMode.Strict, + ConfigurationOverride.FindProcessMode.Always, + ) + private val findProcessTexts: Array = arrayOf( + R.string.dont_modify, + R.string.off, + R.string.strict, + R.string.always, + ) + + private val snifferStrategyValues: Array = arrayOf(null, true, false) + private val snifferStrategyTexts: Array = arrayOf( + R.string.dont_modify, + R.string.enabled, + R.string.disabled, + ) + + private var insets by mutableStateOf(Insets.EMPTY) + private var groups by mutableStateOf>(emptyList()) + + override val root: View = ComposeView(context).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + val title = (context as? Activity)?.title?.toString()?.ifBlank { null } + ?: context.getString(R.string.meta_features) + ClashMetaXTheme { + MetaFeatureSettingsScreen( + insets = insets, + title = title, + groups = groups, + onBack = { + (context as? AppCompatActivity)?.onBackPressedDispatcher?.onBackPressed() + }, + onReset = { requestClear() }, + ) } } } - init { - binding.self = this - - binding.activityBarLayout.applyFrom(context) - - binding.scrollRoot.bindAppBarElevation(binding.activityBarLayout) - - val booleanValues: Array = arrayOf( - null, - true, - false - ) - val booleanValuesText: Array = arrayOf( - R.string.dont_modify, - R.string.enabled, - R.string.disabled + suspend fun requestResetConfirm(): Boolean { + return iosConfirm( + context = context, + title = context.getString(R.string.reset_override_settings), + message = context.getString(R.string.reset_override_settings_message), + confirm = context.getString(R.string.ok), + destructive = true, ) + } - val screen = preferenceScreen(context) { - category(R.string.age_key_category) - - clickable( - title = R.string.age_key_type_x25519, - summary = R.string.age_key_generate_summary, - ) { - clicked { - requestAgeKeyHelper(hybrid = false) - } - } + fun requestClear() { + requests.trySend(Request.ResetOverride) + } - clickable( - title = R.string.age_key_type_hybrid, - summary = R.string.age_key_generate_summary, - ) { - clicked { - requestAgeKeyHelper(hybrid = true) - } + init { + if (context is AppCompatActivity) { + context.window.decorView.setOnInsertsChangedListener { + if (surface.insets != it) surface.insets = it + insets = it } + } + publish() + } - 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, - ) + private fun publish() { + groups = snapshot() + } - selectableList( - value = configuration::findProcessMode, - values = arrayOf( - null, - ConfigurationOverride.FindProcessMode.Off, - ConfigurationOverride.FindProcessMode.Strict, - ConfigurationOverride.FindProcessMode.Always + private fun snapshot(): List { + // 只有 false(已禁用)关掉其余嗅探行,第一次进入也按这个值。 + val sniffEditable = configuration.sniffer.enable != false + return listOf( + SettingGroup( + title = text(R.string.age_key_category), + rows = listOf( + valueRow( + R.string.age_key_type_x25519, + text(R.string.age_key_generate_summary), + ) { requestAgeKeyHelper(hybrid = false) }, + valueRow( + R.string.age_key_type_hybrid, + text(R.string.age_key_generate_summary), + ) { requestAgeKeyHelper(hybrid = true) }, ), - valuesText = arrayOf( - R.string.dont_modify, - R.string.off, - R.string.strict, - R.string.always, + ), + SettingGroup( + title = text(R.string.settings), + rows = listOf( + choice( + R.string.unified_delay, + configuration.unifiedDelay, + booleanValues, + booleanTexts, + ) { configuration.unifiedDelay = it }, + choice( + R.string.geodata_mode, + configuration.geodataMode, + booleanValues, + booleanTexts, + ) { configuration.geodataMode = it }, + choice( + R.string.tcp_concurrent, + configuration.tcpConcurrent, + booleanValues, + booleanTexts, + ) { configuration.tcpConcurrent = it }, + choice( + R.string.find_process_mode, + configuration.findProcessMode, + findProcessValues, + findProcessTexts, + ) { configuration.findProcessMode = it }, ), - title = R.string.find_process_mode, - ) { - - } - - category(R.string.sniffer_setting) - - val snifferDependencies: MutableList = mutableListOf() - - val sniffer = selectableList( - value = configuration.sniffer::enable, - values = arrayOf( - null, - true, - false + ), + SettingGroup( + title = text(R.string.sniffer_setting), + rows = listOf( + choice( + R.string.sniff_strategy, + configuration.sniffer.enable, + snifferStrategyValues, + snifferStrategyTexts, + ) { configuration.sniffer.enable = it }, + listRow( + R.string.sniff_http_ports, + { configuration.sniffer.sniff.http.ports }, + { configuration.sniffer.sniff.http.ports = it }, + sniffEditable, + ), + choice( + R.string.sniff_http_override_destination, + configuration.sniffer.sniff.http.overrideDestination, + booleanValues, + booleanTexts, + sniffEditable, + ) { configuration.sniffer.sniff.http.overrideDestination = it }, + listRow( + R.string.sniff_tls_ports, + { configuration.sniffer.sniff.tls.ports }, + { configuration.sniffer.sniff.tls.ports = it }, + sniffEditable, + ), + choice( + R.string.sniff_tls_override_destination, + configuration.sniffer.sniff.tls.overrideDestination, + booleanValues, + booleanTexts, + sniffEditable, + ) { configuration.sniffer.sniff.tls.overrideDestination = it }, + listRow( + R.string.sniff_quic_ports, + { configuration.sniffer.sniff.quic.ports }, + { configuration.sniffer.sniff.quic.ports = it }, + sniffEditable, + ), + choice( + R.string.sniff_quic_override_destination, + configuration.sniffer.sniff.quic.overrideDestination, + booleanValues, + booleanTexts, + sniffEditable, + ) { configuration.sniffer.sniff.quic.overrideDestination = it }, + choice( + R.string.force_dns_mapping, + configuration.sniffer.forceDnsMapping, + booleanValues, + booleanTexts, + sniffEditable, + ) { configuration.sniffer.forceDnsMapping = it }, + choice( + R.string.parse_pure_ip, + configuration.sniffer.parsePureIp, + booleanValues, + booleanTexts, + sniffEditable, + ) { configuration.sniffer.parsePureIp = it }, + choice( + R.string.override_destination, + configuration.sniffer.overrideDestination, + booleanValues, + booleanTexts, + sniffEditable, + ) { configuration.sniffer.overrideDestination = it }, + listRow( + R.string.force_domain, + { configuration.sniffer.forceDomain }, + { configuration.sniffer.forceDomain = it }, + sniffEditable, + ), + listRow( + R.string.skip_domain, + { configuration.sniffer.skipDomain }, + { configuration.sniffer.skipDomain = it }, + sniffEditable, + ), + listRow( + R.string.skip_src_address, + { configuration.sniffer.skipSrcAddress }, + { configuration.sniffer.skipSrcAddress = it }, + sniffEditable, + ), + listRow( + R.string.skip_dst_address, + { configuration.sniffer.skipDstAddress }, + { configuration.sniffer.skipDstAddress = it }, + sniffEditable, + ), ), - valuesText = arrayOf( - R.string.dont_modify, - R.string.enabled, - R.string.disabled + ), + SettingGroup( + title = text(R.string.geox_files), + rows = listOf( + valueRow(R.string.import_geoip_file, text(R.string.press_to_import)) { + requests.trySend(Request.ImportGeoIp) + }, + valueRow(R.string.import_geosite_file, text(R.string.press_to_import)) { + requests.trySend(Request.ImportGeoSite) + }, + valueRow(R.string.import_country_file, text(R.string.press_to_import)) { + requests.trySend(Request.ImportCountry) + }, + valueRow(R.string.import_asn_file, text(R.string.press_to_import)) { + requests.trySend(Request.ImportASN) + }, ), - title = R.string.strategy - ) { - listener = OnChangedListener { - if (configuration.sniffer.enable == false) { - snifferDependencies.forEach { - it.enabled = false - } - } else { - snifferDependencies.forEach { - it.enabled = true - } + ), + ) + } + + private fun choice( + @StringRes title: Int, + value: T, + values: Array, + texts: Array, + enabled: Boolean = true, + write: (T) -> Unit, + ): SettingRow { + val index = values.indexOf(value) + val labels = texts.map { text(it) } + return SettingRow( + title = text(title), + summary = labels.getOrNull(index).orEmpty(), + enabled = enabled, + selectable = true, + options = labels, + selected = index, + onSelect = { picked -> + if (picked in values.indices) { + val next = values[picked] + launch(Dispatchers.Main) { + withContext(Dispatchers.IO) { write(next) } + publish() } } - } - - editableTextList( - value = configuration.sniffer.sniff.http::ports, - adapter = TextAdapter.String, - title = R.string.sniff_http_ports, - placeholder = R.string.dont_modify, - configure = snifferDependencies::add, - ) - - selectableList( - value = configuration.sniffer.sniff.http::overrideDestination, - values = booleanValues, - valuesText = booleanValuesText, - title = R.string.sniff_http_override_destination, - configure = snifferDependencies::add, - ) - - editableTextList( - value = configuration.sniffer.sniff.tls::ports, - adapter = TextAdapter.String, - title = R.string.sniff_tls_ports, - placeholder = R.string.dont_modify, - configure = snifferDependencies::add, - ) - - selectableList( - value = configuration.sniffer.sniff.tls::overrideDestination, - values = booleanValues, - valuesText = booleanValuesText, - title = R.string.sniff_tls_override_destination, - configure = snifferDependencies::add, - ) - - editableTextList( - value = configuration.sniffer.sniff.quic::ports, - adapter = TextAdapter.String, - title = R.string.sniff_quic_ports, - placeholder = R.string.dont_modify, - configure = snifferDependencies::add, - ) - - selectableList( - value = configuration.sniffer.sniff.quic::overrideDestination, - values = booleanValues, - valuesText = booleanValuesText, - title = R.string.sniff_quic_override_destination, - 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::skipSrcAddress, - adapter = TextAdapter.String, - title = R.string.skip_src_address, - placeholder = R.string.dont_modify, - configure = snifferDependencies::add, - ) - - editableTextList( - value = configuration.sniffer::skipDstAddress, - adapter = TextAdapter.String, - title = R.string.skip_dst_address, - placeholder = R.string.dont_modify, - configure = snifferDependencies::add, - ) + }, + ) + } - sniffer.listener?.onChanged() + private fun listRow( + @StringRes title: Int, + read: () -> List?, + write: (List?) -> Unit, + enabled: Boolean = true, + ) = valueRow(title, elements(read()?.size), enabled) { + editList(title, read, write) + } - /* - category(R.string.geox_url_setting) + private fun valueRow( + @StringRes title: Int, + summary: String, + enabled: Boolean = true, + onClick: () -> Unit, + ) = SettingRow( + title = text(title), + summary = summary, + enabled = enabled, + onClick = onClick, + ) + + private fun elements(size: Int?): String = when (size) { + null -> text(R.string.dont_modify) + 0 -> text(R.string.empty) + else -> context.getString(R.string.format_elements, size) + } - val geoxUrlDependencies: MutableList = mutableListOf() + private fun text(@StringRes id: Int): String = context.getString(id) - 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, - ) + private fun editList( + @StringRes titleRes: Int, + read: () -> List?, + write: (List?) -> Unit, + ) { + launch(Dispatchers.Main) { + val title = context.getText(titleRes) + val initial = withContext(Dispatchers.IO) { read() } + val updated = requestTextList(initial, title) + withContext(Dispatchers.IO) { write(updated) } + publish() + } + } - 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, + private suspend fun requestTextList( + initialValue: List?, + title: CharSequence, + ): List? { + val recyclerAdapter = EditableTextListAdapter( + context, + initialValue?.toMutableList() ?: mutableListOf(), + TextAdapter.String, + ) + val result = requestEditableListOverlay(context, recyclerAdapter, title) { + val entered = context.requestModelTextInput( + initial = "", + title = title, + hint = title, ) - */ - - 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) - } + if (entered.isNotBlank()) { + recyclerAdapter.addElement(entered) } } - - binding.content.addView(screen.root) + return when (result) { + EditableListOverlayResult.Cancel -> initialValue + EditableListOverlayResult.Apply -> recyclerAdapter.values + EditableListOverlayResult.Reset -> null + } } private fun requestAgeKeyHelper(hybrid: Boolean) { @@ -414,8 +449,4 @@ class MetaFeatureSettingsDesign( dialog.show() } } - - fun requestClear() { - requests.trySend(Request.ResetOverride) - } } diff --git a/design/src/main/java/com/github/kr328/clash/design/NetworkSettingsDesign.kt b/design/src/main/java/com/github/kr328/clash/design/NetworkSettingsDesign.kt index ae653fbb14..6548b40baf 100644 --- a/design/src/main/java/com/github/kr328/clash/design/NetworkSettingsDesign.kt +++ b/design/src/main/java/com/github/kr328/clash/design/NetworkSettingsDesign.kt @@ -1,24 +1,33 @@ package com.github.kr328.clash.design +import android.app.Activity import android.content.Context import android.os.Build import android.view.View -import com.github.kr328.clash.design.databinding.DesignSettingsCommonBinding -import com.github.kr328.clash.design.preference.* +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import com.github.kr328.clash.design.ios.ClashMetaXTheme +import com.github.kr328.clash.design.ios.NetworkSettingsScreen +import com.github.kr328.clash.design.ios.NetworkSettingsUiState import com.github.kr328.clash.design.store.UiStore +import com.github.kr328.clash.design.ui.Insets import com.github.kr328.clash.design.ui.ToastDuration -import com.github.kr328.clash.design.util.applyFrom -import com.github.kr328.clash.design.util.bindAppBarElevation -import com.github.kr328.clash.design.util.layoutInflater -import com.github.kr328.clash.design.util.root +import com.github.kr328.clash.design.util.setOnInsertsChangedListener import com.github.kr328.clash.service.model.AccessControlMode import com.github.kr328.clash.service.store.ServiceStore +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlin.reflect.KMutableProperty0 class NetworkSettingsDesign( context: Context, - uiStore: UiStore, - srvStore: ServiceStore, + private val uiStore: UiStore, + private val srvStore: ServiceStore, running: Boolean, ) : Design(context) { enum class Request { @@ -26,134 +35,162 @@ class NetworkSettingsDesign( AccessControlChanged, } - private val binding = DesignSettingsCommonBinding - .inflate(context.layoutInflater, context.root, false) - - override val root: View - get() = binding.root + private var insets by mutableStateOf(Insets.EMPTY) + private var state by mutableStateOf( + NetworkSettingsUiState( + enableVpn = uiStore.enableVpn, + bypassPrivateNetwork = srvStore.bypassPrivateNetwork, + dnsHijacking = srvStore.dnsHijacking, + allowBypass = srvStore.allowBypass, + allowIpv6 = srvStore.allowIpv6, + systemProxy = srvStore.systemProxy, + showSystemProxy = Build.VERSION.SDK_INT >= 29, + tunStackMode = srvStore.tunStackMode, + accessControlMode = srvStore.accessControlMode, + running = running, + activeStrategyName = readActiveStrategyName(), + ) + ) + + private fun readActiveStrategyName(): String? { + val uuid = srvStore.activeAppsStrategyConfigUuid ?: return null + return srvStore.appsStrategyConfigs + .find { it.uuid == uuid } + ?.name + ?.takeIf { it.isNotBlank() } + } - init { - binding.surface = surface - - binding.activityBarLayout.applyFrom(context) - - binding.scrollRoot.bindAppBarElevation(binding.activityBarLayout) - - val screen = preferenceScreen(context) { - val vpnDependencies: MutableList = 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 - } - } + override val root: View = ComposeView(context).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + val title = (context as? Activity)?.title?.toString() + ?.ifBlank { null } + ?: context.getString(R.string.network) + setContent { + ClashMetaXTheme { + NetworkSettingsScreen( + insets = insets, + title = title, + state = state, + onBack = { (context as? AppCompatActivity)?.onBackPressedDispatcher?.onBackPressed() }, + onEnableVpn = ::setEnableVpn, + onVpnBlocked = ::showOptionsUnavailable, + onBypassPrivateNetwork = ::setBypassPrivateNetwork, + onDnsHijacking = ::setDnsHijacking, + onAllowBypass = ::setAllowBypass, + onAllowIpv6 = ::setAllowIpv6, + onSystemProxy = ::setSystemProxy, + onTunStack = ::setTunStack, + onAccessControlMode = ::setAccessControlMode, + onAccessControlList = ::openAccessControlList, + ) } + } + } - 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, - ) + private fun setEnableVpn(value: Boolean) { + if (!state.vpnSwitchEnabled) { + showOptionsUnavailable() + return + } + launch(Dispatchers.Main) { + val stored = withContext(Dispatchers.IO) { + uiStore.enableVpn = value + uiStore.enableVpn } + state = state.copy(enableVpn = stored) + } + } - selectableList( - value = srvStore::tunStackMode, - values = arrayOf( - "system", - "gvisor", - "mixed", - "mips" - ), - valuesText = arrayOf( - R.string.tun_stack_system, - R.string.tun_stack_gvisor, - R.string.tun_stack_mixed, - R.string.tun_stack_mips - ), - 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, - ) { - vpnDependencies.add(this) - listener = OnChangedListener { - requests.trySend(Request.AccessControlChanged) - } + private fun setBypassPrivateNetwork(value: Boolean) { + editVpnBoolean(srvStore::bypassPrivateNetwork, value) { + state = state.copy(bypassPrivateNetwork = it) + } + } + + private fun setDnsHijacking(value: Boolean) { + editVpnBoolean(srvStore::dnsHijacking, value) { + state = state.copy(dnsHijacking = it) + } + } + + private fun setAllowBypass(value: Boolean) { + editVpnBoolean(srvStore::allowBypass, value) { + state = state.copy(allowBypass = it) + } + } + + private fun setAllowIpv6(value: Boolean) { + editVpnBoolean(srvStore::allowIpv6, value) { + state = state.copy(allowIpv6 = it) + } + } + + private fun setSystemProxy(value: Boolean) { + if (!state.showSystemProxy) return + editVpnBoolean(srvStore::systemProxy, value) { + state = state.copy(systemProxy = it) + } + } + + private fun setTunStack(value: String) { + if (!state.vpnDependenciesEnabled) return + launch(Dispatchers.Main) { + val stored = withContext(Dispatchers.IO) { + srvStore.tunStackMode = value + srvStore.tunStackMode } + state = state.copy(tunStackMode = stored) + } + } - clickable( - title = R.string.access_control_packages, - summary = R.string.access_control_packages_summary, - ) { - clicked { - requests.trySend(Request.StartAccessControlList) - } + private fun setAccessControlMode(value: AccessControlMode) { + if (!state.vpnDependenciesEnabled) return + launch(Dispatchers.Main) { + val stored = withContext(Dispatchers.IO) { + srvStore.accessControlMode = value + srvStore.accessControlMode } + state = state.copy(accessControlMode = stored) + requests.trySend(Request.AccessControlChanged) + } + } - if (running) { - vpn.enabled = false + private fun openAccessControlList() { + requests.trySend(Request.StartAccessControlList) + } - vpnDependencies.forEach { - it.enabled = false - } - } else { - vpn.listener?.onChanged() + private fun editVpnBoolean( + property: KMutableProperty0, + value: Boolean, + apply: (Boolean) -> Unit, + ) { + if (!state.vpnDependenciesEnabled) return + launch(Dispatchers.Main) { + val stored = withContext(Dispatchers.IO) { + property.set(value) + property.get() } + apply(stored) } + } - binding.content.addView(screen.root) + private fun showOptionsUnavailable() { + if (!root.isAttachedToWindow) { + root.post { showOptionsUnavailable() } + return + } + launch { + showToast(R.string.options_unavailable, ToastDuration.Indefinite) + } + } - if (running) { - launch { - showToast(R.string.options_unavailable, ToastDuration.Indefinite) + init { + if (context is AppCompatActivity) { + context.window.decorView.setOnInsertsChangedListener { + if (surface.insets != it) surface.insets = it + insets = it } } + if (running) showOptionsUnavailable() } -} \ No newline at end of file +} diff --git a/design/src/main/java/com/github/kr328/clash/design/NewProfileDesign.kt b/design/src/main/java/com/github/kr328/clash/design/NewProfileDesign.kt index 5526251186..4f863a798c 100644 --- a/design/src/main/java/com/github/kr328/clash/design/NewProfileDesign.kt +++ b/design/src/main/java/com/github/kr328/clash/design/NewProfileDesign.kt @@ -1,11 +1,21 @@ package com.github.kr328.clash.design +import android.app.Activity 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 androidx.appcompat.app.AppCompatActivity +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import com.github.kr328.clash.design.ios.ClashMetaXTheme +import com.github.kr328.clash.design.ios.NewProfileScreen import com.github.kr328.clash.design.model.ProfileProvider -import com.github.kr328.clash.design.util.* +import com.github.kr328.clash.design.ui.Insets +import com.github.kr328.clash.design.util.setOnInsertsChangedListener +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext class NewProfileDesign(context: Context) : Design(context) { sealed class Request { @@ -14,44 +24,46 @@ class NewProfileDesign(context: Context) : Design(cont data class LaunchScanner(val provider: ProfileProvider.QR) : Request() } - private val binding = DesignNewProfileBinding - .inflate(context.layoutInflater, context.root, false) - private val adapter = ProfileProviderAdapter(context, this::requestCreate, this::requestDetail) + private var insets by mutableStateOf(Insets.EMPTY) + private var querying by mutableStateOf(true) + private var externals by mutableStateOf>(emptyList()) - override val root: View - get() = binding.root - - suspend fun patchProviders(providers: List) { - adapter.apply { - patchDataSet(this::providers, providers) + override val root: View = ComposeView(context).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + val title = (context as? Activity)?.title?.toString()?.ifBlank { null } + ?: context.getString(R.string.create_profile) + setContent { + ClashMetaXTheme { + NewProfileScreen( + insets = insets, + title = title, + querying = querying, + externals = externals, + onBack = { (context as? AppCompatActivity)?.onBackPressedDispatcher?.onBackPressed() }, + onFile = { requests.trySend(Request.Create(ProfileProvider.File(context))) }, + onUrl = { requests.trySend(Request.Create(ProfileProvider.Url(context))) }, + onQr = { requests.trySend(Request.LaunchScanner(ProfileProvider.QR(context))) }, + onExternal = { requests.trySend(Request.Create(it)) }, + onDetail = { requests.trySend(Request.OpenDetail(it)) }, + ) + } } } - init { - binding.self = this - - binding.activityBarLayout.applyFrom(context) - - binding.mainList.recyclerList.also { - it.bindAppBarElevation(binding.activityBarLayout) - it.applyLinearAdapter(context, adapter) + suspend fun patchProviders(providers: List) { + val external = providers.filterIsInstance() + withContext(Dispatchers.Main) { + externals = external + querying = false } } - private fun requestCreate(provider: ProfileProvider) { - if (provider is ProfileProvider.QR) { - requests.trySend(Request.LaunchScanner(provider)) - } else { - requests.trySend(Request.Create(provider)) + init { + if (context is AppCompatActivity) { + context.window.decorView.setOnInsertsChangedListener { + if (surface.insets != it) surface.insets = it + insets = it + } } - - } - - private fun requestDetail(provider: ProfileProvider): Boolean { - if (provider !is ProfileProvider.External) return false - - requests.trySend(Request.OpenDetail(provider)) - - return true } -} \ No newline at end of file +} diff --git a/design/src/main/java/com/github/kr328/clash/design/OverrideSettingsDesign.kt b/design/src/main/java/com/github/kr328/clash/design/OverrideSettingsDesign.kt index ea4d41215c..da748e5235 100644 --- a/design/src/main/java/com/github/kr328/clash/design/OverrideSettingsDesign.kt +++ b/design/src/main/java/com/github/kr328/clash/design/OverrideSettingsDesign.kt @@ -1,419 +1,636 @@ package com.github.kr328.clash.design +import android.app.Activity import android.content.Context import android.view.View +import androidx.annotation.StringRes +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy 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.github.kr328.clash.design.adapter.EditableTextListAdapter +import com.github.kr328.clash.design.adapter.EditableTextMapAdapter +import com.github.kr328.clash.design.databinding.DialogEditableMapTextFieldBinding +import com.github.kr328.clash.design.dialog.requestModelTextInput +import com.github.kr328.clash.design.ios.ClashMetaXTheme +import com.github.kr328.clash.design.ios.OverrideSettingsScreen +import com.github.kr328.clash.design.ios.SettingGroup +import com.github.kr328.clash.design.ios.SettingRow +import com.github.kr328.clash.design.ios.iosConfirm +import com.github.kr328.clash.design.preference.EditableListOverlayResult +import com.github.kr328.clash.design.preference.NullableTextAdapter +import com.github.kr328.clash.design.preference.TextAdapter +import com.github.kr328.clash.design.preference.requestEditableListOverlay +import com.github.kr328.clash.design.ui.Insets +import com.github.kr328.clash.design.util.layoutInflater +import com.github.kr328.clash.design.util.root +import com.github.kr328.clash.design.util.setOnInsertsChangedListener import com.google.android.material.dialog.MaterialAlertDialogBuilder import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext import kotlin.coroutines.resume class OverrideSettingsDesign( context: Context, - configuration: ConfigurationOverride + private val configuration: ConfigurationOverride, ) : Design(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() + private val booleanValues: Array = arrayOf(null, true, false) + private val booleanTexts: Array = arrayOf( + R.string.dont_modify, + R.string.enabled, + R.string.disabled, + ) + + private val dnsStrategyValues: Array = arrayOf(null, true, false) + private val dnsStrategyTexts: Array = arrayOf( + R.string.dont_modify, + R.string.force_enable, + R.string.use_built_in, + ) + + private val modeValues: Array = arrayOf( + null, + TunnelState.Mode.Direct, + TunnelState.Mode.Global, + TunnelState.Mode.Rule, + ) + private val modeTexts: Array = arrayOf( + R.string.dont_modify, + R.string.direct_mode, + R.string.global_mode, + R.string.rule_mode, + ) + + private val logLevelValues: Array = arrayOf( + null, + LogMessage.Level.Info, + LogMessage.Level.Warning, + LogMessage.Level.Error, + LogMessage.Level.Debug, + LogMessage.Level.Silent, + ) + private val logLevelTexts: Array = arrayOf( + R.string.dont_modify, + R.string.info, + R.string.warning, + R.string.error, + R.string.debug, + R.string.silent, + ) + + private val enhancedModeValues: Array = arrayOf( + null, + ConfigurationOverride.DnsEnhancedMode.None, + ConfigurationOverride.DnsEnhancedMode.FakeIp, + ConfigurationOverride.DnsEnhancedMode.Mapping, + ) + private val enhancedModeTexts: Array = arrayOf( + R.string.dont_modify, + R.string.disabled, + R.string.fakeip, + R.string.mapping, + ) + + private val filterModeValues: Array = arrayOf( + null, + ConfigurationOverride.FilterMode.BlackList, + ConfigurationOverride.FilterMode.WhiteList, + ConfigurationOverride.FilterMode.Rule, + ) + private val filterModeTexts: Array = arrayOf( + R.string.dont_modify, + R.string.blacklist, + R.string.whitelist, + R.string.rule, + ) + + private var insets by mutableStateOf(Insets.EMPTY) + private var groups by mutableStateOf>(emptyList()) + + override val root: View = ComposeView(context).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + val title = (context as? Activity)?.title?.toString()?.ifBlank { null } + ?: context.getString(R.string.override) + ClashMetaXTheme { + OverrideSettingsScreen( + insets = insets, + title = title, + groups = groups, + onBack = { + (context as? AppCompatActivity)?.onBackPressedDispatcher?.onBackPressed() + }, + onReset = { requestClear() }, + ) } } } - init { - binding.self = this - - binding.activityBarLayout.applyFrom(context) - - binding.scrollRoot.bindAppBarElevation(binding.activityBarLayout) - - val booleanValues: Array = arrayOf( - null, - true, - false - ) - val booleanValuesText: Array = arrayOf( - R.string.dont_modify, - R.string.enabled, - R.string.disabled + suspend fun requestResetConfirm(): Boolean { + return iosConfirm( + context = context, + title = context.getString(R.string.reset_override_settings), + message = context.getString(R.string.reset_override_settings_message), + confirm = context.getString(R.string.ok), + destructive = true, ) + } - 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, - ) + fun requestClear() { + requests.trySend(Request.ResetOverride) + } - editableText( - value = configuration::secret, - adapter = NullableTextAdapter.String, - title = R.string.secret, - placeholder = R.string.dont_modify, - empty = R.string.default_ - ) + init { + if (context is AppCompatActivity) { + context.window.decorView.setOnInsertsChangedListener { + if (surface.insets != it) surface.insets = it + insets = it + } + } + publish() + } - 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 - ) + private fun publish() { + groups = snapshot() + } - selectableList( - value = configuration::logLevel, - values = arrayOf( - null, - LogMessage.Level.Info, - LogMessage.Level.Warning, - LogMessage.Level.Error, - LogMessage.Level.Debug, - LogMessage.Level.Silent, + private fun snapshot(): List { + // null 和 true 仍可编辑。只有 false(使用内置)禁用其余 DNS 行。 + val dnsEditable = configuration.dns.enable != false + return listOf( + SettingGroup( + title = text(R.string.general), + rows = listOf( + portRow(R.string.http_port, { configuration.httpPort }) { + configuration.httpPort = it + }, + portRow(R.string.socks_port, { configuration.socksPort }) { + configuration.socksPort = it + }, + portRow(R.string.redirect_port, { configuration.redirectPort }) { + configuration.redirectPort = it + }, + portRow(R.string.tproxy_port, { configuration.tproxyPort }) { + configuration.tproxyPort = it + }, + portRow(R.string.mixed_port, { configuration.mixedPort }) { + configuration.mixedPort = it + }, + listRow( + R.string.authentication, + { configuration.authentication }, + { configuration.authentication = it }, + ), + choice( + R.string.allow_lan, + configuration.allowLan, + booleanValues, + booleanTexts, + ) { configuration.allowLan = it }, + choice( + R.string.ipv6, + configuration.ipv6, + booleanValues, + booleanTexts, + ) { configuration.ipv6 = it }, + stringRow( + R.string.bind_address, + R.string.default_, + { configuration.bindAddress }, + { configuration.bindAddress = it }, + ), + stringRow( + R.string.external_controller, + R.string.default_, + { configuration.externalController }, + { configuration.externalController = it }, + ), + stringRow( + R.string.external_controller_tls, + R.string.default_, + { configuration.externalControllerTLS }, + { configuration.externalControllerTLS = it }, + ), + listRow( + R.string.allow_origins, + { configuration.externalControllerCors.allowOrigins }, + { configuration.externalControllerCors.allowOrigins = it }, + ), + choice( + R.string.allow_private_network, + configuration.externalControllerCors.allowPrivateNetwork, + booleanValues, + booleanTexts, + ) { configuration.externalControllerCors.allowPrivateNetwork = it }, + secretRow(), + choice( + R.string.mode, + configuration.mode, + modeValues, + modeTexts, + ) { configuration.mode = it }, + choice( + R.string.log_level, + configuration.logLevel, + logLevelValues, + logLevelTexts, + ) { configuration.logLevel = it }, + mapRow( + R.string.hosts, + { configuration.hosts }, + { configuration.hosts = it }, + ), ), - valuesText = arrayOf( - R.string.dont_modify, - R.string.info, - R.string.warning, - R.string.error, - R.string.debug, - R.string.silent, + ), + SettingGroup( + title = text(R.string.dns), + rows = listOf( + choice( + R.string.strategy, + configuration.dns.enable, + dnsStrategyValues, + dnsStrategyTexts, + ) { configuration.dns.enable = it }, + choice( + R.string.prefer_h3, + configuration.dns.preferH3, + booleanValues, + booleanTexts, + dnsEditable, + ) { configuration.dns.preferH3 = it }, + stringRow( + R.string.listen, + R.string.disabled, + { configuration.dns.listen }, + { configuration.dns.listen = it }, + dnsEditable, + ), + choice( + R.string.append_system_dns, + configuration.app.appendSystemDns, + booleanValues, + booleanTexts, + dnsEditable, + ) { configuration.app.appendSystemDns = it }, + choice( + R.string.ipv6, + configuration.dns.ipv6, + booleanValues, + booleanTexts, + dnsEditable, + ) { configuration.dns.ipv6 = it }, + choice( + R.string.use_hosts, + configuration.dns.useHosts, + booleanValues, + booleanTexts, + dnsEditable, + ) { configuration.dns.useHosts = it }, + choice( + R.string.enhanced_mode, + configuration.dns.enhancedMode, + enhancedModeValues, + enhancedModeTexts, + dnsEditable, + ) { configuration.dns.enhancedMode = it }, + listRow( + R.string.name_server, + { configuration.dns.nameServer }, + { configuration.dns.nameServer = it }, + dnsEditable, + ), + listRow( + R.string.fallback, + { configuration.dns.fallback }, + { configuration.dns.fallback = it }, + dnsEditable, + ), + listRow( + R.string.default_name_server, + { configuration.dns.defaultServer }, + { configuration.dns.defaultServer = it }, + dnsEditable, + ), + listRow( + R.string.fakeip_filter, + { configuration.dns.fakeIpFilter }, + { configuration.dns.fakeIpFilter = it }, + dnsEditable, + ), + choice( + R.string.fakeip_filter_mode, + configuration.dns.fakeIPFilterMode, + filterModeValues, + filterModeTexts, + dnsEditable, + ) { configuration.dns.fakeIPFilterMode = it }, + choice( + R.string.geoip_fallback, + configuration.dns.fallbackFilter.geoIp, + booleanValues, + booleanTexts, + dnsEditable, + ) { configuration.dns.fallbackFilter.geoIp = it }, + stringRow( + R.string.geoip_fallback_code, + R.string.raw_cn, + { configuration.dns.fallbackFilter.geoIpCode }, + { configuration.dns.fallbackFilter.geoIpCode = it }, + dnsEditable, + ), + listRow( + R.string.domain_fallback, + { configuration.dns.fallbackFilter.domain }, + { configuration.dns.fallbackFilter.domain = it }, + dnsEditable, + ), + listRow( + R.string.ipcidr_fallback, + { configuration.dns.fallbackFilter.ipcidr }, + { configuration.dns.fallbackFilter.ipcidr = it }, + dnsEditable, + ), + mapRow( + R.string.name_server_policy, + { configuration.dns.nameserverPolicy }, + { configuration.dns.nameserverPolicy = it }, + dnsEditable, + ), ), - 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 = 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 - } + private fun choice( + @StringRes title: Int, + value: T, + values: Array, + texts: Array, + enabled: Boolean = true, + write: (T) -> Unit, + ): SettingRow { + val index = values.indexOf(value) + val labels = texts.map { text(it) } + return SettingRow( + title = text(title), + summary = labels.getOrNull(index).orEmpty(), + enabled = enabled, + selectable = true, + options = labels, + selected = index, + onSelect = { picked -> + if (picked in values.indices) { + val next = values[picked] + launch(Dispatchers.Main) { + withContext(Dispatchers.IO) { write(next) } + publish() } } - } - - 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, - ) + private fun portRow( + @StringRes title: Int, + read: () -> Int?, + write: (Int?) -> Unit, + ) = valueRow(title, portSummary(read())) { + editNullable(title, NullableTextAdapter.Port, read, write) + } - 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, - ) + private fun stringRow( + @StringRes title: Int, + @StringRes empty: Int, + read: () -> String?, + write: (String?) -> Unit, + enabled: Boolean = true, + ) = valueRow(title, stringSummary(read(), empty), enabled) { + editNullable(title, NullableTextAdapter.String, read, write) + } - editableTextList( - value = configuration.dns::nameServer, - adapter = TextAdapter.String, - title = R.string.name_server, - placeholder = R.string.dont_modify, - configure = dnsDependencies::add, - ) + private fun secretRow() = valueRow(R.string.secret, secretSummary(configuration.secret)) { + editNullable( + R.string.secret, + NullableTextAdapter.String, + { configuration.secret }, + ) { configuration.secret = it } + } - editableTextList( - value = configuration.dns::fallback, - adapter = TextAdapter.String, - title = R.string.fallback, - placeholder = R.string.dont_modify, - configure = dnsDependencies::add, - ) + private fun listRow( + @StringRes title: Int, + read: () -> List?, + write: (List?) -> Unit, + enabled: Boolean = true, + ) = valueRow(title, elements(read()?.size), enabled) { + editList(title, read, write) + } - editableTextList( - value = configuration.dns::defaultServer, - adapter = TextAdapter.String, - title = R.string.default_name_server, - placeholder = R.string.dont_modify, - configure = dnsDependencies::add, - ) + private fun mapRow( + @StringRes title: Int, + read: () -> Map?, + write: (Map?) -> Unit, + enabled: Boolean = true, + ) = valueRow(title, elements(read()?.size), enabled) { + editMap(title, read, write) + } - editableTextList( - value = configuration.dns::fakeIpFilter, - adapter = TextAdapter.String, - title = R.string.fakeip_filter, - placeholder = R.string.dont_modify, - configure = dnsDependencies::add, - ) + private fun valueRow( + @StringRes title: Int, + summary: String, + enabled: Boolean = true, + onClick: () -> Unit, + ) = SettingRow( + title = text(title), + summary = summary, + enabled = enabled, + onClick = onClick, + ) + + // NullableTextAdapter.Port:null 不修改,空或小于等于 0 显示已禁用。 + private fun portSummary(value: Int?): String { + val shown = NullableTextAdapter.Port.from(value) + return when { + shown == null -> text(R.string.dont_modify) + shown.isEmpty() -> text(R.string.disabled) + else -> shown + } + } - selectableList( - value = configuration.dns::fakeIPFilterMode, - values = arrayOf( - null, - ConfigurationOverride.FilterMode.BlackList, - ConfigurationOverride.FilterMode.WhiteList, - ConfigurationOverride.FilterMode.Rule - ), - valuesText = arrayOf( - R.string.dont_modify, - R.string.blacklist, - R.string.whitelist, - R.string.rule - ), - title = R.string.fakeip_filter_mode, - configure = dnsDependencies::add, - ) + private fun stringSummary(value: String?, @StringRes empty: Int): String { + val shown = NullableTextAdapter.String.from(value) + return when { + shown == null -> text(R.string.dont_modify) + shown.isEmpty() -> text(empty) + else -> shown + } + } - selectableList( - value = configuration.dns.fallbackFilter::geoIp, - values = booleanValues, - valuesText = booleanValuesText, - title = R.string.geoip_fallback, - configure = dnsDependencies::add, - ) + // null 不修改,空字符串默认,非空只显示已设置。编辑器仍预填原文。 + private fun secretSummary(value: String?): String = when (value) { + null -> text(R.string.dont_modify) + "" -> text(R.string.default_) + else -> text(R.string.webdav_password_set) + } - 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, - ) + private fun elements(size: Int?): String = when (size) { + null -> text(R.string.dont_modify) + 0 -> text(R.string.empty) + else -> context.getString(R.string.format_elements, size) + } - editableTextList( - value = configuration.dns.fallbackFilter::domain, - adapter = TextAdapter.String, - title = R.string.domain_fallback, - placeholder = R.string.dont_modify, - configure = dnsDependencies::add, - ) + private fun text(@StringRes id: Int): String = context.getString(id) + + private fun editNullable( + @StringRes titleRes: Int, + adapter: NullableTextAdapter, + read: () -> T, + write: (T) -> Unit, + ) { + launch(Dispatchers.Main) { + val title = context.getText(titleRes) + val initial = withContext(Dispatchers.IO) { adapter.from(read()) } + val entered = context.requestModelTextInput( + initial = initial, + title = title, + reset = context.getText(R.string.reset), + hint = title, + preserveUnedited = true, + ) + withContext(Dispatchers.IO) { + write(adapter.to(entered)) + } + publish() + } + } - editableTextList( - value = configuration.dns.fallbackFilter::ipcidr, - adapter = TextAdapter.String, - title = R.string.ipcidr_fallback, - placeholder = R.string.dont_modify, - configure = dnsDependencies::add, - ) + private fun editList( + @StringRes titleRes: Int, + read: () -> List?, + write: (List?) -> Unit, + ) { + launch(Dispatchers.Main) { + val title = context.getText(titleRes) + val initial = withContext(Dispatchers.IO) { read() } + val updated = requestTextList(initial, title) + withContext(Dispatchers.IO) { write(updated) } + publish() + } + } - 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, - ) + private fun editMap( + @StringRes titleRes: Int, + read: () -> Map?, + write: (Map?) -> Unit, + ) { + launch(Dispatchers.Main) { + val title = context.getText(titleRes) + val initial = withContext(Dispatchers.IO) { read() } + val updated = requestTextMap(initial, title) + withContext(Dispatchers.IO) { write(updated) } + publish() + } + } - dns.listener?.onChanged() + private suspend fun requestTextList( + initialValue: List?, + title: CharSequence, + ): List? { + val recyclerAdapter = EditableTextListAdapter( + context, + initialValue?.toMutableList() ?: mutableListOf(), + TextAdapter.String, + ) + val result = requestEditableListOverlay(context, recyclerAdapter, title) { + val entered = context.requestModelTextInput( + initial = "", + title = title, + hint = title, + ) + if (entered.isNotBlank()) { + recyclerAdapter.addElement(entered) + } + } + return when (result) { + EditableListOverlayResult.Cancel -> initialValue + EditableListOverlayResult.Apply -> recyclerAdapter.values + EditableListOverlayResult.Reset -> null } + } - binding.content.addView(screen.root) + private suspend fun requestTextMap( + initialValue: Map?, + title: CharSequence, + ): Map? { + val editableValue = withContext(Dispatchers.Default) { + initialValue?.map { it.key to it.value }?.toMutableList() ?: mutableListOf() + } + val recyclerAdapter = EditableTextMapAdapter( + context, + editableValue, + TextAdapter.String, + TextAdapter.String, + ) + val result = requestEditableListOverlay(context, recyclerAdapter, title) { + val created = requestMapEntry(title) + if (created != null) { + recyclerAdapter.addElement(created.first, created.second) + } + } + return when (result) { + EditableListOverlayResult.Cancel -> initialValue + EditableListOverlayResult.Apply -> recyclerAdapter.values.toMap() + EditableListOverlayResult.Reset -> null + } } - fun requestClear() { - requests.trySend(Request.ResetOverride) + private suspend fun requestMapEntry(title: CharSequence): Pair? { + return suspendCancellableCoroutine { ctx -> + val binding = DialogEditableMapTextFieldBinding + .inflate(context.layoutInflater, context.root, false) + val dialog = MaterialAlertDialogBuilder(context) + .setTitle(title) + .setNegativeButton(R.string.cancel) { _, _ -> + if (!ctx.isCompleted) { + ctx.resume(null) + } + } + .setPositiveButton(R.string.ok) { _, _ -> } + .setView(binding.root) + .create() + dialog.setOnCancelListener { + if (!ctx.isCompleted) { + ctx.resume(null) + } + } + dialog.setOnDismissListener { + if (!ctx.isCompleted) { + ctx.resume(null) + } + } + dialog.show() + dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { + val key = binding.keyView.text?.toString()?.trim() ?: "" + val value = binding.valueView.text?.toString()?.trim() ?: "" + if (key.isEmpty() || value.isEmpty()) { + return@setOnClickListener + } + if (!ctx.isCompleted) { + ctx.resume(key to value) + } + dialog.dismiss() + } + } } } diff --git a/design/src/main/java/com/github/kr328/clash/design/ProfilesDesign.kt b/design/src/main/java/com/github/kr328/clash/design/ProfilesDesign.kt index 46e0826dfa..e1b3b6d4d7 100644 --- a/design/src/main/java/com/github/kr328/clash/design/ProfilesDesign.kt +++ b/design/src/main/java/com/github/kr328/clash/design/ProfilesDesign.kt @@ -1,19 +1,26 @@ package com.github.kr328.clash.design +import android.app.Activity 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 androidx.appcompat.app.AppCompatActivity +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import com.github.kr328.clash.design.ios.ClashMetaXTheme +import com.github.kr328.clash.design.ios.ProfilesScreen +import com.github.kr328.clash.design.ios.iosConfirm +import com.github.kr328.clash.design.ui.Insets import com.github.kr328.clash.design.ui.ToastDuration -import com.github.kr328.clash.design.util.* +import com.github.kr328.clash.design.util.setOnInsertsChangedListener +import com.github.kr328.clash.design.util.showSubscriptionQr +import com.github.kr328.clash.design.util.isShareableSubscriptionUrl import com.github.kr328.clash.service.model.Profile import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext class ProfilesDesign(context: Context) : Design(context) { @@ -27,31 +34,49 @@ class ProfilesDesign(context: Context) : Design(context) 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 var insets by mutableStateOf(Insets.EMPTY) + private var profiles by mutableStateOf>(emptyList()) + private var loaded by mutableStateOf(false) + private var updatable by mutableStateOf(false) + private var allUpdating by mutableStateOf(false) + private var now by mutableStateOf(System.currentTimeMillis()) + + override val root: View = ComposeView(context).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + val title = (context as? Activity)?.title?.toString()?.ifBlank { null } + ?: context.getString(R.string.profiles) + setContent { + ClashMetaXTheme { + ProfilesScreen( + insets = insets, + title = title, + profiles = profiles, + loaded = loaded, + updatable = updatable, + allUpdating = allUpdating, + now = now, + onBack = { (context as? AppCompatActivity)?.onBackPressedDispatcher?.onBackPressed() }, + onCreate = { requestCreate() }, + onUpdateAll = { requestUpdateAll() }, + onActive = { requestActive(it) }, + onUpdate = { requestUpdate(it) }, + onEdit = { requestEdit(it) }, + onShare = { requestShare(it) }, + onDuplicate = { requestDuplicate(it) }, + onDelete = { requestDelete(it) }, + ) + } } - private val rotateAnimation : Animation = AnimationUtils.loadAnimation(context, R.anim.rotate_infinite) - - override val root: View - get() = binding.root + } suspend fun patchProfiles(profiles: List) { - adapter.apply { - patchDataSet(this::profiles, profiles, id = { it.uuid }) - } - - val updatable = withContext(Dispatchers.Default) { + val canUpdate = 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 + this@ProfilesDesign.profiles = profiles + updatable = canUpdate + loaded = true } } @@ -64,91 +89,99 @@ class ProfilesDesign(context: Context) : Design(context) } 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() + now = System.currentTimeMillis() } fun requestUpdateAll() { - allUpdating = true; - changeUpdateAllButtonStatus() + if (allUpdating) return + allUpdating = true requests.trySend(Request.UpdateAll) } fun finishUpdateAll() { - allUpdating = false; - changeUpdateAllButtonStatus() + allUpdating = false } fun requestCreate() { requests.trySend(Request.Create) } - private fun requestActive(profile: Profile) { - requests.trySend(Request.Active(profile)) + fun canShareSubscriptionQr(profile: Profile): Boolean { + return isShareableSubscriptionUrl(profile.source) } fun requestUpdate(dialog: Dialog, profile: Profile) { - requests.trySend(Request.Update(profile)) - dialog.dismiss() + requestUpdate(profile) } fun requestEdit(dialog: Dialog, profile: Profile) { - requests.trySend(Request.Edit(profile)) + dialog.dismiss() + requestEdit(profile) + } + fun requestShareQr(dialog: Dialog, profile: Profile) { dialog.dismiss() + requestShare(profile) } - fun canShareSubscriptionQr(profile: Profile): Boolean { - return isShareableSubscriptionUrl(profile.source) + fun requestDuplicate(dialog: Dialog, profile: Profile) { + dialog.dismiss() + requestDuplicate(profile) } - fun requestShareQr(dialog: Dialog, profile: Profile) { + fun requestDelete(dialog: Dialog, profile: Profile) { dialog.dismiss() + requestDelete(profile) + } + + private fun requestActive(profile: Profile) { + requests.trySend(Request.Active(profile)) + } + + private fun requestUpdate(profile: Profile) { + requests.trySend(Request.Update(profile)) + } + + private fun requestEdit(profile: Profile) { + requests.trySend(Request.Edit(profile)) + } + + private fun requestShare(profile: Profile) { showSubscriptionQr(profile.name, profile.source) } - fun requestDuplicate(dialog: Dialog, profile: Profile) { + private fun requestDuplicate(profile: Profile) { requests.trySend(Request.Duplicate(profile)) - - dialog.dismiss() } - fun requestDelete(dialog: Dialog, profile: Profile) { - requests.trySend(Request.Delete(profile)) + private fun requestDelete(profile: Profile) { + confirm(context.getString(R.string.delete), profile.name, destructive = true) { + requests.trySend(Request.Delete(profile)) + } + } - dialog.dismiss() + private fun confirm(title: String, message: String, destructive: Boolean, block: () -> Unit) { + launch { + val accepted = withContext(Dispatchers.Main) { + iosConfirm( + context, + title, + message, + context.getString(R.string.ok), + destructive, + ) + } + if (accepted) block() + } } - private fun changeUpdateAllButtonStatus() { - if (allUpdating) { - binding.updateView.startAnimation(rotateAnimation) - } else { - binding.updateView.clearAnimation() + init { + if (context is AppCompatActivity) { + context.window.decorView.setOnInsertsChangedListener { + if (surface.insets != it) surface.insets = it + insets = it + } } } -} \ No newline at end of file +} diff --git a/design/src/main/java/com/github/kr328/clash/design/PropertiesDesign.kt b/design/src/main/java/com/github/kr328/clash/design/PropertiesDesign.kt index 6ffc4272fd..e640b7775d 100644 --- a/design/src/main/java/com/github/kr328/clash/design/PropertiesDesign.kt +++ b/design/src/main/java/com/github/kr328/clash/design/PropertiesDesign.kt @@ -1,21 +1,34 @@ package com.github.kr328.clash.design +import android.app.Activity import android.content.Context import android.view.View +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy 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.design.ios.ClashMetaXTheme +import com.github.kr328.clash.design.ios.PropertiesScreen +import com.github.kr328.clash.design.ios.iosConfirm +import com.github.kr328.clash.design.ui.Insets +import com.github.kr328.clash.design.util.ValidatorAgeSecretKey +import com.github.kr328.clash.design.util.ValidatorAutoUpdateInterval +import com.github.kr328.clash.design.util.ValidatorHttpUrl +import com.github.kr328.clash.design.util.ValidatorNotBlank +import com.github.kr328.clash.design.util.isShareableSubscriptionUrl +import com.github.kr328.clash.design.util.setOnInsertsChangedListener +import com.github.kr328.clash.design.util.showSubscriptionQr 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(context) { sealed class Request { @@ -23,24 +36,46 @@ class PropertiesDesign(context: Context) : Design(cont object BrowseFiles : Request() } - private val binding = DesignPropertiesBinding - .inflate(context.layoutInflater, context.root, false) - - override val root: View - get() = binding.root + private var insets by mutableStateOf(Insets.EMPTY) + private var profileState by mutableStateOf(null) + private var processing by mutableStateOf(false) var profile: Profile - get() = binding.profile!! + get() = profileState!! set(value) { - binding.profile = value + profileState = value } val progressing: Boolean - get() = binding.processing + get() = processing + + override val root: View = ComposeView(context).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + val title = (context as? Activity)?.title?.toString()?.ifBlank { null } + ?: context.getString(R.string.profile) + setContent { + ClashMetaXTheme { + PropertiesScreen( + insets = insets, + title = title, + profile = profileState, + processing = processing, + onBack = { (context as? AppCompatActivity)?.onBackPressedDispatcher?.onBackPressed() }, + onSave = { requestCommit() }, + onName = { inputName() }, + onUrl = { inputUrl() }, + onInterval = { inputInterval() }, + onAgeKey = { inputAgeSecretKey() }, + onShare = { shareSubscriptionQr() }, + onBrowse = { requestBrowseFiles() }, + ) + } + } + } suspend fun withProcessing(executeTask: suspend (suspend (FetchStatus) -> Unit) -> Unit) { try { - binding.processing = true + processing = true context.withModelProgressBar { configure { @@ -55,37 +90,22 @@ class PropertiesDesign(context: Context) : Design(cont } } } finally { - binding.processing = false + 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() } - } + iosConfirm( + context = context, + title = context.getString(R.string.exit_without_save), + message = context.getString(R.string.exit_without_save_warning), + confirm = context.getString(R.string.ok), + destructive = false, + ) } } - 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( @@ -167,6 +187,7 @@ class PropertiesDesign(context: Context) : Design(cont } fun requestCommit() { + if (processing) return requests.trySend(Request.Commit) } @@ -195,4 +216,13 @@ class PropertiesDesign(context: Context) : Design(cont } } } + + init { + if (context is AppCompatActivity) { + context.window.decorView.setOnInsertsChangedListener { + if (surface.insets != it) surface.insets = it + insets = it + } + } + } } diff --git a/design/src/main/java/com/github/kr328/clash/design/ProvidersDesign.kt b/design/src/main/java/com/github/kr328/clash/design/ProvidersDesign.kt index a717bc287f..548686a5ac 100644 --- a/design/src/main/java/com/github/kr328/clash/design/ProvidersDesign.kt +++ b/design/src/main/java/com/github/kr328/clash/design/ProvidersDesign.kt @@ -1,11 +1,20 @@ package com.github.kr328.clash.design +import android.app.Activity import android.content.Context import android.view.View +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy 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 com.github.kr328.clash.design.ios.ClashMetaXTheme +import com.github.kr328.clash.design.ios.ProviderRowUi +import com.github.kr328.clash.design.ios.ProvidersScreen +import com.github.kr328.clash.design.ui.Insets +import com.github.kr328.clash.design.util.setOnInsertsChangedListener import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -17,47 +26,92 @@ class ProvidersDesign( data class Update(val index: Int, val provider: Provider) : Request() } - private val binding = DesignProvidersBinding - .inflate(context.layoutInflater, context.root, false) + private class ProviderRow( + val index: Int, + val provider: Provider, + updatedAt: Long, + ) { + var updatedAt by mutableStateOf(updatedAt) + var updating by mutableStateOf(false) + } - override val root: View - get() = binding.root + private val rows = providers.mapIndexed { index, provider -> + ProviderRow(index, provider, provider.updatedAt) + } + private var now by mutableStateOf(System.currentTimeMillis()) + private var insets by mutableStateOf(Insets.EMPTY) - private val adapter = ProviderAdapter(context, providers) { index, provider -> - requests.trySend(Request.Update(index, provider)) + override val root: View = ComposeView(context).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + val title = (context as? Activity)?.title?.toString() + ?.ifBlank { null } + ?: context.getString(R.string.providers) + setContent { + ClashMetaXTheme { + ProvidersScreen( + insets = insets, + title = title, + rows = rows.map { row -> + ProviderRowUi( + index = row.index, + provider = row.provider, + updatedAt = row.updatedAt, + updating = row.updating, + ) + }, + now = now, + onBack = { + (context as? AppCompatActivity)?.onBackPressedDispatcher?.onBackPressed() + }, + onUpdate = ::requestUpdate, + onUpdateAll = ::requestUpdateAll, + ) + } + } } fun updateElapsed() { - adapter.updateElapsed() + now = System.currentTimeMillis() } suspend fun notifyUpdated(index: Int) { withContext(Dispatchers.Main) { - adapter.notifyUpdated(index) + rows.getOrNull(index)?.updating = false } } suspend fun notifyChanged(index: Int) { withContext(Dispatchers.Main) { - adapter.notifyChanged(index) + rows.getOrNull(index)?.let { row -> + row.updating = false + row.updatedAt = System.currentTimeMillis() + } } } - init { - binding.self = this - - binding.activityBarLayout.applyFrom(context) + fun requestUpdateAll() { + rows.forEach { row -> + if (row.updating) return@forEach + if (row.provider.vehicleType == Provider.VehicleType.Inline) return@forEach + requests.trySend(Request.Update(row.index, row.provider)) + row.updating = true + } + } - binding.mainList.recyclerList.bindAppBarElevation(binding.activityBarLayout) - binding.mainList.recyclerList.applyLinearAdapter(context, adapter) + private fun requestUpdate(index: Int) { + val row = rows.getOrNull(index) ?: return + if (row.updating) return + if (row.provider.vehicleType == Provider.VehicleType.Inline) return + row.updating = true + requests.trySend(Request.Update(row.index, row.provider)) } - fun requestUpdateAll() { - adapter.states.filter { !it.updating }.forEachIndexed { index, state -> - state.updating = true - if (state.provider.vehicleType != Provider.VehicleType.Inline) { - requests.trySend(Request.Update(index, state.provider)) + init { + if (context is AppCompatActivity) { + context.window.decorView.setOnInsertsChangedListener { + if (surface.insets != it) surface.insets = it + insets = it } } } -} \ No newline at end of file +} diff --git a/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt b/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt index 7dc7f413d7..984b7ef164 100644 --- a/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt +++ b/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt @@ -1,32 +1,36 @@ package com.github.kr328.clash.design +import android.app.Activity import android.content.Context -import android.content.res.ColorStateList import android.view.View import android.widget.Toast -import androidx.viewpager2.widget.ViewPager2 +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy import com.github.kr328.clash.core.model.Proxy +import com.github.kr328.clash.core.model.ProxySort 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.ios.ClashMetaXTheme +import com.github.kr328.clash.design.ios.ProxyLinkUi +import com.github.kr328.clash.design.ios.ProxyPageUi +import com.github.kr328.clash.design.ios.ProxyScreen 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 com.github.kr328.clash.design.ui.Insets +import com.github.kr328.clash.design.util.setOnInsertsChangedListener import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext class ProxyDesign( context: Context, - overrideMode: TunnelState.Mode?, - groupNames: List, - uiStore: UiStore, + private val groupNames: List, + initialIndex: Int, + private val uiStore: UiStore, + private val states: List, + private val onUserGroup: (String) -> Unit, ) : Design(context) { sealed class Request { object ReloadAll : Request() @@ -38,48 +42,113 @@ class ProxyDesign( 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 data class Page( + val name: String, + val type: String = "", + val proxies: List = emptyList(), + val selectable: Boolean = false, + val now: String = "", + val loaded: Boolean = false, + val loading: Boolean = false, + val urlTesting: Boolean = false, + ) + + private var insets by mutableStateOf(Insets.EMPTY) + private var current by mutableStateOf(initialIndex) + private var sort by mutableStateOf(uiStore.proxySort) + private var excludeNotSelectable by mutableStateOf(uiStore.proxyExcludeNotSelectable) + private var pages by mutableStateOf( + List(groupNames.size) { index -> + Page( + name = groupNames[index], + loading = index == initialIndex, + ) } - } - - 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 + ) + private var relaunching = false + + val currentGroupName: String? + get() = pages.getOrNull(current)?.name + + override val root: View = ComposeView(context).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + val title = (context as? Activity)?.title?.toString() + ?.ifBlank { null } + ?: context.getString(R.string.proxy) + setContent { + ClashMetaXTheme { + val page = pages.getOrNull(current) + ProxyScreen( + insets = insets, + title = title, + groupNames = groupNames, + current = current, + page = page?.let { + ProxyPageUi( + name = it.name, + type = it.type, + now = it.now, + proxies = it.proxies, + selectable = it.selectable, + loaded = it.loaded, + loading = it.loading, + urlTesting = it.urlTesting, + ) + }, + links = pages.map { ProxyLinkUi(it.name, it.loaded, it.now) }, + sort = sort, + excludeNotSelectable = excludeNotSelectable, + onBack = { + (context as? AppCompatActivity)?.onBackPressedDispatcher?.onBackPressed() + }, + onPickGroup = ::pickGroup, + onSelect = ::select, + onUrlTest = ::requestUrlTesting, + onSort = ::applySort, + onExclude = ::setExclude, + ) + } } - - override val root: View = binding.root + } suspend fun updateGroup( position: Int, proxies: List, selectable: Boolean, parent: ProxyState, - links: Map + links: Map, + type: String, + clearUrlTesting: Boolean = false, ) { - adapter.updateAdapter(position, proxies, selectable, parent, links) - - adapter.states[position].urlTesting = false - - updateUrlTestButtonStatus() + withContext(Dispatchers.Main) { + if (position !in pages.indices) return@withContext + pages = pages.mapIndexed { index, page -> + when { + index == position -> page.copy( + type = type, + proxies = proxies, + selectable = selectable, + now = parent.now, + loaded = true, + loading = false, + urlTesting = if (clearUrlTesting) false else page.urlTesting, + ) + page.loaded -> { + val now = links[page.name]?.now + if (now != null && now != page.now) page.copy(now = now) else page + } + else -> page + } + } + } } suspend fun requestRedrawVisible() { withContext(Dispatchers.Main) { - adapter.requestRedrawVisible() + pages = pages.mapIndexed { index, page -> + val now = states.getOrNull(index)?.now ?: return@mapIndexed page + if (now == page.now) page else page.copy(now = now) + } } } @@ -89,89 +158,60 @@ class ProxyDesign( } } - init { - binding.self = this - - binding.activityBarLayout.applyFrom(context) - - binding.menuView.setOnClickListener { - menu.show() + fun requestUrlTesting() { + val index = current + val page = pages.getOrNull(index) ?: return + if (page.urlTesting) return + pages = pages.mapIndexed { i, item -> + if (i == index) item.copy(urlTesting = true) else item } + requests.trySend(Request.UrlTest(index)) + } - 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(com.google.android.material.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) - } + private fun pickGroup(index: Int) { + val page = pages.getOrNull(index) ?: return + onUserGroup(page.name) + if (index == current) return + current = index + pages = pages.mapIndexed { i, item -> + if (i == index) item.copy(loading = true) else item } + requests.trySend(Request.Reload(index)) } - fun requestUrlTesting() { - urlTesting = true - - requests.trySend(Request.UrlTest(binding.pagesView.currentItem)) - - updateUrlTestButtonStatus() + private fun select(name: String) { + val index = current + val page = pages.getOrNull(index) ?: return + if (!page.selectable) return + requests.trySend(Request.Select(index, name)) } - private fun updateUrlTestButtonStatus() { - if (verticalBottomScrolled || horizontalScrolling || urlTesting) { - binding.urlTestFloatView.hide() - } else { - binding.urlTestFloatView.show() + private fun applySort(value: ProxySort) { + if (value == sort) return + uiStore.proxySort = value + sort = value + val index = current + if (index !in pages.indices) return + pages = pages.mapIndexed { i, page -> + if (i == index) page.copy(loading = true) else page } + requests.trySend(Request.Reload(index)) + } + + private fun setExclude(value: Boolean) { + if (relaunching || value == excludeNotSelectable) return + uiStore.proxyExcludeNotSelectable = value + excludeNotSelectable = value + relaunching = true + requests.trySend(Request.ReLaunch) + } - if (urlTesting) { - binding.urlTestView.visibility = View.GONE - binding.urlTestProgressView.visibility = View.VISIBLE - } else { - binding.urlTestView.visibility = View.VISIBLE - binding.urlTestProgressView.visibility = View.GONE + init { + if (context is AppCompatActivity) { + context.window.decorView.setOnInsertsChangedListener { + if (surface.insets != it) surface.insets = it + insets = it + } } } -} \ No newline at end of file +} diff --git a/design/src/main/java/com/github/kr328/clash/design/SettingsDesign.kt b/design/src/main/java/com/github/kr328/clash/design/SettingsDesign.kt index fb1e5bc7e1..aa36dc4f7c 100644 --- a/design/src/main/java/com/github/kr328/clash/design/SettingsDesign.kt +++ b/design/src/main/java/com/github/kr328/clash/design/SettingsDesign.kt @@ -1,33 +1,57 @@ package com.github.kr328.clash.design +import android.app.Activity 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 +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import com.github.kr328.clash.design.ios.ClashMetaXTheme +import com.github.kr328.clash.design.ios.SettingsScreen +import com.github.kr328.clash.design.ui.Insets +import com.github.kr328.clash.design.util.setOnInsertsChangedListener class SettingsDesign(context: Context) : Design(context) { enum class Request { StartApp, StartNetwork, StartOverride, StartMetaFeature, StartWebdav, } - 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) + private var insets by mutableStateOf(Insets.EMPTY) + + override val root: View = ComposeView(context).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + val title = (context as? Activity)?.title?.toString() + ?.ifBlank { null } + ?: context.getString(R.string.settings) + setContent { + ClashMetaXTheme { + SettingsScreen( + insets = insets, + title = title, + onBack = { (context as? AppCompatActivity)?.onBackPressedDispatcher?.onBackPressed() }, + onApp = { request(Request.StartApp) }, + onNetwork = { request(Request.StartNetwork) }, + onOverride = { request(Request.StartOverride) }, + onMeta = { request(Request.StartMetaFeature) }, + onWebdav = { request(Request.StartWebdav) }, + ) + } + } } fun request(request: Request) { requests.trySend(request) } -} \ No newline at end of file + + init { + if (context is AppCompatActivity) { + context.window.decorView.setOnInsertsChangedListener { + if (surface.insets != it) surface.insets = it + insets = it + } + } + } +} diff --git a/design/src/main/java/com/github/kr328/clash/design/WebdavSettingsDesign.kt b/design/src/main/java/com/github/kr328/clash/design/WebdavSettingsDesign.kt index 6a43ea4d56..6f6d61c039 100644 --- a/design/src/main/java/com/github/kr328/clash/design/WebdavSettingsDesign.kt +++ b/design/src/main/java/com/github/kr328/clash/design/WebdavSettingsDesign.kt @@ -1,28 +1,31 @@ package com.github.kr328.clash.design +import android.app.Activity import android.content.Context import android.view.View -import com.github.kr328.clash.design.databinding.DesignSettingsCommonBinding -import com.github.kr328.clash.design.dialog.withModelProgressBar +import androidx.annotation.StringRes +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy import com.github.kr328.clash.design.dialog.requestModelTextInput -import com.github.kr328.clash.design.preference.ClickablePreference +import com.github.kr328.clash.design.dialog.withModelProgressBar +import com.github.kr328.clash.design.ios.ClashMetaXTheme +import com.github.kr328.clash.design.ios.WebdavSettingsScreen +import com.github.kr328.clash.design.ios.WebdavSettingsUiState +import com.github.kr328.clash.design.ios.iosConfirm import com.github.kr328.clash.design.preference.NullableTextAdapter -import com.github.kr328.clash.design.preference.OnChangedListener -import com.github.kr328.clash.design.preference.TipsPreference -import com.github.kr328.clash.design.preference.category -import com.github.kr328.clash.design.preference.clickable -import com.github.kr328.clash.design.preference.editableText -import com.github.kr328.clash.design.preference.preferenceScreen -import com.github.kr328.clash.design.preference.switch -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 +import com.github.kr328.clash.design.ui.Insets +import com.github.kr328.clash.design.util.setOnInsertsChangedListener import com.github.kr328.clash.service.webdav.WebdavSettingsStore import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import java.util.concurrent.atomic.AtomicLong class WebdavSettingsDesign( context: Context, @@ -33,122 +36,58 @@ class WebdavSettingsDesign( Test, Sync, Pull, Push, } - private val binding = DesignSettingsCommonBinding - .inflate(context.layoutInflater, context.root, false) - - private var statusPreference: TipsPreference? = null - private var passwordPreference: ClickablePreference? = null - - 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.webdav_tips) - - statusPreference = tips(R.string.webdav_status_empty) - - category(R.string.webdav_server) - - editableText( - value = store::url, - adapter = NullableTextAdapter.String, - title = R.string.webdav_url, - placeholder = R.string.webdav_url_placeholder, - empty = R.string.webdav_url_placeholder, - ) - - editableText( - value = store::lanUrl, - adapter = NullableTextAdapter.String, - title = R.string.webdav_lan_url, - placeholder = R.string.webdav_unset, - empty = R.string.webdav_unset, - ) - - tips(R.string.webdav_lan_hint) - - editableText( - value = store::username, - adapter = NullableTextAdapter.String, - title = R.string.webdav_username, - empty = R.string.webdav_unset, - ) - - passwordPreference = clickable( - title = R.string.webdav_password, - summary = if (store.password.isEmpty()) R.string.webdav_unset else R.string.webdav_password_set, - ) { - clicked { - launch(Dispatchers.Main) { - val entered = context.requestModelTextInput( - initial = store.password, - title = context.getText(R.string.webdav_password), - hint = context.getText(R.string.webdav_password), - ) - store.password = entered - refreshPassword() - } - } - } - - editableText( - value = store::remotePath, - adapter = NullableTextAdapter.String, - title = R.string.webdav_remote_path, - placeholder = R.string.webdav_remote_path_default, - empty = R.string.webdav_remote_path_default, - ) - - category(R.string.webdav_sync) - - switch( - value = store::autoSync, - title = R.string.webdav_auto_sync, - summary = R.string.webdav_auto_sync_summary, - ) { - listener = OnChangedListener { - if (store.autoSync) { - requests.trySend(Request.Sync) - } - } - } - - clickable( - title = R.string.webdav_test, - icon = R.drawable.ic_baseline_info, - ) { - clicked { requests.trySend(Request.Test) } - } - - clickable( - title = R.string.webdav_sync_now, - icon = R.drawable.ic_baseline_sync, - ) { - clicked { requests.trySend(Request.Sync) } - } - - clickable( - title = R.string.webdav_pull, - icon = R.drawable.ic_baseline_cloud_download, - ) { - clicked { requests.trySend(Request.Pull) } - } - - clickable( - title = R.string.webdav_push, - icon = R.drawable.ic_baseline_cloud_upload, - ) { - clicked { requests.trySend(Request.Push) } + private var insets by mutableStateOf(Insets.EMPTY) + private var urlText by mutableStateOf(store.url) + private var lanText by mutableStateOf(store.lanUrl) + private var usernameText by mutableStateOf(store.username) + private var passwordEmpty by mutableStateOf(store.password.isEmpty()) + private var remoteText by mutableStateOf(store.remotePath) + private var autoSync by mutableStateOf(store.autoSync) + private var statusText by mutableStateOf( + status.ifBlank { context.getString(R.string.webdav_status_empty) }, + ) + private var busy = false + private val autoSyncMutex = Mutex() + private val autoSyncGeneration = AtomicLong() + + override val root: View = ComposeView(context).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + ClashMetaXTheme { + WebdavSettingsScreen( + insets = insets, + title = context.pushedTitle(R.string.webdav_sync), + state = WebdavSettingsUiState( + url = urlText, + lanUrl = lanText, + username = usernameText, + passwordEmpty = passwordEmpty, + remotePath = remoteText, + autoSync = autoSync, + status = statusText, + ), + onBack = { (context as? AppCompatActivity)?.onBackPressedDispatcher?.onBackPressed() }, + onUrl = { onUrl() }, + onLanUrl = { onLanUrl() }, + onUsername = { onUsername() }, + onPassword = { onPassword() }, + onRemotePath = { onRemotePath() }, + onAutoSync = { onAutoSync(it) }, + onTest = { send(Request.Test) }, + onSync = { confirmThenSend(R.string.webdav_sync_now, Request.Sync) }, + onPull = { confirmThenSend(R.string.webdav_pull, Request.Pull) }, + onPush = { confirmThenSend(R.string.webdav_push, Request.Push) }, + ) } } + } + + fun setStatus(text: String) { + statusText = text.ifBlank { context.getString(R.string.webdav_status_empty) } + } - binding.content.addView(screen.root) - setStatus(status) + fun onActionFinished() { + busy = false } suspend fun withProgress( @@ -176,6 +115,101 @@ class WebdavSettingsDesign( return result } + private fun onUrl() { + edit(R.string.webdav_url, { store.url }, { store.url = it }) { urlText = it } + } + + private fun onLanUrl() { + edit(R.string.webdav_lan_url, { store.lanUrl }, { store.lanUrl = it }) { lanText = it } + } + + private fun onUsername() { + edit(R.string.webdav_username, { store.username }, { store.username = it }) { usernameText = it } + } + + private fun onRemotePath() { + edit(R.string.webdav_remote_path, { store.remotePath }, { store.remotePath = it }) { remoteText = it } + } + + private fun onPassword() { + launch(Dispatchers.Main) { + val initial = withContext(Dispatchers.IO) { store.password } + val entered = context.requestModelTextInput( + initial = initial, + title = context.getText(R.string.webdav_password), + hint = context.getText(R.string.webdav_password), + ) + passwordEmpty = withContext(Dispatchers.IO) { + store.password = entered + store.password.isEmpty() + } + } + } + + private fun onAutoSync(enabled: Boolean) { + val generation = autoSyncGeneration.incrementAndGet() + autoSync = enabled + launch(Dispatchers.Main) { + withContext(Dispatchers.IO) { + autoSyncMutex.withLock { + if (generation != autoSyncGeneration.get()) return@withLock + store.autoSync = enabled + } + } + if (generation == autoSyncGeneration.get() && enabled) { + if (requests.trySend(Request.Sync).isSuccess) busy = true + } + } + } + + private fun confirmThenSend(@StringRes titleRes: Int, request: Request) { + if (busy) return + busy = true + launch(Dispatchers.Main) { + var sent = false + try { + val ok = iosConfirm( + context, + title = context.getString(titleRes), + message = context.getString(R.string.webdav_tips), + confirm = context.getString(R.string.ok), + ) + if (ok) sent = requests.trySend(request).isSuccess + } finally { + if (!sent) busy = false + } + } + } + + private fun send(request: Request) { + if (busy) return + busy = true + if (!requests.trySend(request).isSuccess) busy = false + } + + private fun edit( + @StringRes titleRes: Int, + read: () -> String?, + write: (String?) -> Unit, + show: (String?) -> Unit, + ) { + launch(Dispatchers.Main) { + val initial = withContext(Dispatchers.IO) { read() } + val text = context.requestModelTextInput( + initial = initial, + title = context.getText(titleRes), + reset = context.getText(R.string.reset), + hint = context.getText(titleRes), + ) + val shown = withContext(Dispatchers.IO) { + val newValue = NullableTextAdapter.String.to(text) + write(newValue) + NullableTextAdapter.String.from(newValue) + } + show(shown) + } + } + private fun formatWebdavProgress(phase: String, current: Int, total: Int, label: String): String { val name = label.substringAfterLast('/').removeSuffix(".json") return when (phase) { @@ -197,13 +231,17 @@ class WebdavSettingsDesign( } } - fun setStatus(text: String) { - statusPreference?.text = text.ifBlank { context.getString(R.string.webdav_status_empty) } + init { + if (context is AppCompatActivity) { + context.window.decorView.setOnInsertsChangedListener { + if (surface.insets != it) surface.insets = it + insets = it + } + } } +} - private fun refreshPassword() { - passwordPreference?.summary = context.getString( - if (store.password.isEmpty()) R.string.webdav_unset else R.string.webdav_password_set, - ) - } +private fun Context.pushedTitle(fallback: Int): String { + val current = (this as? Activity)?.title?.toString()?.ifBlank { null } + return current ?: getString(fallback) } diff --git a/design/src/main/java/com/github/kr328/clash/design/adapter/AppAdapter.kt b/design/src/main/java/com/github/kr328/clash/design/adapter/AppAdapter.kt deleted file mode 100644 index 1d44221d63..0000000000 --- a/design/src/main/java/com/github/kr328/clash/design/adapter/AppAdapter.kt +++ /dev/null @@ -1,51 +0,0 @@ -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, - private val onSelectionChanged: () -> Unit = {}, -) : RecyclerView.Adapter() { - class Holder(val binding: AdapterAppBinding) : RecyclerView.ViewHolder(binding.root) - - var apps: List = 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 - } - onSelectionChanged() - } - } - - override fun getItemCount(): Int { - return apps.size - } -} \ No newline at end of file diff --git a/design/src/main/java/com/github/kr328/clash/design/adapter/AppSetAdapter.kt b/design/src/main/java/com/github/kr328/clash/design/adapter/AppSetAdapter.kt deleted file mode 100644 index 50b89e569d..0000000000 --- a/design/src/main/java/com/github/kr328/clash/design/adapter/AppSetAdapter.kt +++ /dev/null @@ -1,116 +0,0 @@ -package com.github.kr328.clash.design.adapter - -import android.content.Context -import android.graphics.drawable.Drawable -import android.view.View -import android.view.ViewGroup -import androidx.core.content.ContextCompat -import androidx.recyclerview.widget.RecyclerView -import com.github.kr328.clash.design.R -import com.github.kr328.clash.design.databinding.AdapterAppSetBinding -import com.github.kr328.clash.design.databinding.AdapterAppSetHeaderBinding -import com.github.kr328.clash.design.model.AppSetCheckState -import com.github.kr328.clash.design.util.layoutInflater -import com.github.kr328.clash.design.util.root - -internal data class AppSetRow( - val id: String, - val title: String, - val summary: String, - val iconRes: Int, - val checkState: AppSetCheckState, - val enabled: Boolean, -) - -internal class AppSetAdapter( - private val context: Context, - private val onToggle: (String) -> Unit, -) : RecyclerView.Adapter() { - var rows: List = emptyList() - private set - - fun submit(rows: List) { - val oldCount = itemCount - this.rows = rows - val newCount = itemCount - if (oldCount == newCount) { - if (rows.isNotEmpty()) notifyItemRangeChanged(1, rows.size) - } else { - if (oldCount > 0) notifyItemRangeRemoved(0, oldCount) - if (newCount > 0) notifyItemRangeInserted(0, newCount) - } - } - - override fun getItemCount(): Int { - return if (rows.isEmpty()) 0 else rows.size + 2 - } - - override fun getItemViewType(position: Int): Int { - return when (position) { - 0 -> TYPE_HEADER - itemCount - 1 -> TYPE_DIVIDER - else -> TYPE_SET - } - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - return when (viewType) { - TYPE_HEADER -> HeaderHolder( - AdapterAppSetHeaderBinding.inflate(context.layoutInflater, context.root, false) - ) - TYPE_DIVIDER -> DividerHolder( - context.layoutInflater.inflate(R.layout.adapter_app_set_divider, context.root, false) - ) - else -> SetHolder( - AdapterAppSetBinding.inflate(context.layoutInflater, context.root, false), - ContextCompat.getDrawable(context, R.drawable.ic_app_set_indeterminate)?.mutate(), - ) - } - } - - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (holder) { - is HeaderHolder -> holder.binding.title = context.getString(R.string.app_sets) - is SetHolder -> bindSet(holder, rows[position - 1]) - } - } - - private fun bindSet(holder: SetHolder, row: AppSetRow) { - holder.binding.title = row.title - holder.binding.summary = row.summary - holder.binding.enabled = row.enabled - holder.binding.executePendingBindings() - holder.binding.iconView.setImageResource(row.iconRes) - val checked = row.checkState == AppSetCheckState.Checked - holder.binding.switchView.buttonDrawable = if (row.checkState == AppSetCheckState.Indeterminate) { - holder.indeterminateDrawable - } else { - holder.checkboxDrawable - } - holder.binding.switchView.isChecked = checked - holder.binding.root.setOnClickListener { - if (row.enabled) onToggle(row.id) - } - } - - private class HeaderHolder( - val binding: AdapterAppSetHeaderBinding, - ) : RecyclerView.ViewHolder(binding.root) - - private class SetHolder( - val binding: AdapterAppSetBinding, - val indeterminateDrawable: Drawable?, - ) : RecyclerView.ViewHolder(binding.root) { - val checkboxDrawable: Drawable? = - binding.switchView.buttonDrawable?.constantState?.newDrawable()?.mutate() - ?: binding.switchView.buttonDrawable - } - - private class DividerHolder(view: View) : RecyclerView.ViewHolder(view) - - private companion object { - const val TYPE_HEADER = 0 - const val TYPE_SET = 1 - const val TYPE_DIVIDER = 2 - } -} diff --git a/design/src/main/java/com/github/kr328/clash/design/adapter/AppsStrategyConfigAdapter.kt b/design/src/main/java/com/github/kr328/clash/design/adapter/AppsStrategyConfigAdapter.kt deleted file mode 100644 index e08d424917..0000000000 --- a/design/src/main/java/com/github/kr328/clash/design/adapter/AppsStrategyConfigAdapter.kt +++ /dev/null @@ -1,44 +0,0 @@ -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.design.databinding.AdapterAppsStrategyConfigBinding -import com.github.kr328.clash.design.util.layoutInflater -import com.github.kr328.clash.service.model.AppsStrategyConfig -import java.util.* - -class AppsStrategyConfigAdapter( - private val context: Context, - private val onClicked: (AppsStrategyConfig) -> Unit, - private val onEditClicked: (AppsStrategyConfig) -> Unit, - private val onChooseClicked: (AppsStrategyConfig) -> Unit, -) : RecyclerView.Adapter() { - - class Holder(val binding: AdapterAppsStrategyConfigBinding) : - RecyclerView.ViewHolder(binding.root) - - var configs: List = emptyList() - var activeUuid: UUID? = null - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder { - return Holder( - AdapterAppsStrategyConfigBinding - .inflate(context.layoutInflater, parent, false) - ) - } - - override fun onBindViewHolder(holder: Holder, position: Int) { - val config = configs[position] - val binding = holder.binding - - binding.config = config - binding.isActive = config.uuid == activeUuid - binding.clicked = View.OnClickListener { onClicked(config) } - binding.editClicked = View.OnClickListener { onEditClicked(config) } - binding.chooseClicked = View.OnClickListener { onChooseClicked(config) } - } - - override fun getItemCount(): Int = configs.size -} diff --git a/design/src/main/java/com/github/kr328/clash/design/adapter/EditableTextMapAdapter.kt b/design/src/main/java/com/github/kr328/clash/design/adapter/EditableTextMapAdapter.kt index 8003710507..630d4c4f8e 100644 --- a/design/src/main/java/com/github/kr328/clash/design/adapter/EditableTextMapAdapter.kt +++ b/design/src/main/java/com/github/kr328/clash/design/adapter/EditableTextMapAdapter.kt @@ -18,9 +18,15 @@ class EditableTextMapAdapter( fun addElement(key: String, value: String) { val keyValue = keyAdapter.to(key) val valueValue = valueAdapter.to(value) - - notifyItemInserted(values.size) - values.add(keyValue to valueValue) + val index = values.indexOfFirst { it.first == keyValue } + + if (index >= 0) { + values[index] = keyValue to valueValue + notifyItemChanged(index) + } else { + notifyItemInserted(values.size) + values.add(keyValue to valueValue) + } } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder { diff --git a/design/src/main/java/com/github/kr328/clash/design/adapter/FileAdapter.kt b/design/src/main/java/com/github/kr328/clash/design/adapter/FileAdapter.kt deleted file mode 100644 index fcf5c54432..0000000000 --- a/design/src/main/java/com/github/kr328/clash/design/adapter/FileAdapter.kt +++ /dev/null @@ -1,53 +0,0 @@ -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() { - class Holder(val binding: AdapterFileBinding) : RecyclerView.ViewHolder(binding.root) - - private val currentTime = ObservableCurrentTime() - - var files: List = 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 - } -} \ No newline at end of file diff --git a/design/src/main/java/com/github/kr328/clash/design/adapter/LogFileAdapter.kt b/design/src/main/java/com/github/kr328/clash/design/adapter/LogFileAdapter.kt deleted file mode 100644 index 3ab65d6b75..0000000000 --- a/design/src/main/java/com/github/kr328/clash/design/adapter/LogFileAdapter.kt +++ /dev/null @@ -1,38 +0,0 @@ -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() { - class Holder(val label: ActionLabel) : RecyclerView.ViewHolder(label) - - var logs: List = 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 - } -} \ No newline at end of file diff --git a/design/src/main/java/com/github/kr328/clash/design/adapter/LogMessageAdapter.kt b/design/src/main/java/com/github/kr328/clash/design/adapter/LogMessageAdapter.kt deleted file mode 100644 index 4f7c6572fc..0000000000 --- a/design/src/main/java/com/github/kr328/clash/design/adapter/LogMessageAdapter.kt +++ /dev/null @@ -1,40 +0,0 @@ -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() { - class Holder(val binding: AdapterLogMessageBinding) : RecyclerView.ViewHolder(binding.root) - - var messages: List = 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 - } -} \ No newline at end of file diff --git a/design/src/main/java/com/github/kr328/clash/design/adapter/ProfileAdapter.kt b/design/src/main/java/com/github/kr328/clash/design/adapter/ProfileAdapter.kt deleted file mode 100644 index fdb4cdf529..0000000000 --- a/design/src/main/java/com/github/kr328/clash/design/adapter/ProfileAdapter.kt +++ /dev/null @@ -1,56 +0,0 @@ -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() { - class Holder(val binding: AdapterProfileBinding) : RecyclerView.ViewHolder(binding.root) - - private val currentTime = ObservableCurrentTime() - - var profiles: List = 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 - } -} \ No newline at end of file diff --git a/design/src/main/java/com/github/kr328/clash/design/adapter/ProfileProviderAdapter.kt b/design/src/main/java/com/github/kr328/clash/design/adapter/ProfileProviderAdapter.kt deleted file mode 100644 index 559883ed94..0000000000 --- a/design/src/main/java/com/github/kr328/clash/design/adapter/ProfileProviderAdapter.kt +++ /dev/null @@ -1,48 +0,0 @@ -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() { - class Holder(val binding: AdapterProfileProviderBinding) : RecyclerView.ViewHolder(binding.root) - - var providers: List = 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 - } -} \ No newline at end of file diff --git a/design/src/main/java/com/github/kr328/clash/design/adapter/ProviderAdapter.kt b/design/src/main/java/com/github/kr328/clash/design/adapter/ProviderAdapter.kt deleted file mode 100644 index b076827a75..0000000000 --- a/design/src/main/java/com/github/kr328/clash/design/adapter/ProviderAdapter.kt +++ /dev/null @@ -1,75 +0,0 @@ -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, - private val requestUpdate: (Int, Provider) -> Unit, -) : RecyclerView.Adapter() { - 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 - if (state.provider.vehicleType == Provider.VehicleType.Inline) { - holder.binding.endView.visibility = View.GONE - holder.binding.elapsedView.visibility = View.GONE - holder.binding.divider.visibility = View.GONE - } else { - holder.binding.endView.visibility = View.VISIBLE - holder.binding.elapsedView.visibility = View.VISIBLE - holder.binding.update = View.OnClickListener { - state.updating = true - requestUpdate(position, state.provider) - } - } - } - - override fun getItemCount(): Int { - return states.size - } -} \ No newline at end of file diff --git a/design/src/main/java/com/github/kr328/clash/design/adapter/ProxyAdapter.kt b/design/src/main/java/com/github/kr328/clash/design/adapter/ProxyAdapter.kt deleted file mode 100644 index 619c17e58c..0000000000 --- a/design/src/main/java/com/github/kr328/clash/design/adapter/ProxyAdapter.kt +++ /dev/null @@ -1,44 +0,0 @@ -package com.github.kr328.clash.design.adapter - -import android.view.ViewGroup -import androidx.recyclerview.widget.RecyclerView -import com.github.kr328.clash.design.component.ProxyView -import com.github.kr328.clash.design.component.ProxyViewConfig -import com.github.kr328.clash.design.component.ProxyViewState - -class ProxyAdapter( - private val config: ProxyViewConfig, - private val clicked: (String) -> Unit, -) : RecyclerView.Adapter() { - class Holder(val view: ProxyView) : RecyclerView.ViewHolder(view) - - var selectable: Boolean = false - var states: List = 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 - } -} diff --git a/design/src/main/java/com/github/kr328/clash/design/adapter/ProxyPageAdapter.kt b/design/src/main/java/com/github/kr328/clash/design/adapter/ProxyPageAdapter.kt deleted file mode 100644 index db4ccbc31b..0000000000 --- a/design/src/main/java/com/github/kr328/clash/design/adapter/ProxyPageAdapter.kt +++ /dev/null @@ -1,112 +0,0 @@ -package com.github.kr328.clash.design.adapter - -import android.view.ViewGroup -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, - private val stateChanged: (Int) -> Unit, -) : RecyclerView.Adapter() { - private val factory = ProxyPageFactory(config) - private var parent: RecyclerView? = null - - val states = List(adapters.size) { ProxyPageState() } - - suspend fun updateAdapter( - position: Int, - proxies: List, - selectable: Boolean, - parent: ProxyState, - links: Map - ) { - val states = withContext(Dispatchers.Default) { - proxies.map { - val link = if (it.isGroup) 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() - } - - 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 - } -} \ No newline at end of file diff --git a/design/src/main/java/com/github/kr328/clash/design/component/AccessControlMenu.kt b/design/src/main/java/com/github/kr328/clash/design/component/AccessControlMenu.kt deleted file mode 100644 index ae3bbc59be..0000000000 --- a/design/src/main/java/com/github/kr328/clash/design/component/AccessControlMenu.kt +++ /dev/null @@ -1,97 +0,0 @@ -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, -) : 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) - } -} \ No newline at end of file diff --git a/design/src/main/java/com/github/kr328/clash/design/component/ProxyMenu.kt b/design/src/main/java/com/github/kr328/clash/design/component/ProxyMenu.kt deleted file mode 100644 index 5d0672adad..0000000000 --- a/design/src/main/java/com/github/kr328/clash/design/component/ProxyMenu.kt +++ /dev/null @@ -1,120 +0,0 @@ -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, - 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 = 2 - - 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) - } -} diff --git a/design/src/main/java/com/github/kr328/clash/design/component/ProxyPageFactory.kt b/design/src/main/java/com/github/kr328/clash/design/component/ProxyPageFactory.kt deleted file mode 100644 index ff9b84fb11..0000000000 --- a/design/src/main/java/com/github/kr328/clash/design/component/ProxyPageFactory.kt +++ /dev/null @@ -1,61 +0,0 @@ -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 - } -} \ No newline at end of file diff --git a/design/src/main/java/com/github/kr328/clash/design/component/ProxyView.kt b/design/src/main/java/com/github/kr328/clash/design/component/ProxyView.kt deleted file mode 100644 index 2f2c0500db..0000000000 --- a/design/src/main/java/com/github/kr328/clash/design/component/ProxyView.kt +++ /dev/null @@ -1,195 +0,0 @@ -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) - } - } -} \ No newline at end of file diff --git a/design/src/main/java/com/github/kr328/clash/design/component/ProxyViewConfig.kt b/design/src/main/java/com/github/kr328/clash/design/component/ProxyViewConfig.kt deleted file mode 100644 index 0426e44567..0000000000 --- a/design/src/main/java/com/github/kr328/clash/design/component/ProxyViewConfig.kt +++ /dev/null @@ -1,40 +0,0 @@ -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(com.google.android.material.R.attr.colorSurface) - - val clickableBackground = - context.resolveThemedResourceId(android.R.attr.selectableItemBackground) - - val selectedControl = context.resolveThemedColor(com.google.android.material.R.attr.colorOnPrimary) - val selectedBackground = context.resolveThemedColor(com.google.android.material.R.attr.colorPrimary) - - val unselectedControl = context.resolveThemedColor(com.google.android.material.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() -} \ No newline at end of file diff --git a/design/src/main/java/com/github/kr328/clash/design/component/ProxyViewState.kt b/design/src/main/java/com/github/kr328/clash/design/component/ProxyViewState.kt deleted file mode 100644 index cb52c64ac0..0000000000 --- a/design/src/main/java/com/github/kr328/clash/design/component/ProxyViewState.kt +++ /dev/null @@ -1,127 +0,0 @@ -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 - private 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.isGroup) { - title = proxy.name - - if (link == null) { - subtitle = proxy.type - } else { - if (linkNow !== link.now) { - linkNow = link.now - - subtitle = "%s(%s)".format( - proxy.type, - 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 - } -} \ No newline at end of file diff --git a/design/src/main/java/com/github/kr328/clash/design/dialog/Input.kt b/design/src/main/java/com/github/kr328/clash/design/dialog/Input.kt index 3801477566..115f25812e 100644 --- a/design/src/main/java/com/github/kr328/clash/design/dialog/Input.kt +++ b/design/src/main/java/com/github/kr328/clash/design/dialog/Input.kt @@ -27,6 +27,7 @@ suspend fun Context.requestModelTextInput( hint: CharSequence? = null, error: CharSequence? = null, validator: Validator = ValidatorAcceptAll, + preserveUnedited: Boolean = false, ): String? { return suspendCancellableCoroutine { val binding = DialogTextFieldBinding @@ -38,11 +39,14 @@ suspend fun Context.requestModelTextInput( .setCancelable(true) .setPositiveButton(R.string.ok) { _, _ -> val text = binding.textField.text?.toString() ?: "" + val edited = text != (initial ?: "") - if (validator(text)) - it.resume(text) - else + if (!validator(text)) + it.resume(initial) + else if (preserveUnedited && !edited) it.resume(initial) + else + it.resume(text) } .setNegativeButton(R.string.cancel) { _, _ -> } .setOnDismissListener { _ -> diff --git a/design/src/main/java/com/github/kr328/clash/design/ios/AccessControlScreen.kt b/design/src/main/java/com/github/kr328/clash/design/ios/AccessControlScreen.kt new file mode 100644 index 0000000000..d7701e4b58 --- /dev/null +++ b/design/src/main/java/com/github/kr328/clash/design/ios/AccessControlScreen.kt @@ -0,0 +1,315 @@ +package com.github.kr328.clash.design.ios + +import android.os.Build +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.Icon +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import com.github.kr328.clash.design.R +import com.github.kr328.clash.design.model.AppInfo +import com.github.kr328.clash.design.model.AppInfoSort +import com.github.kr328.clash.design.model.AppSetCheckState +import com.github.kr328.clash.design.model.AppSetIds +import com.github.kr328.clash.design.model.appSetMembership +import com.github.kr328.clash.design.ui.Insets + +private val accessSorts = listOf( + AppInfoSort.Label, + AppInfoSort.PackageName, + AppInfoSort.InstallTime, + AppInfoSort.UpdateTime, +) + +@Composable +fun AccessControlScreen( + insets: Insets, + title: String, + query: String, + keyword: String, + apps: List, + selection: Set, + systemPackages: Set, + domesticPackages: Set, + loading: Boolean, + hideSystem: Boolean, + sort: AppInfoSort, + reversed: Boolean, + onBack: () -> Unit, + onQueryChange: (String) -> Unit, + onHideSystem: (Boolean) -> Unit, + onToggleApp: (String) -> Unit, + onToggleSet: (String) -> Unit, + onSelectAll: () -> Unit, + onSelectNone: () -> Unit, + onSelectInvert: () -> Unit, + onSort: (AppInfoSort) -> Unit, + onReverse: () -> Unit, + onImport: () -> Unit, + onExport: () -> Unit, +) { + // Keyword only hides rows. design.apps stays the loadAccessControlApps list. + val visible = remember(apps, keyword) { + if (keyword.isEmpty()) { + apps + } else { + apps.filter { app -> + app.label.contains(keyword, ignoreCase = true) || + app.packageName.contains(keyword, ignoreCase = true) + } + } + } + var menuOpen by remember { mutableStateOf(false) } + var sortOpen by remember { mutableStateOf(false) } + val sortLabels = listOf( + stringResource(R.string.name), + stringResource(R.string.package_name), + stringResource(R.string.install_time), + stringResource(R.string.update_time), + ) + + IosScreenScaffold( + insets = insets, + title = title, + onBack = onBack, + trailing = { + BarIconButton( + icon = R.drawable.ic_baseline_more_vert, + description = stringResource(R.string.more), + onClick = { menuOpen = true }, + ) + }, + ) { + Column(Modifier.fillMaxSize()) { + IosSearchField( + value = query, + onValueChange = onQueryChange, + placeholder = stringResource(R.string.search), + ) + LazyColumn( + modifier = Modifier.weight(1f), + contentPadding = PaddingValues(bottom = 24.dp), + ) { + item(key = "access-filter") { + Column(Modifier.fillMaxWidth().padding(top = 4.dp)) { + SectionLabel(stringResource(R.string.filter)) + IosSwitchRow( + title = hideSystemAppsLabel(), + summary = null, + checked = hideSystem, + index = 0, + count = 1, + onCheckedChange = onHideSystem, + ) + } + } + if (loading) { + item(key = "access-progress") { + IosProgressRow(stringResource(R.string.loading)) + } + } else { + item(key = "access-sets") { + Column(Modifier.fillMaxWidth().padding(bottom = 12.dp)) { + SectionLabel(stringResource(R.string.app_sets)) + SetRow( + id = AppSetIds.SYSTEM, + title = stringResource(R.string.system_apps), + members = systemPackages, + selection = selection, + index = 0, + count = 2, + onToggleSet = onToggleSet, + ) + SetRow( + id = AppSetIds.DOMESTIC, + title = stringResource(R.string.app_set_domestic), + members = domesticPackages, + selection = selection, + index = 1, + count = 2, + onToggleSet = onToggleSet, + ) + } + } + item(key = "access-selected") { + SectionLabel( + pluralStringResource( + R.plurals.selected_apps_count, + selection.size, + selection.size, + ), + ) + } + if (visible.isEmpty()) { + item(key = "access-empty") { + IosEmptyState( + message = if (keyword.isEmpty()) { + stringResource(R.string.app_set_summary_empty) + } else { + stringResource(R.string.search_empty) + }, + ) + } + } else { + itemsIndexed(visible, key = { _, app -> app.packageName }) { index, app -> + val checked = app.packageName in selection + IosListRow( + row = IosDetail( + title = app.label, + subtitle = app.packageName, + selection = if (checked) { + IosSelection.Checked + } else { + IosSelection.None + }, + disclosure = false, + onClick = { onToggleApp(app.packageName) }, + ), + showDivider = index < visible.lastIndex, + ) + } + } + } + } + } + if (menuOpen) { + IosActionSheet( + actions = listOf( + IosAction(stringResource(R.string.select_all), onClick = onSelectAll), + IosAction(stringResource(R.string.select_none), onClick = onSelectNone), + IosAction(stringResource(R.string.select_invert), onClick = onSelectInvert), + IosAction(stringResource(R.string.sort)) { sortOpen = true }, + IosAction(stringResource(R.string.reverse), marked = reversed, onClick = onReverse), + IosAction(stringResource(R.string.import_from_clipboard), onClick = onImport), + IosAction(stringResource(R.string.export_to_clipboard), onClick = onExport), + ), + onDismiss = { menuOpen = false }, + cancelLabel = stringResource(R.string.cancel), + ) + } + if (sortOpen) { + IosOptionSheet( + title = stringResource(R.string.sort), + options = sortLabels, + selected = accessSorts.indexOf(sort).coerceAtLeast(0), + onSelect = { index -> onSort(accessSorts[index]) }, + onDismiss = { sortOpen = false }, + ) + } + } +} + +@Composable +private fun SectionLabel(text: String) { + Text( + text = text, + style = IosType.footnote, + color = IosTheme.colors.secondaryLabel, + modifier = Modifier.padding(start = 32.dp, top = 12.dp, bottom = 6.dp), + ) +} + +@Composable +private fun SetRow( + id: String, + title: String, + members: Set, + selection: Set, + index: Int, + count: Int, + onToggleSet: (String) -> Unit, +) { + val membership = appSetMembership(members, selection) + val enabled = members.isNotEmpty() + val summary = if (!enabled) { + stringResource(R.string.app_set_summary_empty) + } else { + stringResource(R.string.app_set_summary, membership.total, membership.selectedCount) + } + IosPreferenceRow( + row = IosDetail( + title = title, + subtitle = summary, + enabled = enabled, + selection = when (membership.state) { + AppSetCheckState.Checked -> IosSelection.Checked + AppSetCheckState.Indeterminate -> IosSelection.Mixed + AppSetCheckState.Unchecked -> IosSelection.None + }, + disclosure = false, + onClick = if (enabled) { + { onToggleSet(id) } + } else { + null + }, + ), + index = index, + count = count, + ) +} + +@Composable +private fun hideSystemAppsLabel(): String { + val config = LocalConfiguration.current + @Suppress("DEPRECATION") + val locale = if (Build.VERSION.SDK_INT >= 24) config.locales[0] else config.locale + return when (locale.language) { + "zh" -> when (locale.country) { + "TW", "HK", "MO" -> "隱藏系統應用" + else -> "隐藏系统应用" + } + else -> "Hide system apps" + } +} + +@Composable +private fun BarIconButton( + icon: Int, + description: String, + onClick: () -> Unit, +) { + val colors = IosTheme.colors + val interaction = remember { MutableInteractionSource() } + val pressed by interaction.collectIsPressedAsState() + Box( + Modifier + .size(44.dp) + .background(if (pressed) colors.pressed else Color.Transparent) + .semantics { role = Role.Button } + .clickable(interactionSource = interaction, indication = null, onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(icon), + contentDescription = description, + tint = colors.blue, + modifier = Modifier.size(22.dp), + ) + } +} diff --git a/design/src/main/java/com/github/kr328/clash/design/ios/ApkBrokenScreen.kt b/design/src/main/java/com/github/kr328/clash/design/ios/ApkBrokenScreen.kt new file mode 100644 index 0000000000..2ef6fa915e --- /dev/null +++ b/design/src/main/java/com/github/kr328/clash/design/ios/ApkBrokenScreen.kt @@ -0,0 +1,94 @@ +package com.github.kr328.clash.design.ios + +import android.graphics.Typeface +import android.text.Spanned +import android.text.style.StyleSpan +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.github.kr328.clash.design.R +import com.github.kr328.clash.design.ui.Insets +import com.github.kr328.clash.design.util.getHtml + +@Composable +fun ApkBrokenScreen( + insets: Insets, + title: String, + onBack: () -> Unit, + onReleases: () -> Unit, +) { + IosScreenScaffold(insets = insets, title = title, onBack = onBack) { + Column( + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(top = 8.dp, bottom = 24.dp), + ) { + HtmlTip(R.string.application_broken_tips) + GroupTitle(stringResource(R.string.reinstall)) + IosPreferenceRow( + IosDetail( + title = stringResource(R.string.github_releases), + subtitle = stringResource(R.string.meta_github_url).breakAfterSlash(), + onClick = onReleases, + ), + index = 0, + count = 1, + ) + } + } +} + +@Composable +private fun HtmlTip(@StringRes id: Int) { + val context = LocalContext.current + val text = remember(id, context) { context.getHtml(id).asIosText() } + Text( + text = text, + style = IosType.footnote, + color = IosTheme.colors.secondaryLabel, + modifier = Modifier + .fillMaxWidth() + .padding(start = 32.dp, end = 32.dp, top = 8.dp, bottom = 18.dp), + ) +} + +@Composable +private fun GroupTitle(title: String) { + Text( + text = title, + style = IosType.footnote, + color = IosTheme.colors.secondaryLabel, + modifier = Modifier.padding(start = 32.dp, end = 32.dp, bottom = 6.dp), + ) +} + +private fun Spanned.asIosText(): AnnotatedString { + val value = toString() + return buildAnnotatedString { + append(value) + getSpans(0, length, StyleSpan::class.java).forEach { span -> + if ((span.style and Typeface.BOLD) == 0) return@forEach + val start = getSpanStart(span).coerceIn(0, value.length) + val end = getSpanEnd(span).coerceIn(start, value.length) + if (start < end) { + addStyle(SpanStyle(fontWeight = FontWeight.Bold), start, end) + } + } + } +} diff --git a/design/src/main/java/com/github/kr328/clash/design/ios/AppCrashedScreen.kt b/design/src/main/java/com/github/kr328/clash/design/ios/AppCrashedScreen.kt new file mode 100644 index 0000000000..643129a526 --- /dev/null +++ b/design/src/main/java/com/github/kr328/clash/design/ios/AppCrashedScreen.kt @@ -0,0 +1,147 @@ +package com.github.kr328.clash.design.ios + +import android.content.Context +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.github.kr328.clash.common.compat.preferredLocale +import com.github.kr328.clash.design.R +import com.github.kr328.clash.design.ui.Insets + +@Composable +fun AppCrashedScreen( + insets: Insets, + title: String, + logs: String?, + onBack: () -> Unit, + onCopy: () -> Unit, +) { + val context = LocalContext.current + val notice = remember(context) { systemLogNotice(context) } + val hasLogs = !logs.isNullOrEmpty() + IosScreenScaffold( + insets = insets, + title = title, + onBack = onBack, + trailing = { CopyLabel(enabled = hasLogs, onClick = onCopy) }, + ) { + Column(Modifier.fillMaxSize()) { + Text( + text = notice, + style = IosType.footnote, + color = IosTheme.colors.secondaryLabel, + modifier = Modifier + .fillMaxWidth() + .padding(start = 32.dp, end = 32.dp, top = 12.dp, bottom = 8.dp), + ) + when { + logs == null -> Unit + logs.isEmpty() -> IosEmptyState(message = noSystemLog(context)) + else -> { + val text = logs + Box( + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 8.dp), + ) { + SelectionContainer { + Text( + text = text, + style = IosType.footnote.copy( + fontFamily = FontFamily.Monospace, + lineHeight = 20.sp, + ), + color = IosTheme.colors.label, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + } + } + } +} + +@Composable +private fun CopyLabel(enabled: Boolean, onClick: () -> Unit) { + val colors = IosTheme.colors + val interaction = remember { MutableInteractionSource() } + val pressed by interaction.collectIsPressedAsState() + Box( + Modifier + .heightIn(min = 44.dp) + .background(if (pressed && enabled) colors.pressed else Color.Transparent) + .semantics { role = Role.Button } + .clickable( + interactionSource = interaction, + indication = null, + enabled = enabled, + onClick = onClick, + ) + .padding(horizontal = 8.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(R.string.age_key_copy), + style = IosType.body, + color = if (enabled) colors.blue else colors.tertiaryLabel, + ) + } +} + +private fun systemLogNotice(context: Context): String { + val locale = context.resources.configuration.preferredLocale + return when (locale.language) { + "zh" -> when (locale.country) { + "TW" -> "這是一次系統日誌,不是上面的擷取檔案。" + "HK", "MO" -> "這是一次系統日誌,不是上面的捕獲檔案。" + else -> "这是一次系统日志,不是上面的捕获文件。" + } + "ja" -> "これはシステムログであり、上のキャプチャファイルではありません。" + "ko" -> "이것은 시스템 로그이며, 위의 캡처 파일이 아닙니다." + "ru" -> "Это системный журнал, а не файл захвата выше." + "vi" -> "Đây là nhật ký hệ thống, không phải tệp đã ghi ở trên." + else -> "This is a system log, not the capture file above." + } +} + +private fun noSystemLog(context: Context): String { + val locale = context.resources.configuration.preferredLocale + return when (locale.language) { + "zh" -> when (locale.country) { + "TW", "HK", "MO" -> "沒有系統日誌" + else -> "没有系统日志" + } + "ja" -> "システムログはありません" + "ko" -> "시스템 로그 없음" + "ru" -> "Системный журнал пуст" + "vi" -> "Không có nhật ký hệ thống" + else -> "No system log" + } +} diff --git a/design/src/main/java/com/github/kr328/clash/design/ios/AppSettingsScreen.kt b/design/src/main/java/com/github/kr328/clash/design/ios/AppSettingsScreen.kt new file mode 100644 index 0000000000..a6d4745162 --- /dev/null +++ b/design/src/main/java/com/github/kr328/clash/design/ios/AppSettingsScreen.kt @@ -0,0 +1,146 @@ +package com.github.kr328.clash.design.ios + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.github.kr328.clash.design.R +import com.github.kr328.clash.design.model.DarkMode +import com.github.kr328.clash.design.ui.Insets + +data class AppSettingsUiState( + val autoRestart: Boolean = false, + val darkMode: DarkMode = DarkMode.Auto, + val hideAppIcon: Boolean = false, + val hideFromRecents: Boolean = false, + val dynamicNotification: Boolean = true, + val running: Boolean = false, +) { + val showTrafficEnabled: Boolean get() = !running +} + +@Composable +fun AppSettingsScreen( + insets: Insets, + title: String, + state: AppSettingsUiState, + onBack: () -> Unit, + onAutoRestart: (Boolean) -> Unit, + onDarkMode: (DarkMode) -> Unit, + onHideIcon: (Boolean) -> Unit, + onHideFromRecents: (Boolean) -> Unit, + onShowTraffic: (Boolean) -> Unit, +) { + var darkModeOpen by remember { mutableStateOf(false) } + val darkModes = DarkMode.values() + val darkLabels = listOf( + stringResource(R.string.follow_system_android_10), + stringResource(R.string.always_light), + stringResource(R.string.always_dark), + ) + val darkIndex = darkModes.indexOf(state.darkMode) + + IosScreenScaffold(insets = insets, title = title, onBack = onBack) { + Column( + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(top = 12.dp, bottom = 24.dp), + ) { + GroupLabel(stringResource(R.string.behavior)) + IosSwitchRow( + title = stringResource(R.string.auto_restart), + summary = stringResource(R.string.allow_clash_auto_restart), + checked = state.autoRestart, + index = 0, + count = 1, + onCheckedChange = onAutoRestart, + ) + Spacer(Modifier.height(22.dp)) + GroupLabel(stringResource(R.string.interface_)) + IosPreferenceRow( + row = IosDetail( + title = stringResource(R.string.dark_mode), + subtitle = darkLabels.getOrNull(darkIndex), + onClick = { darkModeOpen = true }, + ), + index = 0, + count = 3, + ) + IosSwitchRow( + title = stringResource(R.string.hide_app_icon_title), + summary = stringResource(R.string.hide_app_icon_desc).withDialCodeKeptIntact(), + checked = state.hideAppIcon, + index = 1, + count = 3, + onCheckedChange = onHideIcon, + ) + IosSwitchRow( + title = stringResource(R.string.hide_from_recents_title), + summary = stringResource(R.string.hide_from_recents_desc), + checked = state.hideFromRecents, + index = 2, + count = 3, + onCheckedChange = onHideFromRecents, + ) + Spacer(Modifier.height(22.dp)) + GroupLabel(stringResource(R.string.service)) + IosSwitchRow( + title = stringResource(R.string.show_traffic), + summary = stringResource(R.string.show_traffic_summary), + checked = state.dynamicNotification, + enabled = state.showTrafficEnabled, + index = 0, + count = 1, + onCheckedChange = onShowTraffic, + ) + } + } + + if (darkModeOpen) { + IosOptionSheet( + title = stringResource(R.string.dark_mode), + options = darkLabels, + selected = darkIndex, + onSelect = { onDarkMode(darkModes[it]) }, + onDismiss = { darkModeOpen = false }, + ) + } +} + +@Composable +private fun GroupLabel(text: String) { + Text( + text = text, + style = IosType.footnote, + color = IosTheme.colors.secondaryLabel, + modifier = Modifier.padding(start = 32.dp, end = 32.dp, bottom = 6.dp), + ) +} + +private const val DIAL_CODE = "*#*#252746382#*#*" + +/** Word joiners so the dial code wraps as one token, not in the middle. */ +private fun String.withDialCodeKeptIntact(): String { + val at = indexOf(DIAL_CODE) + if (at < 0) return this + val glued = buildString(DIAL_CODE.length * 2) { + DIAL_CODE.forEachIndexed { index, ch -> + append(ch) + if (index < DIAL_CODE.lastIndex) append('\u2060') + } + } + return replace(DIAL_CODE, glued) +} diff --git a/design/src/main/java/com/github/kr328/clash/design/ios/AppsStrategyScreen.kt b/design/src/main/java/com/github/kr328/clash/design/ios/AppsStrategyScreen.kt new file mode 100644 index 0000000000..2998eea2eb --- /dev/null +++ b/design/src/main/java/com/github/kr328/clash/design/ios/AppsStrategyScreen.kt @@ -0,0 +1,183 @@ +package com.github.kr328.clash.design.ios + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import com.github.kr328.clash.design.R +import com.github.kr328.clash.design.ui.Insets +import com.github.kr328.clash.service.model.AccessControlMode +import com.github.kr328.clash.service.model.AppsStrategyConfig +import java.util.UUID + +@Composable +fun AppsStrategyScreen( + insets: Insets, + title: String, + configs: List, + activeUuid: UUID?, + onBack: () -> Unit, + onCreate: () -> Unit, + onActive: (AppsStrategyConfig) -> Unit, + onChoose: (AppsStrategyConfig) -> Unit, + onEdit: (AppsStrategyConfig) -> Unit, +) { + IosScreenScaffold( + insets = insets, + title = title, + onBack = onBack, + trailing = { + BarIconButton( + icon = R.drawable.ic_baseline_add, + description = stringResource(R.string._new), + onClick = onCreate, + ) + }, + ) { + if (configs.isEmpty()) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + IosEmptyState( + message = stringResource(R.string.not_selected), + action = stringResource(R.string._new), + onAction = onCreate, + ) + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(top = 8.dp, bottom = 24.dp), + ) { + itemsIndexed(configs, key = { _, config -> config.uuid }) { index, config -> + val mode = stringResource(config.mode.labelRes()) + val subtitle = if (config.mode == AccessControlMode.AcceptAll) { + mode + } else { + val count = pluralStringResource( + R.plurals.selected_apps_count, + config.packages.size, + config.packages.size, + ) + "$mode\n$count" + } + StrategyRow( + title = config.name, + subtitle = subtitle, + active = config.uuid == activeUuid, + showDivider = index < configs.lastIndex, + onActive = { onActive(config) }, + onChoose = { onChoose(config) }, + onEdit = { onEdit(config) }, + ) + } + } + } + } +} + +@Composable +private fun StrategyRow( + title: String, + subtitle: String, + active: Boolean, + showDivider: Boolean, + onActive: () -> Unit, + onChoose: () -> Unit, + onEdit: () -> Unit, +) { + val colors = IosTheme.colors + Column(Modifier.fillMaxWidth().background(colors.grouped)) { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(Modifier.weight(1f)) { + IosListRow( + row = IosDetail( + title = title, + subtitle = subtitle, + selection = if (active) IosSelection.Checked else IosSelection.None, + disclosure = false, + onClick = onActive, + ), + showDivider = false, + ) + } + BarIconButton( + icon = R.drawable.ic_baseline_apps, + description = stringResource(R.string.choose_apps), + onClick = onChoose, + ) + BarIconButton( + icon = R.drawable.ic_baseline_edit, + description = stringResource(R.string.edit), + onClick = onEdit, + ) + } + if (showDivider) { + Box( + Modifier + .padding(start = 16.dp) + .fillMaxWidth() + .height(0.5.dp) + .background(colors.separator), + ) + } + } +} + +@Composable +private fun BarIconButton( + icon: Int, + description: String, + onClick: () -> Unit, +) { + val colors = IosTheme.colors + val interaction = remember { MutableInteractionSource() } + val pressed by interaction.collectIsPressedAsState() + Box( + Modifier + .size(44.dp) + .background(if (pressed) colors.pressed else Color.Transparent) + .semantics { role = Role.Button } + .clickable(interactionSource = interaction, indication = null, onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(icon), + contentDescription = description, + tint = colors.blue, + modifier = Modifier.size(22.dp), + ) + } +} + +private fun AccessControlMode.labelRes(): Int = when (this) { + AccessControlMode.AcceptAll -> R.string.allow_all_apps + AccessControlMode.AcceptSelected -> R.string.allow_selected_apps + AccessControlMode.DenySelected -> R.string.deny_selected_apps +} diff --git a/design/src/main/java/com/github/kr328/clash/design/ios/FilesScreen.kt b/design/src/main/java/com/github/kr328/clash/design/ios/FilesScreen.kt new file mode 100644 index 0000000000..0cd2f14442 --- /dev/null +++ b/design/src/main/java/com/github/kr328/clash/design/ios/FilesScreen.kt @@ -0,0 +1,171 @@ +package com.github.kr328.clash.design.ios + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.Icon +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import com.github.kr328.clash.design.R +import com.github.kr328.clash.design.model.File +import com.github.kr328.clash.design.ui.Insets +import com.github.kr328.clash.design.util.elapsedIntervalString +import com.github.kr328.clash.design.util.toBytesString + +@Composable +fun FilesScreen( + insets: Insets, + title: String, + files: List, + currentInBaseDir: Boolean, + configurationEditable: Boolean, + now: Long, + onBack: () -> Unit, + onNew: () -> Unit, + onOpen: (File) -> Unit, + onImport: (File) -> Unit, + onExport: (File) -> Unit, + onRename: (File) -> Unit, + onDelete: (File) -> Unit, +) { + var menu by remember { mutableStateOf(null) } + IosScreenScaffold( + insets = insets, + title = title, + onBack = onBack, + trailing = if (currentInBaseDir) { + null + } else { + { + BarIcon(stringResource(R.string._new), onNew) + } + }, + ) { + if (files.isEmpty()) { + IosEmptyState(message = stringResource(R.string.list_empty)) + } else { + val context = LocalContext.current + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(top = 8.dp, bottom = 24.dp), + ) { + itemsIndexed(files, key = { _, file -> file.id }) { index, file -> + val actions = fileActions( + file = file, + currentInBaseDir = currentInBaseDir, + configurationEditable = configurationEditable, + onImport = onImport, + onExport = onExport, + onRename = onRename, + onDelete = onDelete, + ) + val elapsed = if (file.isDirectory || file.lastModified <= 0L) { + null + } else { + (now - file.lastModified).elapsedIntervalString(context) + } + val detail = IosDetail( + title = file.name, + subtitle = if (file.isDirectory) null else file.size.toBytesString(), + value = elapsed, + disclosure = actions.isEmpty(), + onClick = { onOpen(file) }, + ) + if (actions.isEmpty()) { + IosListRow(detail, showDivider = index < files.lastIndex) + } else { + IosSideActionRow( + row = detail, + actionIcon = painterResource(R.drawable.ic_baseline_more_vert), + actionDescription = stringResource(R.string.more), + onAction = { menu = file }, + showDivider = index < files.lastIndex, + ) + } + } + } + } + menu?.let { file -> + IosActionSheet( + actions = fileActions( + file = file, + currentInBaseDir = currentInBaseDir, + configurationEditable = configurationEditable, + onImport = onImport, + onExport = onExport, + onRename = onRename, + onDelete = onDelete, + ), + onDismiss = { menu = null }, + cancelLabel = stringResource(R.string.cancel), + title = file.name, + ) + } + } +} + +@Composable +private fun fileActions( + file: File, + currentInBaseDir: Boolean, + configurationEditable: Boolean, + onImport: (File) -> Unit, + onExport: (File) -> Unit, + onRename: (File) -> Unit, + onDelete: (File) -> Unit, +): List { + val importTitle = stringResource(R.string.import_) + val exportTitle = stringResource(R.string.export) + val renameTitle = stringResource(R.string.rename) + val deleteTitle = stringResource(R.string.delete) + return buildList { + if (!file.isDirectory && (!currentInBaseDir || configurationEditable)) { + add(IosAction(importTitle) { onImport(file) }) + } + if (!file.isDirectory && file.size > 0) { + add(IosAction(exportTitle) { onExport(file) }) + } + if (!currentInBaseDir) { + add(IosAction(renameTitle) { onRename(file) }) + add(IosAction(deleteTitle, destructive = true) { onDelete(file) }) + } + } +} + +@Composable +private fun BarIcon(description: String, onClick: () -> Unit) { + val interaction = remember { MutableInteractionSource() } + Box( + Modifier + .size(44.dp) + .semantics { role = Role.Button } + .clickable(interactionSource = interaction, indication = null, onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Filled.Add, + contentDescription = description, + tint = IosTheme.colors.blue, + modifier = Modifier.size(22.dp), + ) + } +} diff --git a/design/src/main/java/com/github/kr328/clash/design/ios/HelpScreen.kt b/design/src/main/java/com/github/kr328/clash/design/ios/HelpScreen.kt new file mode 100644 index 0000000000..df5bc28b24 --- /dev/null +++ b/design/src/main/java/com/github/kr328/clash/design/ios/HelpScreen.kt @@ -0,0 +1,128 @@ +package com.github.kr328.clash.design.ios + +import android.graphics.Typeface +import android.text.Spanned +import android.text.style.StyleSpan +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.github.kr328.clash.design.R +import com.github.kr328.clash.design.ui.Insets +import com.github.kr328.clash.design.util.getHtml + +@Composable +fun HelpScreen( + insets: Insets, + title: String, + onBack: () -> Unit, + onClashWiki: () -> Unit, + onClashMetaWiki: () -> Unit, + onClashMetaCore: () -> Unit, + onRepository: () -> Unit, +) { + IosScreenScaffold(insets = insets, title = title, onBack = onBack) { + Column( + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(top = 8.dp, bottom = 24.dp), + ) { + HtmlTip(R.string.tips_help) + GroupTitle(stringResource(R.string.document)) + IosPreferenceRow( + IosDetail( + title = stringResource(R.string.clash_wiki), + subtitle = stringResource(R.string.clash_wiki_url).breakAfterSlash(), + onClick = onClashWiki, + ), + index = 0, + count = 2, + ) + IosPreferenceRow( + IosDetail( + title = stringResource(R.string.clash_meta_wiki), + subtitle = stringResource(R.string.clash_meta_wiki_url).breakAfterSlash(), + onClick = onClashMetaWiki, + ), + index = 1, + count = 2, + ) + Spacer(Modifier.height(22.dp)) + GroupTitle(stringResource(R.string.sources)) + IosPreferenceRow( + IosDetail( + title = stringResource(R.string.clash_meta_core), + subtitle = stringResource(R.string.clash_meta_core_url).breakAfterSlash(), + onClick = onClashMetaCore, + ), + index = 0, + count = 2, + ) + IosPreferenceRow( + IosDetail( + title = stringResource(R.string.clash_meta_for_android), + subtitle = stringResource(R.string.meta_github_url).breakAfterSlash(), + onClick = onRepository, + ), + index = 1, + count = 2, + ) + } + } +} + +@Composable +private fun HtmlTip(@StringRes id: Int) { + val context = LocalContext.current + val text = remember(id, context) { context.getHtml(id).asIosText() } + Text( + text = text, + style = IosType.footnote, + color = IosTheme.colors.secondaryLabel, + modifier = Modifier + .fillMaxWidth() + .padding(start = 32.dp, end = 32.dp, top = 8.dp, bottom = 18.dp), + ) +} + +@Composable +private fun GroupTitle(title: String) { + Text( + text = title, + style = IosType.footnote, + color = IosTheme.colors.secondaryLabel, + modifier = Modifier.padding(start = 32.dp, end = 32.dp, bottom = 6.dp), + ) +} + +private fun Spanned.asIosText(): AnnotatedString { + val value = toString() + return buildAnnotatedString { + append(value) + getSpans(0, length, StyleSpan::class.java).forEach { span -> + if ((span.style and Typeface.BOLD) == 0) return@forEach + val start = getSpanStart(span).coerceIn(0, value.length) + val end = getSpanEnd(span).coerceIn(start, value.length) + if (start < end) { + addStyle(SpanStyle(fontWeight = FontWeight.Bold), start, end) + } + } + } +} diff --git a/design/src/main/java/com/github/kr328/clash/design/ios/HomeScreen.kt b/design/src/main/java/com/github/kr328/clash/design/ios/HomeScreen.kt new file mode 100644 index 0000000000..409ed7b82b --- /dev/null +++ b/design/src/main/java/com/github/kr328/clash/design/ios/HomeScreen.kt @@ -0,0 +1,276 @@ +package com.github.kr328.clash.design.ios + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.github.kr328.clash.core.model.TunnelState +import com.github.kr328.clash.design.R +import com.github.kr328.clash.design.ui.Insets + +data class TrafficRates( + val upload: String = "", + val download: String = "", + val uploadTotal: String = "", + val downloadTotal: String = "", + val samples: List = emptyList(), +) + +data class HomeUiState( + val running: Boolean = false, + val forwarded: String = "", + val mode: TunnelState.Mode = TunnelState.Mode.Rule, + val profileName: String? = null, + val appsStrategyName: String? = null, + val hasProviders: Boolean = false, + val currentNode: String? = null, + val currentGroup: String? = null, + val traffic: TrafficRates = TrafficRates(), +) + +@Composable +fun HomeScreen( + insets: Insets, + state: HomeUiState, + onToggle: () -> Unit, + onMode: (TunnelState.Mode) -> Unit, + onProfiles: () -> Unit, + onApps: () -> Unit, + onProxy: (String?) -> Unit, + onProviders: () -> Unit, + onLogs: () -> Unit, + onSettings: () -> Unit, + onHelp: () -> Unit, + onAbout: () -> Unit, +) { + val colors = IosTheme.colors + val density = LocalDensity.current + val top = with(density) { insets.top.toDp() } + val bottom = with(density) { insets.bottom.toDp() } + val start = with(density) { insets.start.toDp() } + val end = with(density) { insets.end.toDp() } + val unset = stringResource(R.string.not_selected) + val modes = listOf( + TunnelState.Mode.Rule to stringResource(R.string.mode_rule_short), + TunnelState.Mode.Global to stringResource(R.string.mode_global_short), + TunnelState.Mode.Direct to stringResource(R.string.mode_direct_short), + ) + val scriptShort = stringResource(R.string.mode_script_short) + val modeSummary = when (state.mode) { + TunnelState.Mode.Rule -> stringResource(R.string.mode_rule_summary) + TunnelState.Mode.Global -> stringResource(R.string.mode_global_summary) + TunnelState.Mode.Direct -> stringResource(R.string.mode_direct_summary) + TunnelState.Mode.Script -> stringResource(R.string.mode_script_summary) + } + val selected = modes.indexOfFirst { it.first == state.mode } + val inUse = buildList { + add(IosDetail(stringResource(R.string.profile), state.profileName ?: unset, onClick = onProfiles)) + add(IosDetail(stringResource(R.string.apps_strategy), state.appsStrategyName ?: unset, onClick = onApps)) + add( + IosDetail( + title = stringResource(R.string.proxy), + value = if (state.running) { + modes.firstOrNull { it.first == state.mode }?.second + ?: if (state.mode == TunnelState.Mode.Script) { + scriptShort + } else { + stringResource(R.string.mode) + } + } else { + stringResource(R.string.proxy_after_connect) + }, + enabled = state.running, + onClick = { onProxy(null) }, + ), + ) + if (state.running && state.hasProviders) { + add(IosDetail(stringResource(R.string.providers), onClick = onProviders)) + } + } + + Column( + Modifier + .fillMaxSize() + .background(colors.background) + .padding(start = start, top = top, end = end, bottom = bottom) + .verticalScroll(rememberScrollState()) + .padding(bottom = 24.dp), + ) { + Text( + text = stringResource(R.string.application_name), + style = IosType.largeTitle, + color = colors.label, + modifier = Modifier.padding(start = 32.dp, top = 8.dp, end = 16.dp, bottom = 16.dp), + ) + Column( + Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .background(colors.grouped) + .padding(16.dp), + ) { + Text( + text = stringResource(if (state.running) R.string.running else R.string.stopped), + style = IosType.title2, + color = if (state.running) colors.green else colors.label, + ) + Spacer(Modifier.height(4.dp)) + Text( + text = when { + state.running && state.forwarded.isNotEmpty() -> + stringResource(R.string.format_traffic_forwarded, state.forwarded) + state.running -> stringResource(R.string.running) + state.profileName == null -> stringResource(R.string.no_profile_selected) + else -> stringResource(R.string.tap_to_start) + }, + style = IosType.subhead, + color = colors.secondaryLabel, + ) + Spacer(Modifier.height(16.dp)) + val chooseProfile = !state.running && state.profileName == null + IosButton( + text = stringResource( + when { + state.running -> R.string.disconnect + chooseProfile -> R.string.select_profile + else -> R.string.connect + }, + ), + style = if (state.running) IosButtonStyle.Destructive else IosButtonStyle.Filled, + onClick = if (chooseProfile) onProfiles else onToggle, + ) + } + Spacer(Modifier.height(22.dp)) + Text( + text = stringResource(R.string.mode), + style = IosType.footnote, + color = colors.secondaryLabel, + modifier = Modifier.padding(start = 32.dp, bottom = 6.dp), + ) + IosModePicker( + labels = modes.map { it.second }, + selected = selected, + onSelect = { index -> + val mode = modes[index].first + if (mode != state.mode) onMode(mode) + }, + ) + if (modeSummary != null) { + Text( + text = modeSummary, + style = IosType.footnote, + color = colors.secondaryLabel, + modifier = Modifier.padding(start = 32.dp, top = 8.dp, end = 32.dp), + ) + } + Text( + text = stringResource(R.string.mode_session_hint), + style = IosType.footnote, + color = colors.tertiaryLabel, + modifier = Modifier.padding( + start = 32.dp, + top = if (modeSummary != null) 4.dp else 8.dp, + end = 32.dp, + ), + ) + if (state.running) { + Spacer(Modifier.height(22.dp)) + Text( + text = stringResource(R.string.traffic), + style = IosType.footnote, + color = colors.secondaryLabel, + modifier = Modifier.padding(start = 32.dp, bottom = 6.dp), + ) + Column( + Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .background(colors.grouped) + .padding(horizontal = 16.dp, vertical = 12.dp), + ) { + Row(Modifier.fillMaxWidth()) { + TrafficStat( + label = stringResource(R.string.upload), + value = stringResource(R.string.per_second, state.traffic.upload.ifEmpty { "0 Bytes" }), + color = colors.green, + modifier = Modifier.weight(1f), + ) + TrafficStat( + label = stringResource(R.string.download), + value = stringResource(R.string.per_second, state.traffic.download.ifEmpty { "0 Bytes" }), + color = colors.blue, + modifier = Modifier.weight(1f), + ) + } + Spacer(Modifier.height(8.dp)) + TrafficSparkline(state.traffic.samples) + if (state.traffic.uploadTotal.isNotEmpty()) { + Spacer(Modifier.height(6.dp)) + Text( + text = "${stringResource(R.string.upload)} ${state.traffic.uploadTotal} · ${stringResource(R.string.download)} ${state.traffic.downloadTotal}", + style = IosType.footnote, + color = colors.tertiaryLabel, + ) + } + } + if (!state.currentNode.isNullOrEmpty()) { + Spacer(Modifier.height(22.dp)) + IosInsetGroup( + title = stringResource(R.string.current_node), + rows = listOf( + IosDetail(state.currentNode, onClick = { onProxy(state.currentGroup) }), + ), + ) + } + } + Spacer(Modifier.height(22.dp)) + IosInsetGroup(title = stringResource(R.string.section_in_use), rows = inUse) + Spacer(Modifier.height(22.dp)) + IosInsetGroup( + title = stringResource(R.string.section_tools), + rows = listOf( + IosDetail(stringResource(R.string.logs), onClick = onLogs), + IosDetail(stringResource(R.string.settings), onClick = onSettings), + ), + ) + Spacer(Modifier.height(22.dp)) + IosInsetGroup( + title = stringResource(R.string.section_other), + rows = listOf( + IosDetail(stringResource(R.string.help), onClick = onHelp), + IosDetail(stringResource(R.string.about), onClick = onAbout), + ), + ) + } +} + +@Composable +private fun TrafficStat( + label: String, + value: String, + color: Color, + modifier: Modifier = Modifier, +) { + Column(modifier) { + Text(text = label, style = IosType.footnote, color = IosTheme.colors.secondaryLabel) + Spacer(Modifier.height(2.dp)) + Text(text = value, style = IosType.headline, color = color, maxLines = 1) + } +} diff --git a/design/src/main/java/com/github/kr328/clash/design/ios/IosComponents.kt b/design/src/main/java/com/github/kr328/clash/design/ios/IosComponents.kt new file mode 100644 index 0000000000..c6a20f92d1 --- /dev/null +++ b/design/src/main/java/com/github/kr328/clash/design/ios/IosComponents.kt @@ -0,0 +1,523 @@ +package com.github.kr328.clash.design.ios + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.Icon +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.github.kr328.clash.design.R +import nota.ui.ios.shellIosListRowColors +import nota.ui.ios.shellIosListRowSpec +import nota.ui.list.ShellListRow +import nota.ui.settings.PreferenceChevron + +enum class IosSelection { None, Checked, Mixed } + +data class IosDetail( + val title: String, + val value: String? = null, + val enabled: Boolean = true, + val onClick: (() -> Unit)? = null, + val subtitle: String? = null, + val subtitleAnnotated: AnnotatedString? = null, + val selected: Boolean = false, + val trailing: String? = null, + val onTrailing: (() -> Unit)? = null, + val selection: IosSelection = IosSelection.None, + val disclosure: Boolean = true, +) + +internal fun IosDetail.resolvedSelection(): IosSelection = when (selection) { + IosSelection.Mixed -> IosSelection.Mixed + IosSelection.Checked -> IosSelection.Checked + IosSelection.None -> if (selected) IosSelection.Checked else IosSelection.None +} + +@Composable +fun IosInsetGroup( + title: String, + rows: List, + modifier: Modifier = Modifier, +) { + if (rows.isEmpty()) return + val colors = IosTheme.colors + Column(modifier.fillMaxWidth()) { + Text( + text = title, + style = IosType.footnote, + color = colors.secondaryLabel, + modifier = Modifier.padding(start = 32.dp, end = 32.dp, bottom = 6.dp), + ) + rows.forEachIndexed { index, row -> + IosPreferenceRow(row = row, index = index, count = rows.size) + } + } +} + +@Composable +internal fun IosDetailTrailing( + row: IosDetail, + showChevron: Boolean, + chevronColor: Color, +) { + val colors = IosTheme.colors + val mark = row.resolvedSelection() + val valueFollows = mark != IosSelection.None || !row.trailing.isNullOrEmpty() || showChevron + Row(verticalAlignment = Alignment.CenterVertically) { + if (!row.value.isNullOrEmpty()) { + Text( + text = row.value, + style = IosType.body, + color = if (row.enabled) colors.secondaryLabel else colors.tertiaryLabel, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.End, + modifier = Modifier.widthIn(max = 160.dp), + ) + if (valueFollows) { + Spacer(Modifier.width(8.dp)) + } + } + if (mark != IosSelection.None) { + IosSelectionMark(mark) + if (!row.trailing.isNullOrEmpty() || showChevron) { + Spacer(Modifier.width(8.dp)) + } + } + if (!row.trailing.isNullOrEmpty()) { + val trail = remember(row.trailing) { MutableInteractionSource() } + val trailPressed by trail.collectIsPressedAsState() + Text( + text = row.trailing, + style = IosType.body, + color = if (row.enabled && row.onTrailing != null) colors.blue else colors.tertiaryLabel, + modifier = Modifier + .background(if (trailPressed && row.onTrailing != null) colors.pressed else Color.Transparent) + .then( + if (row.enabled && row.onTrailing != null) { + Modifier.clickable( + interactionSource = trail, + indication = null, + onClick = row.onTrailing, + ) + } else { + Modifier + }, + ) + .padding(horizontal = 4.dp, vertical = 4.dp), + ) + } else if (showChevron) { + PreferenceChevron(color = chevronColor, chevronSize = 13.dp) + } + } +} + +internal fun IosDetail.hasCustomTrailing(): Boolean = + resolvedSelection() != IosSelection.None || !value.isNullOrEmpty() || !trailing.isNullOrEmpty() + +@Composable +internal fun IosSelectionMark(selection: IosSelection) { + if (selection == IosSelection.None) return + val color = IosTheme.colors.blue + Canvas(Modifier.size(18.dp)) { + val stroke = Stroke(width = 2.dp.toPx(), cap = StrokeCap.Round, join = StrokeJoin.Round) + if (selection == IosSelection.Mixed) { + val y = size.height / 2f + drawLine( + color = color, + start = Offset(size.width * 0.22f, y), + end = Offset(size.width * 0.78f, y), + strokeWidth = stroke.width, + cap = StrokeCap.Round, + ) + } else { + val path = Path().apply { + moveTo(size.width * 0.18f, size.height * 0.52f) + lineTo(size.width * 0.40f, size.height * 0.74f) + lineTo(size.width * 0.82f, size.height * 0.28f) + } + drawPath(path, color, style = stroke) + } + } +} + +/** + * Plain list row. Padding and the hairline come from the copied iOS list spec, + * so the parent list should not add another 16dp of horizontal padding. + * [showDivider] is for every row except the last. + */ +@Composable +fun IosListRow(row: IosDetail, showDivider: Boolean = false) { + val colors = shellIosListRowColors() + val custom = row.hasCustomTrailing() + val clickable = row.enabled && row.onClick != null + val showChevron = clickable && row.disclosure && row.trailing.isNullOrEmpty() + val annotated = row.subtitleAnnotated?.takeIf { it.isNotEmpty() } + ShellListRow( + title = row.title, + summary = if (annotated == null) row.subtitle else null, + summaryAnnotated = annotated, + onClick = if (clickable) row.onClick else null, + enabled = row.enabled, + showDivider = showDivider, + showArrow = clickable && row.disclosure && !custom, + spec = shellIosListRowSpec(), + colors = colors, + trailing = if (custom) { + { + IosDetailTrailing( + row = row, + showChevron = showChevron, + chevronColor = if (row.enabled) colors.trailingColor else colors.disabledTrailingColor, + ) + } + } else { + null + }, + ) +} + +@Composable +fun IosSegmentedControl( + items: List, + selected: Int, + onSelect: (Int) -> Unit, + modifier: Modifier = Modifier, +) { + val colors = IosTheme.colors + Row( + modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .height(32.dp) + .clip(RoundedCornerShape(8.dp)) + .background(colors.track) + .padding(2.dp), + ) { + items.forEachIndexed { index, label -> + val active = index == selected + val interaction = remember(index) { MutableInteractionSource() } + Box( + modifier = Modifier + .weight(1f) + .height(28.dp) + .then( + if (active) { + Modifier.shadow(1.dp, RoundedCornerShape(7.dp)) + } else { + Modifier + }, + ) + .clip(RoundedCornerShape(7.dp)) + .background(if (active) colors.segment else Color.Transparent) + .semantics { role = Role.Tab } + .clickable( + interactionSource = interaction, + indication = null, + onClick = { onSelect(index) }, + ), + contentAlignment = Alignment.Center, + ) { + Text( + text = label, + style = IosType.segment, + color = colors.label, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = 4.dp), + ) + } + } + } +} + +enum class IosButtonStyle { Filled, Destructive } + +@Composable +fun IosButton( + text: String, + style: IosButtonStyle, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val colors = IosTheme.colors + val interaction = remember { MutableInteractionSource() } + val pressed by interaction.collectIsPressedAsState() + val background = when (style) { + IosButtonStyle.Filled -> colors.blue + IosButtonStyle.Destructive -> colors.red.copy(alpha = 0.12f) + } + val content = when (style) { + IosButtonStyle.Filled -> colors.onBlue + IosButtonStyle.Destructive -> colors.red + } + Box( + modifier + .fillMaxWidth() + .heightIn(min = 50.dp) + .clip(RoundedCornerShape(12.dp)) + .background(if (pressed) background.copy(alpha = background.alpha * 0.82f) else background) + .semantics { role = Role.Button } + .clickable(interactionSource = interaction, indication = null, onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Text(text = text, style = IosType.headline, color = content) + } +} + +internal val IosNavBarHeight = 44.dp + +@Composable +fun IosNavBar( + title: String, + onBack: () -> Unit, + trailing: (@Composable () -> Unit)? = null, +) { + val colors = IosTheme.colors + val interaction = remember { MutableInteractionSource() } + Box( + Modifier + .fillMaxWidth() + .height(IosNavBarHeight) + .background(colors.background), + ) { + Box( + Modifier + .align(Alignment.CenterStart) + .size(44.dp) + .semantics { role = Role.Button } + .clickable(interactionSource = interaction, indication = null, onClick = onBack), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.back), + tint = colors.blue, + modifier = Modifier.size(22.dp), + ) + } + Text( + text = title, + style = IosType.headline, + color = colors.label, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .align(Alignment.Center) + .padding(horizontal = if (trailing == null) 56.dp else 96.dp), + ) + if (trailing != null) { + Box( + Modifier + .align(Alignment.CenterEnd) + .heightIn(min = 44.dp) + .padding(end = 8.dp), + contentAlignment = Alignment.Center, + ) { + trailing() + } + } + } +} + +@Composable +fun IosNavIcon( + icon: Painter, + description: String, + onClick: () -> Unit, + tint: Color = IosTheme.colors.blue, +) { + val colors = IosTheme.colors + val interaction = remember { MutableInteractionSource() } + val pressed by interaction.collectIsPressedAsState() + Box( + Modifier + .size(44.dp) + .background(if (pressed) colors.pressed else Color.Transparent) + .semantics { role = Role.Button } + .clickable(interactionSource = interaction, indication = null, onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Icon( + painter = icon, + contentDescription = description, + tint = tint, + modifier = Modifier.size(24.dp), + ) + } +} + +data class TrafficSample(val up: Float, val down: Float) + +@Composable +fun IosModePicker( + labels: List, + selected: Int, + onSelect: (Int) -> Unit, + modifier: Modifier = Modifier, +) { + val colors = IosTheme.colors + Row( + modifier.fillMaxWidth().padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + labels.forEachIndexed { index, label -> + val active = index == selected + val interaction = remember(index) { MutableInteractionSource() } + val pressed by interaction.collectIsPressedAsState() + Box( + Modifier + .weight(1f) + .heightIn(min = 44.dp) + .clip(RoundedCornerShape(12.dp)) + .background( + when { + active -> colors.blue + pressed -> colors.pressed + else -> colors.grouped + }, + ) + .semantics { role = Role.Tab } + .clickable( + interactionSource = interaction, + indication = null, + onClick = { onSelect(index) }, + ), + contentAlignment = Alignment.Center, + ) { + Text( + text = label, + style = IosType.headline, + color = if (active) colors.onBlue else colors.label, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 11.dp), + ) + } + } + } +} + +@Composable +fun TrafficSparkline( + samples: List, + modifier: Modifier = Modifier, +) { + val colors = IosTheme.colors + Canvas(modifier.fillMaxWidth().height(56.dp)) { + if (samples.size < 2) return@Canvas + val max = samples.maxOf { maxOf(it.up, it.down) }.coerceAtLeast(1f) + fun stroke(select: (TrafficSample) -> Float, color: Color) { + val path = Path() + samples.forEachIndexed { index, sample -> + val x = index / samples.lastIndex.toFloat() * size.width + val y = size.height - (select(sample) / max) * (size.height - 4f) - 2f + if (index == 0) path.moveTo(x, y) else path.lineTo(x, y) + } + drawPath( + path, + color, + style = Stroke(width = 2.dp.toPx(), cap = StrokeCap.Round), + ) + } + stroke({ it.up }, colors.green) + stroke({ it.down }, colors.blue) + } +} + +/** + * Full-bleed row with 44dp icons beside it. The row click stays on [row]. + * [beforeAction] is an extra icon drawn before the main action, such as update. + * [below] sits under the row and above the divider, for a usage bar. + */ +@Composable +fun IosSideActionRow( + row: IosDetail, + actionIcon: Painter, + actionDescription: String, + onAction: () -> Unit, + showDivider: Boolean, + beforeAction: (@Composable () -> Unit)? = null, + below: (@Composable () -> Unit)? = null, +) { + val colors = IosTheme.colors + Column(Modifier.fillMaxWidth().background(colors.grouped)) { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(Modifier.weight(1f)) { + IosListRow(row, showDivider = false) + } + if (beforeAction != null) beforeAction() + IosNavIcon( + icon = actionIcon, + description = actionDescription, + onClick = onAction, + ) + } + if (below != null) below() + if (showDivider) { + Box( + Modifier + .padding(start = 16.dp) + .fillMaxWidth() + .height(0.5.dp) + .background(colors.separator), + ) + } + } +} + +/** True when the text fits the 160dp trailing slot. A CJK character counts as two Latin units. */ +internal fun String.fitsTrailingValue(): Boolean { + if (isBlank() || contains('\n')) return false + var units = 0 + for (ch in this) { + units += if (ch.code <= 0x00FF) 1 else 2 + if (units > 16) return false + } + return true +} + +/** Lets a URL wrap after a slash instead of leaving one path segment on the next line. */ +internal fun String.breakAfterSlash(): String = replace("/", "/\u200b") diff --git a/design/src/main/java/com/github/kr328/clash/design/ios/IosControls.kt b/design/src/main/java/com/github/kr328/clash/design/ios/IosControls.kt new file mode 100644 index 0000000000..4df002539d --- /dev/null +++ b/design/src/main/java/com/github/kr328/clash/design/ios/IosControls.kt @@ -0,0 +1,657 @@ +package com.github.kr328.clash.design.ios + +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper +import android.content.res.Configuration +import android.view.WindowManager +import androidx.appcompat.app.AlertDialog +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogWindowProvider +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.compose.ui.window.DialogProperties +import kotlin.math.roundToInt +import com.github.kr328.clash.design.R +import com.github.kr328.clash.design.model.DarkMode +import com.github.kr328.clash.design.store.UiStore +import com.github.kr328.clash.design.ui.Insets +import kotlinx.coroutines.suspendCancellableCoroutine +import nota.ui.ios.shellIosGroupPosition +import nota.ui.ios.shellIosPreferenceColors +import nota.ui.ios.shellIosPreferenceSpec +import nota.ui.ios.shellIosSearchDimens +import nota.ui.ios.shellIosSwitchColors +import nota.ui.ios.shellIosSwitchSpec +import nota.ui.settings.ShellPreferenceItem +import nota.ui.switch.ShellSwitch +import universe.agentic.ui.search.SearchField +import universe.agentic.ui.search.SearchFieldVariant +import kotlin.coroutines.resume + +@Composable +fun IosScreenScaffold( + insets: Insets, + title: String, + onBack: () -> Unit, + trailing: (@Composable () -> Unit)? = null, + content: @Composable () -> Unit, +) { + val colors = IosTheme.colors + val density = LocalDensity.current + Box( + Modifier + .fillMaxSize() + .background(colors.background) + .padding( + start = with(density) { insets.start.toDp() }, + top = with(density) { insets.top.toDp() }, + end = with(density) { insets.end.toDp() }, + bottom = with(density) { insets.bottom.toDp() }, + ), + ) { + Column(Modifier.fillMaxSize()) { + Spacer(Modifier.height(IosNavBarHeight)) + Box( + Modifier + .weight(1f) + .fillMaxWidth() + .clipToBounds(), + ) { + content() + } + } + IosNavBar(title = title, onBack = onBack, trailing = trailing) + } +} + +@Composable +fun IosSearchField( + value: String, + onValueChange: (String) -> Unit, + placeholder: String, + modifier: Modifier = Modifier, +) { + val colors = IosTheme.colors + SearchField( + value = value, + onValueChange = onValueChange, + placeholder = placeholder, + modifier = modifier.padding(horizontal = 16.dp, vertical = 8.dp), + variant = SearchFieldVariant.Overlay, + dimens = shellIosSearchDimens(), + leadingIcon = { IosSearchGlyph() }, + textStyle = IosType.body, + containerColor = colors.track, + contentColor = colors.label, + placeholderColor = colors.tertiaryLabel, + cursorColor = colors.blue, + clearTint = colors.tertiaryLabel, + clearContentDescription = stringResource(R.string.search_clear), + ) +} + +@Composable +private fun IosSearchGlyph() { + val color = IosTheme.colors.tertiaryLabel + Canvas(Modifier.size(15.dp)) { + val stroke = 1.5.dp.toPx() + val radius = size.minDimension * 0.32f + val center = Offset(size.width * 0.42f, size.height * 0.42f) + drawCircle( + color = color, + radius = radius, + center = center, + style = Stroke(width = stroke), + ) + drawLine( + color = color, + start = Offset(center.x + radius * 0.7f, center.y + radius * 0.7f), + end = Offset(size.width * 0.82f, size.height * 0.82f), + strokeWidth = stroke, + cap = StrokeCap.Round, + ) + } +} + +/** + * Inset grouped preference row. [index] and [count] pick the corner and the hairline. + * A single call is a card by itself. + */ +@Composable +fun IosPreferenceRow( + row: IosDetail, + index: Int = 0, + count: Int = 1, +) { + val colors = shellIosPreferenceColors() + val custom = row.hasCustomTrailing() + val clickable = row.enabled && row.onClick != null + val showChevron = clickable && row.disclosure && row.trailing.isNullOrEmpty() + ShellPreferenceItem( + title = row.title, + summary = row.subtitle, + onClick = if (clickable) row.onClick else null, + enabled = row.enabled, + showArrow = clickable && row.disclosure && !custom, + showDivider = index < count - 1, + groupPosition = shellIosGroupPosition(index, count), + spec = shellIosPreferenceSpec(), + colors = colors, + trailing = if (custom) { + { + IosDetailTrailing( + row = row, + showChevron = showChevron, + chevronColor = if (row.enabled) colors.arrowColor else colors.disabledArrowColor, + ) + } + } else { + null + }, + ) +} + +/** + * Grouped switch row. The switch is the copied 51×31 control. + * Tapping the title toggles it. Press feedback is the row tint, not a ripple. + */ +@Composable +fun IosSwitchRow( + title: String, + summary: String?, + checked: Boolean, + enabled: Boolean = true, + index: Int = 0, + count: Int = 1, + onCheckedChange: (Boolean) -> Unit, +) { + ShellPreferenceItem( + title = title, + summary = summary, + onClick = if (enabled) { + { onCheckedChange(!checked) } + } else { + null + }, + enabled = enabled, + showArrow = false, + showDivider = index < count - 1, + groupPosition = shellIosGroupPosition(index, count), + spec = shellIosPreferenceSpec(), + colors = shellIosPreferenceColors(), + trailing = { + ShellSwitch( + checked = checked, + onCheckedChange = if (enabled) onCheckedChange else null, + enabled = enabled, + spec = shellIosSwitchSpec(), + colors = shellIosSwitchColors(tint = IosTheme.colors.green), + ) + }, + ) +} + +internal val LocalIosSheetDismiss = staticCompositionLocalOf<(() -> Unit) -> Unit> { { } } + +private const val SheetMotionMs = 250 + +data class IosAction( + val title: String, + val destructive: Boolean = false, + val marked: Boolean = false, + val onClick: () -> Unit, +) + +@Composable +fun IosOptionSheet( + title: String?, + options: List, + selected: Int, + onSelect: (Int) -> Unit, + onDismiss: () -> Unit, +) { + IosSheet(onDismiss) { + val dismiss = LocalIosSheetDismiss.current + if (!title.isNullOrEmpty()) { + Text( + text = title, + style = IosType.footnote, + color = IosTheme.colors.secondaryLabel, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + ) + } + options.forEachIndexed { index, label -> + IosListRow( + IosDetail( + title = label, + selected = index == selected, + disclosure = false, + onClick = { dismiss { onSelect(index) } }, + ), + ) + } + } +} + +@Composable +fun IosActionSheet( + actions: List, + onDismiss: () -> Unit, + cancelLabel: String? = null, + title: String? = null, +) { + val colors = IosTheme.colors + IosSheetHost(onDismiss) { + val dismiss = LocalIosSheetDismiss.current + IosSheetCard { + if (!title.isNullOrEmpty()) { + Text( + text = title, + style = IosType.footnote, + color = colors.secondaryLabel, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .pointerInput(title) { detectTapGestures(onTap = {}) } + .padding(horizontal = 16.dp, vertical = 12.dp), + ) + Box( + Modifier + .fillMaxWidth() + .height(0.5.dp) + .background(colors.separator), + ) + } + actions.forEachIndexed { index, action -> + if (index > 0) { + Box( + Modifier + .fillMaxWidth() + .height(0.5.dp) + .background(colors.separator), + ) + } + IosSheetButton( + title = action.title, + color = if (action.destructive) colors.red else colors.blue, + marked = action.marked, + onClick = { dismiss(action.onClick) }, + ) + } + } + if (!cancelLabel.isNullOrEmpty()) { + Spacer(Modifier.height(8.dp)) + IosSheetCard { + IosSheetButton( + title = cancelLabel, + color = colors.blue, + onClick = { dismiss {} }, + ) + } + } + } +} + +@Composable +fun IosEmptyState( + message: String, + action: String? = null, + onAction: (() -> Unit)? = null, + modifier: Modifier = Modifier, +) { + Column( + modifier + .fillMaxWidth() + .padding(horizontal = 32.dp, vertical = 48.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(text = message, style = IosType.body, color = IosTheme.colors.secondaryLabel) + if (action != null && onAction != null) { + Spacer(Modifier.height(16.dp)) + IosButton(text = action, style = IosButtonStyle.Filled, onClick = onAction) + } + } +} + +@Composable +fun IosProgressRow(label: String) { + val color = IosTheme.colors.blue + val rotation by rememberInfiniteTransition(label = "ios-progress").animateFloat( + initialValue = 0f, + targetValue = 360f, + animationSpec = infiniteRepeatable(tween(durationMillis = 900, easing = LinearEasing)), + label = "ios-progress-rotation", + ) + Row( + Modifier + .fillMaxWidth() + .heightIn(min = 44.dp) + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Canvas( + Modifier + .size(18.dp) + .rotate(rotation), + ) { + drawArc( + color = color, + startAngle = 0f, + sweepAngle = 270f, + useCenter = false, + style = Stroke(width = 2.dp.toPx(), cap = StrokeCap.Round), + ) + } + Text( + text = label, + style = IosType.footnote, + color = IosTheme.colors.secondaryLabel, + modifier = Modifier.padding(start = 12.dp), + ) + } +} + +@Composable +fun IosFractionBar(fraction: Float, modifier: Modifier = Modifier) { + val colors = IosTheme.colors + val clamped = fraction.coerceIn(0f, 1f) + Canvas( + modifier + .fillMaxWidth() + .height(4.dp), + ) { + val radius = CornerRadius(size.height / 2f, size.height / 2f) + drawRoundRect(color = colors.track, cornerRadius = radius) + if (clamped > 0f) { + drawRoundRect( + color = colors.blue, + size = Size(size.width * clamped, size.height), + cornerRadius = radius, + ) + } + } +} + +suspend fun iosConfirm( + context: Context, + title: String, + message: String, + confirm: String, + destructive: Boolean = false, +): Boolean = suspendCancellableCoroutine { cont -> + val dialog = AlertDialog.Builder(context) + .setTitle(title) + .setMessage(message) + .setPositiveButton(confirm) { _, _ -> + if (cont.isActive) cont.resume(true) + } + .setNegativeButton(context.getString(R.string.cancel)) { _, _ -> + if (cont.isActive) cont.resume(false) + } + .setOnCancelListener { + if (cont.isActive) cont.resume(false) + } + .create() + cont.invokeOnCancellation { dialog.dismiss() } + dialog.setOnShowListener { + if (!destructive) return@setOnShowListener + val night = context.iosForceAwareDark() + val color = if (night) 0xFFFF453A.toInt() else 0xFFFF3B30.toInt() + dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.setTextColor(color) + } + dialog.show() +} + +private fun Context.iosForceAwareDark(): Boolean { + val nightFlag = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK + return when (UiStore(this).darkMode) { + DarkMode.ForceDark -> true + DarkMode.ForceLight -> false + DarkMode.Auto -> nightFlag == Configuration.UI_MODE_NIGHT_YES + } +} + +@Composable +fun IosSheet( + onDismiss: () -> Unit, + content: @Composable ColumnScope.() -> Unit, +) { + IosSheetHost(onDismiss) { + IosSheetCard(content) + } +} + +@Composable +private fun IosSheetHost( + onDismiss: () -> Unit, + content: @Composable ColumnScope.() -> Unit, +) { + val composeBottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + val (barTop, barBottom) = activitySystemBars() + val bottom = 8.dp + maxOf(composeBottom, barBottom) + val scroll = rememberScrollState() + val density = LocalDensity.current + var lift by remember { mutableStateOf(0.dp) } + val progress = remember { Animatable(0f) } + var travel by remember { mutableIntStateOf(100_000) } + val closingState = remember { mutableStateOf(false) } + val followUp = remember { mutableStateOf<(() -> Unit)?>(null) } + val onDismissState = rememberUpdatedState(onDismiss) + val requestClose = remember { + { after: () -> Unit -> + followUp.value = after + if (!closingState.value) { + closingState.value = true + } + } + } + val closing = closingState.value + val fraction = progress.value + val slidePx = ((1f - fraction) * travel).roundToInt() + LaunchedEffect(closing) { + progress.animateTo( + targetValue = if (closing) 0f else 1f, + animationSpec = tween(durationMillis = SheetMotionMs, easing = FastOutSlowInEasing), + ) + if (closing) { + followUp.value?.invoke() + onDismissState.value() + } + } + Dialog( + onDismissRequest = { requestClose {} }, + properties = DialogProperties( + usePlatformDefaultWidth = false, + decorFitsSystemWindows = false, + ), + ) { + val view = LocalView.current + SideEffect { + val window = (view as? DialogWindowProvider)?.window + ?: (view.parent as? DialogWindowProvider)?.window + window?.setDimAmount(0f) + window?.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) + window?.setWindowAnimations(0) + } + CompositionLocalProvider(LocalIosSheetDismiss provides requestClose) { + BoxWithConstraints(Modifier.fillMaxSize()) { + val maxSheet = (maxHeight - bottom - lift - barTop - 8.dp).coerceAtLeast(120.dp) + Box( + Modifier + .fillMaxSize() + .graphicsLayer(alpha = fraction) + .background(Color.Black.copy(alpha = 0.4f)) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = { requestClose {} }, + ), + ) + Column( + Modifier + .align(Alignment.BottomCenter) + .onGloballyPositioned { coords -> + if (coords.size.height > 0) travel = coords.size.height + val loc = IntArray(2) + view.getLocationOnScreen(loc) + val screenH = view.resources.displayMetrics.heightPixels + val pos = coords.positionInRoot() + val bottomOnScreen = loc[1] + pos.y + coords.size.height + val overflow = bottomOnScreen - screenH + if (overflow > 1f) { + val extra = with(density) { overflow.toDp() } + if (extra > lift) lift = extra + } + } + .offset { IntOffset(0, slidePx) } + .padding(start = 8.dp, end = 8.dp, bottom = bottom + lift) + .fillMaxWidth() + .heightIn(max = maxSheet) + .verticalScroll(scroll), + content = content, + ) + } + } + } +} + +@Composable +private fun activitySystemBars(): Pair { + val context = LocalContext.current + val density = LocalDensity.current + val decor = context.findActivity()?.window?.decorView + val root = decor?.let { ViewCompat.getRootWindowInsets(it) } + val (topPx, bottomPx) = if (root == null) { + 0 to 0 + } else { + val bars = root.getInsets( + WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout(), + ) + bars.top to bars.bottom + } + return with(density) { topPx.toDp() to bottomPx.toDp() } +} + +private fun Context.findActivity(): Activity? { + var current: Context = this + while (true) { + when (current) { + is Activity -> return current + is ContextWrapper -> current = current.baseContext + else -> return null + } + } +} + +@Composable +private fun IosSheetCard( + content: @Composable ColumnScope.() -> Unit, +) { + Column( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .background(IosTheme.colors.grouped), + content = content, + ) +} + +@Composable +private fun IosSheetButton( + title: String, + color: Color, + marked: Boolean = false, + onClick: () -> Unit, +) { + val colors = IosTheme.colors + val interaction = remember(title, marked) { MutableInteractionSource() } + val pressed by interaction.collectIsPressedAsState() + Box( + Modifier + .fillMaxWidth() + .heightIn(min = 44.dp) + .background(if (pressed) colors.pressed else Color.Transparent) + .semantics { role = Role.Button } + .clickable(interactionSource = interaction, indication = null, onClick = onClick) + .padding(horizontal = 16.dp), + contentAlignment = Alignment.Center, + ) { + Text(text = title, style = IosType.body, color = color) + if (marked) { + Box(Modifier.align(Alignment.CenterEnd)) { + IosSelectionMark(IosSelection.Checked) + } + } + } +} diff --git a/design/src/main/java/com/github/kr328/clash/design/ios/IosTheme.kt b/design/src/main/java/com/github/kr328/clash/design/ios/IosTheme.kt new file mode 100644 index 0000000000..4ec950584e --- /dev/null +++ b/design/src/main/java/com/github/kr328/clash/design/ios/IosTheme.kt @@ -0,0 +1,132 @@ +package com.github.kr328.clash.design.ios + +import android.content.res.Configuration +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.remember +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp +import com.github.kr328.clash.design.model.DarkMode +import com.github.kr328.clash.design.store.UiStore +import nota.ui.ios.LocalShellIosDark +import nota.ui.theme.LocalShellPressTint + +@Immutable +data class IosColors( + val background: Color, + val grouped: Color, + val label: Color, + val secondaryLabel: Color, + val tertiaryLabel: Color, + val separator: Color, + val track: Color, + val segment: Color, + val pressed: Color, + val blue: Color, + val green: Color, + val red: Color, + val onBlue: Color, +) + +private val LightColors = IosColors( + background = Color(0xFFF2F2F7), + grouped = Color.White, + label = Color.Black, + secondaryLabel = Color(0x993C3C43), + tertiaryLabel = Color(0x4D3C3C43), + separator = Color(0x4A3C3C43), + track = Color(0x1F767680), + segment = Color.White, + pressed = Color(0x14000000), + blue = Color(0xFF007AFF), + green = Color(0xFF34C759), + red = Color(0xFFFF3B30), + onBlue = Color.White, +) + +private val DarkColors = IosColors( + background = Color.Black, + grouped = Color(0xFF1C1C1E), + label = Color.White, + secondaryLabel = Color(0x99EBEBF5), + tertiaryLabel = Color(0x4DEBEBF5), + separator = Color(0xFF38383A), + track = Color(0x3D767680), + segment = Color(0xFF636366), + pressed = Color(0x14FFFFFF), + blue = Color(0xFF0A84FF), + green = Color(0xFF30D158), + red = Color(0xFFFF453A), + onBlue = Color.White, +) + +val LocalIosColors = staticCompositionLocalOf { LightColors } + +object IosTheme { + val colors: IosColors + @Composable get() = LocalIosColors.current +} + +object IosType { + val largeTitle = TextStyle( + fontSize = 34.sp, + lineHeight = 41.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.4.sp, + ) + val title2 = TextStyle( + fontSize = 22.sp, + lineHeight = 28.sp, + fontWeight = FontWeight.Bold, + ) + val headline = TextStyle( + fontSize = 17.sp, + lineHeight = 22.sp, + fontWeight = FontWeight.SemiBold, + ) + val body = TextStyle( + fontSize = 17.sp, + lineHeight = 22.sp, + fontWeight = FontWeight.Normal, + ) + val subhead = TextStyle( + fontSize = 15.sp, + lineHeight = 20.sp, + fontWeight = FontWeight.Normal, + ) + val footnote = TextStyle( + fontSize = 13.sp, + lineHeight = 18.sp, + fontWeight = FontWeight.Normal, + ) + val segment = TextStyle( + fontSize = 13.sp, + lineHeight = 18.sp, + fontWeight = FontWeight.SemiBold, + ) +} + +@Composable +fun ClashMetaXTheme(content: @Composable () -> Unit) { + val context = androidx.compose.ui.platform.LocalContext.current + val dark = remember(context) { + val nightFlag = context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK + when (UiStore(context).darkMode) { + DarkMode.ForceDark -> true + DarkMode.ForceLight -> false + DarkMode.Auto -> nightFlag == Configuration.UI_MODE_NIGHT_YES + } + } + val colors = if (dark) DarkColors else LightColors + CompositionLocalProvider( + LocalIosColors provides colors, + LocalShellIosDark provides dark, + LocalShellPressTint provides colors.pressed, + ) { + content() + } +} diff --git a/design/src/main/java/com/github/kr328/clash/design/ios/LogcatScreen.kt b/design/src/main/java/com/github/kr328/clash/design/ios/LogcatScreen.kt new file mode 100644 index 0000000000..02d54122b9 --- /dev/null +++ b/design/src/main/java/com/github/kr328/clash/design/ios/LogcatScreen.kt @@ -0,0 +1,252 @@ +package com.github.kr328.clash.design.ios + +import android.content.Context +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.github.kr328.clash.common.compat.preferredLocale +import com.github.kr328.clash.core.model.LogMessage +import com.github.kr328.clash.design.R +import com.github.kr328.clash.design.ui.Insets +import com.github.kr328.clash.design.util.format + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun LogcatScreen( + insets: Insets, + title: String, + streaming: Boolean, + messages: List, + level: LogMessage.Level?, + keyword: String, + onBack: () -> Unit, + onKeyword: (String) -> Unit, + onLevel: (LogMessage.Level?) -> Unit, + onClose: () -> Unit, + onDelete: () -> Unit, + onExport: () -> Unit, + onCopy: (LogMessage) -> Unit, +) { + val colors = IosTheme.colors + val context = LocalContext.current + val allLabel = remember(context) { allLevelsLabel(context) } + val levelLabels = mapOf( + LogMessage.Level.Debug to stringResource(R.string.debug), + LogMessage.Level.Info to stringResource(R.string.info), + LogMessage.Level.Warning to stringResource(R.string.warning), + LogMessage.Level.Error to stringResource(R.string.error), + LogMessage.Level.Silent to stringResource(R.string.silent), + ) + val rows = remember(messages, level, keyword, streaming, context, levelLabels) { + visibleRows(messages, level, keyword, streaming, context, levelLabels) + } + val listState = rememberLazyListState() + var showLevels by remember { mutableStateOf(false) } + IosScreenScaffold( + insets = insets, + title = title, + onBack = onBack, + trailing = { + if (streaming) { + BarAction(stringResource(R.string.close), colors.blue, onClose) + } else { + Row(verticalAlignment = Alignment.CenterVertically) { + IosNavIcon( + icon = painterResource(R.drawable.ic_baseline_publish), + description = stringResource(R.string.export), + onClick = onExport, + ) + IosNavIcon( + icon = painterResource(R.drawable.ic_baseline_delete), + description = stringResource(R.string.delete), + onClick = onDelete, + tint = IosTheme.colors.red, + ) + } + } + }, + ) { + Column(Modifier.fillMaxSize()) { + Spacer(Modifier.height(8.dp)) + IosPreferenceRow( + IosDetail( + title = stringResource(R.string.log_level), + value = level?.let { levelLabels[it] ?: it.name } ?: allLabel, + onClick = { showLevels = true }, + ), + ) + IosSearchField( + value = keyword, + onValueChange = onKeyword, + placeholder = stringResource(R.string.search), + ) + if (rows.isEmpty()) { + IosEmptyState( + message = stringResource( + if (keyword.isNotEmpty()) R.string.search_empty else R.string.list_empty, + ), + modifier = Modifier.weight(1f), + ) + } else { + StickToNewest(streaming = streaming, rows = rows, listState = listState) + LazyColumn( + modifier = Modifier.weight(1f).fillMaxWidth(), + state = listState, + ) { + itemsIndexed(rows, key = { _, row -> row.key }) { index, row -> + Box( + Modifier + .fillMaxWidth() + .combinedClickable( + interactionSource = remember(row.key) { MutableInteractionSource() }, + indication = null, + onClick = {}, + onLongClick = { onCopy(row.message) }, + ), + ) { + IosListRow( + row = IosDetail( + title = row.message.message, + subtitle = "${row.time} ${levelLabels[row.message.level] ?: row.message.level.name}", + ), + showDivider = index < rows.lastIndex, + ) + } + } + } + } + } + } + if (showLevels) { + val labels = listOf(allLabel) + LogMessage.Level.entries.map { levelLabels[it] ?: it.name } + val selected = if (level == null) 0 else LogMessage.Level.entries.indexOf(level) + 1 + IosOptionSheet( + title = stringResource(R.string.log_level), + options = labels, + selected = selected, + onSelect = { index -> + onLevel(if (index == 0) null else LogMessage.Level.entries[index - 1]) + }, + onDismiss = { showLevels = false }, + ) + } +} + +/** + * Streaming keeps the newest visible row at the top only while the vertical offset is still 0. + * A plain column is used: reversing the data, not the layout, puts that row first. + */ +@Composable +private fun StickToNewest( + streaming: Boolean, + rows: List, + listState: LazyListState, +) { + if (!streaming) return + val atTop = listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0 + LaunchedEffect(rows) { + if (atTop && rows.isNotEmpty()) listState.scrollToItem(0) + } +} + +private data class LogRow( + val key: String, + val message: LogMessage, + val time: String, +) + +private fun visibleRows( + messages: List, + level: LogMessage.Level?, + keyword: String, + streaming: Boolean, + context: Context, + levelLabels: Map, +): List { + val seen = HashMap() + val rows = ArrayList() + for (message in messages) { + val identity = "${message.time.time}:${message.level.name}:${message.message}" + val ordinal = seen[identity] ?: 0 + seen[identity] = ordinal + 1 + if (level != null && message.level != level) continue + val time = message.time.format(context, includeDate = false, includeTime = true) + val levelLabel = levelLabels[message.level] ?: message.level.name + if (keyword.isNotEmpty() && !matches(message, time, levelLabel, keyword)) continue + rows.add(LogRow("$identity#$ordinal", message, time)) + } + if (streaming) rows.reverse() + return rows +} + +private fun matches(message: LogMessage, time: String, levelLabel: String, keyword: String): Boolean { + return time.contains(keyword, ignoreCase = true) || + levelLabel.contains(keyword, ignoreCase = true) || + message.level.name.contains(keyword, ignoreCase = true) || + message.message.contains(keyword, ignoreCase = true) +} + +private fun allLevelsLabel(context: Context): String { + return if (context.resources.configuration.preferredLocale.language == "zh") "全部" else "All" +} + +@Composable +private fun BarAction( + text: String, + color: Color, + onClick: () -> Unit, +) { + val interaction = remember { MutableInteractionSource() } + val pressed by interaction.collectIsPressedAsState() + Box( + Modifier + .heightIn(min = 44.dp) + .background(if (pressed) IosTheme.colors.pressed else Color.Transparent) + .semantics { role = Role.Button } + .clickable(interactionSource = interaction, indication = null, onClick = onClick) + .padding(horizontal = 8.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = text, + style = IosType.body, + color = color, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} diff --git a/design/src/main/java/com/github/kr328/clash/design/ios/LogsScreen.kt b/design/src/main/java/com/github/kr328/clash/design/ios/LogsScreen.kt new file mode 100644 index 0000000000..c5155b1b4b --- /dev/null +++ b/design/src/main/java/com/github/kr328/clash/design/ios/LogsScreen.kt @@ -0,0 +1,87 @@ +package com.github.kr328.clash.design.ios + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.github.kr328.clash.design.R +import com.github.kr328.clash.design.model.LogFile +import com.github.kr328.clash.design.ui.Insets +import com.github.kr328.clash.design.util.format + +@Composable +fun LogsScreen( + insets: Insets, + title: String, + files: List, + onBack: () -> Unit, + onStart: () -> Unit, + onDeleteAll: () -> Unit, + onOpen: (LogFile) -> Unit, +) { + val colors = IosTheme.colors + IosScreenScaffold( + insets = insets, + title = title, + onBack = onBack, + trailing = if (files.isEmpty()) { + null + } else { + { + IosNavIcon( + icon = painterResource(R.drawable.ic_baseline_delete), + description = stringResource(R.string.delete_all_logs), + onClick = onDeleteAll, + tint = IosTheme.colors.red, + ) + } + }, + ) { + Column(Modifier.fillMaxSize()) { + Spacer(Modifier.height(12.dp)) + IosPreferenceRow( + IosDetail( + title = stringResource(R.string.logcat_open), + subtitle = stringResource(R.string.logcat_open_summary), + onClick = onStart, + ), + ) + Text( + text = stringResource(R.string.history), + style = IosType.footnote, + color = colors.secondaryLabel, + modifier = Modifier.padding(start = 16.dp, top = 22.dp, bottom = 6.dp), + ) + if (files.isEmpty()) { + IosEmptyState( + message = stringResource(R.string.list_empty), + modifier = Modifier.weight(1f), + ) + } else { + val context = LocalContext.current + LazyColumn(Modifier.weight(1f).fillMaxWidth()) { + itemsIndexed(files, key = { _, file -> file.fileName }) { index, file -> + IosListRow( + row = IosDetail( + title = file.date.format(context), + onClick = { onOpen(file) }, + ), + showDivider = index < files.lastIndex, + ) + } + } + } + } + } +} diff --git a/design/src/main/java/com/github/kr328/clash/design/ios/MetaFeatureSettingsScreen.kt b/design/src/main/java/com/github/kr328/clash/design/ios/MetaFeatureSettingsScreen.kt new file mode 100644 index 0000000000..6135e20471 --- /dev/null +++ b/design/src/main/java/com/github/kr328/clash/design/ios/MetaFeatureSettingsScreen.kt @@ -0,0 +1,21 @@ +package com.github.kr328.clash.design.ios + +import androidx.compose.runtime.Composable +import com.github.kr328.clash.design.ui.Insets + +@Composable +fun MetaFeatureSettingsScreen( + insets: Insets, + title: String, + groups: List, + onBack: () -> Unit, + onReset: () -> Unit, +) { + SettingsPage( + insets = insets, + title = title, + groups = groups, + onBack = onBack, + onReset = onReset, + ) +} diff --git a/design/src/main/java/com/github/kr328/clash/design/ios/NetworkSettingsScreen.kt b/design/src/main/java/com/github/kr328/clash/design/ios/NetworkSettingsScreen.kt new file mode 100644 index 0000000000..c357baf329 --- /dev/null +++ b/design/src/main/java/com/github/kr328/clash/design/ios/NetworkSettingsScreen.kt @@ -0,0 +1,253 @@ +package com.github.kr328.clash.design.ios + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.github.kr328.clash.design.R +import com.github.kr328.clash.design.ui.Insets +import com.github.kr328.clash.service.model.AccessControlMode + +data class NetworkSettingsUiState( + val enableVpn: Boolean = true, + val bypassPrivateNetwork: Boolean = true, + val dnsHijacking: Boolean = true, + val allowBypass: Boolean = true, + val allowIpv6: Boolean = false, + val systemProxy: Boolean = true, + val showSystemProxy: Boolean = false, + val tunStackMode: String = "mips", + val accessControlMode: AccessControlMode = AccessControlMode.AcceptAll, + val running: Boolean = false, + val activeStrategyName: String? = null, +) { + // Master switch is disabled only while the tunnel is running. + val vpnSwitchEnabled: Boolean get() = !running + + // vpnDependencies, not the app list. Also off when enableVpn is false. + val vpnDependenciesEnabled: Boolean get() = vpnSwitchEnabled && enableVpn +} + +private enum class NetworkOptionSheet { + Tun, + AccessMode, +} + +@Composable +fun NetworkSettingsScreen( + insets: Insets, + title: String, + state: NetworkSettingsUiState, + onBack: () -> Unit, + onEnableVpn: (Boolean) -> Unit, + onVpnBlocked: () -> Unit, + onBypassPrivateNetwork: (Boolean) -> Unit, + onDnsHijacking: (Boolean) -> Unit, + onAllowBypass: (Boolean) -> Unit, + onAllowIpv6: (Boolean) -> Unit, + onSystemProxy: (Boolean) -> Unit, + onTunStack: (String) -> Unit, + onAccessControlMode: (AccessControlMode) -> Unit, + onAccessControlList: () -> Unit, +) { + var sheet by remember { mutableStateOf(null) } + val tunValues = listOf("system", "gvisor", "mixed", "mips") + val tunLabels = listOf( + stringResource(R.string.tun_stack_system), + stringResource(R.string.tun_stack_gvisor), + stringResource(R.string.tun_stack_mixed), + stringResource(R.string.tun_stack_mips), + ) + val modes = AccessControlMode.values() + val modeLabels = listOf( + stringResource(R.string.allow_all_apps), + stringResource(R.string.allow_selected_apps), + stringResource(R.string.deny_selected_apps), + ) + val tunIndex = tunValues.indexOf(state.tunStackMode) + val modeIndex = modes.indexOf(state.accessControlMode) + val accessControlNote = state.activeStrategyName?.let { name -> + stringResource(R.string.access_control_idle, name) + } + val extra = if (state.showSystemProxy) 1 else 0 + val count = 7 + extra + val tunRow = 4 + extra + val modeRow = 5 + extra + val packagesRow = 6 + extra + val vpnTitle = stringResource(R.string.route_system_traffic) + val vpnSummary = stringResource(R.string.routing_via_vpn_service) + val dependenciesEnabled = state.vpnDependenciesEnabled + + IosScreenScaffold(insets = insets, title = title, onBack = onBack) { + Column( + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(top = 12.dp, bottom = 24.dp), + ) { + if (state.running) { + val interaction = remember { MutableInteractionSource() } + Box( + Modifier + .fillMaxWidth() + .clickable( + interactionSource = interaction, + indication = null, + onClick = onVpnBlocked, + ), + ) { + IosSwitchRow( + title = vpnTitle, + summary = vpnSummary, + checked = state.enableVpn, + enabled = false, + index = 0, + count = 1, + onCheckedChange = {}, + ) + } + } else { + IosSwitchRow( + title = vpnTitle, + summary = vpnSummary, + checked = state.enableVpn, + enabled = true, + index = 0, + count = 1, + onCheckedChange = onEnableVpn, + ) + } + Spacer(Modifier.height(22.dp)) + GroupLabel(stringResource(R.string.vpn_service_options)) + IosSwitchRow( + title = stringResource(R.string.bypass_private_network), + summary = stringResource(R.string.bypass_private_network_summary), + checked = state.bypassPrivateNetwork, + enabled = dependenciesEnabled, + index = 0, + count = count, + onCheckedChange = onBypassPrivateNetwork, + ) + IosSwitchRow( + title = stringResource(R.string.dns_hijacking), + summary = stringResource(R.string.dns_hijacking_summary), + checked = state.dnsHijacking, + enabled = dependenciesEnabled, + index = 1, + count = count, + onCheckedChange = onDnsHijacking, + ) + IosSwitchRow( + title = stringResource(R.string.allow_bypass), + summary = stringResource(R.string.allow_bypass_summary), + checked = state.allowBypass, + enabled = dependenciesEnabled, + index = 2, + count = count, + onCheckedChange = onAllowBypass, + ) + IosSwitchRow( + title = stringResource(R.string.allow_ipv6), + summary = stringResource(R.string.allow_ipv6_summary), + checked = state.allowIpv6, + enabled = dependenciesEnabled, + index = 3, + count = count, + onCheckedChange = onAllowIpv6, + ) + if (state.showSystemProxy) { + IosSwitchRow( + title = stringResource(R.string.system_proxy), + summary = stringResource(R.string.system_proxy_summary), + checked = state.systemProxy, + enabled = dependenciesEnabled, + index = 4, + count = count, + onCheckedChange = onSystemProxy, + ) + } + IosPreferenceRow( + row = IosDetail( + title = stringResource(R.string.tun_stack_mode), + subtitle = tunLabels.getOrNull(tunIndex) ?: state.tunStackMode, + enabled = dependenciesEnabled, + onClick = { sheet = NetworkOptionSheet.Tun }, + ), + index = tunRow, + count = count, + ) + IosPreferenceRow( + row = IosDetail( + title = stringResource(R.string.access_control_mode), + subtitle = modeLabels.getOrNull(modeIndex), + enabled = dependenciesEnabled, + onClick = { sheet = NetworkOptionSheet.AccessMode }, + ), + index = modeRow, + count = count, + ) + IosPreferenceRow( + row = IosDetail( + title = stringResource(R.string.access_control_packages), + subtitle = stringResource(R.string.access_control_packages_summary), + enabled = true, + onClick = onAccessControlList, + ), + index = packagesRow, + count = count, + ) + if (accessControlNote != null) { + Text( + text = accessControlNote, + style = IosType.footnote, + color = IosTheme.colors.secondaryLabel, + modifier = Modifier.padding(start = 32.dp, end = 32.dp, top = 8.dp), + ) + } + } + } + + when (sheet) { + NetworkOptionSheet.Tun -> IosOptionSheet( + title = stringResource(R.string.tun_stack_mode), + options = tunLabels, + selected = tunIndex, + onSelect = { onTunStack(tunValues[it]) }, + onDismiss = { sheet = null }, + ) + NetworkOptionSheet.AccessMode -> IosOptionSheet( + title = stringResource(R.string.access_control_mode), + options = modeLabels, + selected = modeIndex, + onSelect = { onAccessControlMode(modes[it]) }, + onDismiss = { sheet = null }, + ) + null -> Unit + } +} + +@Composable +private fun GroupLabel(text: String) { + Text( + text = text, + style = IosType.footnote, + color = IosTheme.colors.secondaryLabel, + modifier = Modifier.padding(start = 32.dp, end = 32.dp, bottom = 6.dp), + ) +} diff --git a/design/src/main/java/com/github/kr328/clash/design/ios/NewProfileScreen.kt b/design/src/main/java/com/github/kr328/clash/design/ios/NewProfileScreen.kt new file mode 100644 index 0000000000..f4f4476b6e --- /dev/null +++ b/design/src/main/java/com/github/kr328/clash/design/ios/NewProfileScreen.kt @@ -0,0 +1,96 @@ +package com.github.kr328.clash.design.ios + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.github.kr328.clash.design.R +import com.github.kr328.clash.design.model.ProfileProvider +import com.github.kr328.clash.design.ui.Insets + +@Composable +fun NewProfileScreen( + insets: Insets, + title: String, + querying: Boolean, + externals: List, + onBack: () -> Unit, + onFile: () -> Unit, + onUrl: () -> Unit, + onQr: () -> Unit, + onExternal: (ProfileProvider.External) -> Unit, + onDetail: (ProfileProvider.External) -> Unit, +) { + val builtin = listOf( + IosDetail( + title = stringResource(R.string.file), + subtitle = stringResource(R.string.import_from_file), + onClick = onFile, + ), + IosDetail( + title = stringResource(R.string.url), + subtitle = stringResource(R.string.import_from_url), + onClick = onUrl, + ), + IosDetail( + title = stringResource(R.string.qr), + subtitle = stringResource(R.string.import_from_qr), + onClick = onQr, + ), + ) + val detail = stringResource(R.string.detail) + val externalRows = externals.map { provider -> + IosDetail( + title = provider.name, + subtitle = provider.summary, + trailing = detail, + onClick = { onExternal(provider) }, + onTrailing = { onDetail(provider) }, + ) + } + IosScreenScaffold(insets = insets, title = title, onBack = onBack) { + Column( + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(top = 12.dp, bottom = 24.dp), + ) { + PreferenceGroup(builtin) + if (querying || externalRows.isNotEmpty()) { + Spacer(Modifier.height(22.dp)) + SectionLabel(stringResource(R.string.providers)) + if (querying) { + IosProgressRow(stringResource(R.string.loading)) + } else { + PreferenceGroup(externalRows) + } + } + } + } +} + +@Composable +private fun PreferenceGroup(rows: List) { + val count = rows.size + rows.forEachIndexed { index, row -> + IosPreferenceRow(row = row, index = index, count = count) + } +} + +@Composable +private fun SectionLabel(text: String) { + Text( + text = text, + style = IosType.footnote, + color = IosTheme.colors.secondaryLabel, + modifier = Modifier.padding(start = 32.dp, end = 32.dp, bottom = 6.dp), + ) +} diff --git a/design/src/main/java/com/github/kr328/clash/design/ios/OverrideSettingsScreen.kt b/design/src/main/java/com/github/kr328/clash/design/ios/OverrideSettingsScreen.kt new file mode 100644 index 0000000000..ed1084c504 --- /dev/null +++ b/design/src/main/java/com/github/kr328/clash/design/ios/OverrideSettingsScreen.kt @@ -0,0 +1,164 @@ +package com.github.kr328.clash.design.ios + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import com.github.kr328.clash.design.R +import com.github.kr328.clash.design.ui.Insets + +data class SettingRow( + val title: String, + val summary: String, + val enabled: Boolean = true, + val selectable: Boolean = false, + val options: List = emptyList(), + val selected: Int = -1, + val onClick: () -> Unit = {}, + val onSelect: (Int) -> Unit = {}, +) + +data class SettingGroup( + val title: String, + val rows: List, +) + +@Composable +fun OverrideSettingsScreen( + insets: Insets, + title: String, + groups: List, + onBack: () -> Unit, + onReset: () -> Unit, +) { + SettingsPage( + insets = insets, + title = title, + groups = groups, + onBack = onBack, + onReset = onReset, + ) +} + +@Composable +internal fun SettingsPage( + insets: Insets, + title: String, + groups: List, + onBack: () -> Unit, + onReset: () -> Unit, +) { + var sheet by remember { mutableStateOf(null) } + IosScreenScaffold( + insets = insets, + title = title, + onBack = onBack, + trailing = { ResetButton(onReset) }, + ) { + Column( + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(top = 12.dp, bottom = 24.dp), + ) { + groups.forEachIndexed { index, group -> + if (index > 0) { + Spacer(Modifier.height(22.dp)) + } + SettingGroupBlock(group) { row -> + if (row.selectable) { + sheet = row + } else { + row.onClick() + } + } + } + } + val current = sheet + if (current != null) { + IosOptionSheet( + title = current.title, + options = current.options, + selected = current.selected, + onSelect = current.onSelect, + onDismiss = { sheet = null }, + ) + } + } +} + +@Composable +private fun SettingGroupBlock( + group: SettingGroup, + onOpen: (SettingRow) -> Unit, +) { + val colors = IosTheme.colors + val count = group.rows.size + if (count == 0) return + Text( + text = group.title, + style = IosType.footnote, + color = colors.secondaryLabel, + modifier = Modifier.padding(start = 32.dp, end = 32.dp, bottom = 6.dp), + ) + group.rows.forEachIndexed { index, row -> + val summary = row.summary + val onSide = summary.fitsTrailingValue() + IosPreferenceRow( + row = IosDetail( + title = row.title, + subtitle = if (summary.isNotBlank() && !onSide) summary else null, + value = if (onSide) summary else null, + enabled = row.enabled, + onClick = if (row.enabled) { + { onOpen(row) } + } else { + null + }, + ), + index = index, + count = count, + ) + } +} + +@Composable +private fun ResetButton(onReset: () -> Unit) { + val colors = IosTheme.colors + val interaction = remember { MutableInteractionSource() } + val pressed by interaction.collectIsPressedAsState() + Box( + Modifier + .heightIn(min = 44.dp) + .background(if (pressed) colors.pressed else Color.Transparent) + .semantics { role = Role.Button } + .clickable(interactionSource = interaction, indication = null, onClick = onReset) + .padding(horizontal = 8.dp), + contentAlignment = Alignment.Center, + ) { + Text(text = stringResource(R.string.reset), style = IosType.body, color = colors.red) + } +} diff --git a/design/src/main/java/com/github/kr328/clash/design/ios/ProfilesScreen.kt b/design/src/main/java/com/github/kr328/clash/design/ios/ProfilesScreen.kt new file mode 100644 index 0000000000..59c404c167 --- /dev/null +++ b/design/src/main/java/com/github/kr328/clash/design/ios/ProfilesScreen.kt @@ -0,0 +1,303 @@ +package com.github.kr328.clash.design.ios + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Icon +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import com.github.kr328.clash.design.R +import com.github.kr328.clash.design.ui.Insets +import com.github.kr328.clash.design.util.elapsedIntervalString +import com.github.kr328.clash.design.util.isShareableSubscriptionUrl +import com.github.kr328.clash.design.util.toBytesString +import com.github.kr328.clash.design.util.toDateStr +import com.github.kr328.clash.service.model.Profile +import java.util.Locale + +@Composable +fun ProfilesScreen( + insets: Insets, + title: String, + profiles: List, + loaded: Boolean, + updatable: Boolean, + allUpdating: Boolean, + now: Long, + onBack: () -> Unit, + onCreate: () -> Unit, + onUpdateAll: () -> Unit, + onActive: (Profile) -> Unit, + onUpdate: (Profile) -> Unit, + onEdit: (Profile) -> Unit, + onShare: (Profile) -> Unit, + onDuplicate: (Profile) -> Unit, + onDelete: (Profile) -> Unit, +) { + var menu by remember { mutableStateOf(null) } + IosScreenScaffold( + insets = insets, + title = title, + onBack = onBack, + trailing = { + Row(verticalAlignment = Alignment.CenterVertically) { + if (allUpdating) { + Box(Modifier.size(44.dp), contentAlignment = Alignment.Center) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + color = IosTheme.colors.blue, + strokeWidth = 2.dp, + ) + } + } else if (updatable) { + BarIcon( + image = Icons.Filled.Refresh, + description = stringResource(R.string.update_all), + onClick = onUpdateAll, + ) + } + BarIcon( + image = Icons.Filled.Add, + description = stringResource(R.string._new), + onClick = onCreate, + ) + } + }, + ) { + when { + !loaded && profiles.isEmpty() -> IosProgressRow(stringResource(R.string.loading)) + profiles.isEmpty() -> IosEmptyState( + message = stringResource(R.string.list_empty), + action = stringResource(R.string._new), + onAction = onCreate, + ) + else -> LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(top = 8.dp, bottom = 24.dp), + ) { + itemsIndexed(profiles, key = { _, profile -> profile.uuid }) { index, profile -> + ProfileRow( + profile = profile, + now = now, + showDivider = index < profiles.lastIndex, + onActive = { onActive(profile) }, + onUpdate = { onUpdate(profile) }, + onMore = { menu = profile }, + ) + } + } + } + menu?.let { profile -> + IosActionSheet( + actions = profileActions( + profile = profile, + onEdit = onEdit, + onShare = onShare, + onDuplicate = onDuplicate, + onDelete = onDelete, + ), + onDismiss = { menu = null }, + cancelLabel = stringResource(R.string.cancel), + title = profile.name, + ) + } + } +} + +@Composable +private fun ProfileRow( + profile: Profile, + now: Long, + showDivider: Boolean, + onActive: () -> Unit, + onUpdate: () -> Unit, + onMore: () -> Unit, +) { + val fraction = usageFraction(profile) + val canUpdate = profile.imported && profile.type != Profile.Type.File + val updateIcon = rememberVectorPainter(Icons.Filled.Refresh) + val updateLabel = stringResource(R.string.update) + val detail = IosDetail( + title = profile.name, + subtitleAnnotated = profileSubtitle(profile, now, fraction), + selected = profile.active, + disclosure = false, + onClick = onActive, + ) + IosSideActionRow( + row = detail, + actionIcon = painterResource(R.drawable.ic_baseline_more_vert), + actionDescription = stringResource(R.string.more), + onAction = onMore, + showDivider = showDivider, + beforeAction = if (!canUpdate) { + null + } else { + { + IosNavIcon( + icon = updateIcon, + description = updateLabel, + onClick = onUpdate, + ) + } + }, + below = if (fraction == null) { + null + } else { + { + IosFractionBar( + fraction = fraction, + modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 8.dp), + ) + } + }, + ) +} + +@Composable +private fun profileActions( + profile: Profile, + onEdit: (Profile) -> Unit, + onShare: (Profile) -> Unit, + onDuplicate: (Profile) -> Unit, + onDelete: (Profile) -> Unit, +): List { + val edit = stringResource(R.string.edit) + val share = stringResource(R.string.share_subscription_qr) + val duplicate = stringResource(R.string.duplicate) + val delete = stringResource(R.string.delete) + return buildList { + add(IosAction(edit) { onEdit(profile) }) + if (isShareableSubscriptionUrl(profile.source)) { + add(IosAction(share) { onShare(profile) }) + } + if (profile.imported) { + add(IosAction(duplicate) { onDuplicate(profile) }) + } + add(IosAction(delete, destructive = true) { onDelete(profile) }) + } +} + +@Composable +private fun profileSubtitle(profile: Profile, now: Long, fraction: Float?): AnnotatedString { + val context = LocalContext.current + val expiredColor = IosTheme.colors.red + val typeName = when (profile.type) { + Profile.Type.File -> stringResource(R.string.file) + Profile.Type.Url -> stringResource(R.string.url) + Profile.Type.External -> stringResource(R.string.external) + } + val typeLine = when { + profile.pending -> stringResource(R.string.format_type_unsaved, typeName) + profile.type == Profile.Type.Url -> null + else -> typeName + } + val expiredLabel = stringResource(R.string.profile_expired) + val expire = expireLine(profile, now, expiredLabel) + val expired = profile.expire > 0L && profile.expire < now + val updated = if (profile.expire > 0L || profile.updatedAt <= 0L) { + null + } else { + (now - profile.updatedAt).elapsedIntervalString(context) + } + val lines = listOfNotNull(typeLine, trafficLine(profile, fraction), expire, updated) + return buildAnnotatedString { + lines.forEachIndexed { index, line -> + if (index > 0) append('\n') + if (expired && line == expire) { + withStyle(SpanStyle(color = expiredColor)) { append(line) } + } else { + append(line) + } + } + } +} + +private fun trafficLine(profile: Profile, fraction: Float?): String? { + val used = (profile.download + profile.upload).toBytesString() + if (fraction != null) { + return used + "/" + profile.total.toBytesString() + " · " + percentText(fraction) + } + if (profile.download < 2) return null + return used +} + +private fun percentText(fraction: Float): String { + val pct = (fraction * 100f).coerceIn(0f, 100f) + val number = if (pct > 0f && pct < 10f) { + String.format(Locale.US, "%.1f", pct) + } else { + String.format(Locale.US, "%.0f", pct) + } + return "$number%" +} + +private fun expireLine(profile: Profile, now: Long, expiredLabel: String): String? { + if (profile.expire <= 0L) return null + val date = profile.expire.toDateStr() + return if (profile.expire < now) "$expiredLabel $date" else date +} + +/** + * Old binding: max 1000, integer (download + upload) / (total / 1000). + * total/1000 == 0 throws. Skip the bar in that case. + */ +private fun usageFraction(profile: Profile): Float? { + if (profile.total < 2) return null + val divisor = profile.total / 1000 + if (divisor == 0L) return null + val ticks = (profile.download + profile.upload) / divisor + return (ticks.toFloat() / 1000f).coerceIn(0f, 1f) +} + +@Composable +private fun BarIcon( + image: ImageVector, + description: String, + onClick: () -> Unit, +) { + val interaction = remember { MutableInteractionSource() } + Box( + Modifier + .size(44.dp) + .semantics { role = Role.Button } + .clickable(interactionSource = interaction, indication = null, onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = image, + contentDescription = description, + tint = IosTheme.colors.blue, + modifier = Modifier.size(22.dp), + ) + } +} diff --git a/design/src/main/java/com/github/kr328/clash/design/ios/PropertiesScreen.kt b/design/src/main/java/com/github/kr328/clash/design/ios/PropertiesScreen.kt new file mode 100644 index 0000000000..173ba5d927 --- /dev/null +++ b/design/src/main/java/com/github/kr328/clash/design/ios/PropertiesScreen.kt @@ -0,0 +1,228 @@ +package com.github.kr328.clash.design.ios + +import android.text.Spanned +import android.widget.TextView +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import com.github.kr328.clash.design.R +import com.github.kr328.clash.design.ui.Insets +import com.github.kr328.clash.design.util.getHtml +import com.github.kr328.clash.design.util.isShareableSubscriptionUrl +import com.github.kr328.clash.service.model.Profile + +@Composable +fun PropertiesScreen( + insets: Insets, + title: String, + profile: Profile?, + processing: Boolean, + onBack: () -> Unit, + onSave: () -> Unit, + onName: () -> Unit, + onUrl: () -> Unit, + onInterval: () -> Unit, + onAgeKey: () -> Unit, + onShare: () -> Unit, + onBrowse: () -> Unit, +) { + val context = LocalContext.current + val tips = remember(context) { context.getHtml(R.string.tips_properties) } + IosScreenScaffold( + insets = insets, + title = title, + onBack = onBack, + trailing = { + if (processing) { + Box(Modifier.size(44.dp), contentAlignment = Alignment.Center) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + color = IosTheme.colors.blue, + strokeWidth = 2.dp, + ) + } + } else { + BarText(stringResource(R.string.save), onSave) + } + }, + ) { + if (profile == null) { + IosProgressRow(stringResource(R.string.loading)) + } else { + PropertiesBody( + profile = profile, + tips = tips, + onName = onName, + onUrl = onUrl, + onInterval = onInterval, + onAgeKey = onAgeKey, + onShare = onShare, + onBrowse = onBrowse, + ) + } + } +} + +@Composable +private fun PropertiesBody( + profile: Profile, + tips: Spanned, + onName: () -> Unit, + onUrl: () -> Unit, + onInterval: () -> Unit, + onAgeKey: () -> Unit, + onShare: () -> Unit, + onBrowse: () -> Unit, +) { + val file = profile.type == Profile.Type.File + val urlEditable = profile.type == Profile.Type.Url + val minutes = (profile.interval / 1000 / 60).toInt() + val intervalLabel = when { + file -> stringResource(R.string.unavailable) + profile.interval == 0L -> stringResource(R.string.disabled) + else -> stringResource(R.string.format_minutes, minutes) + } + val ageSet = !profile.ageSecretKey.isNullOrBlank() + val nameSide = sideValue(profile.name) + val sourceText = if (file) stringResource(R.string.unavailable) else profile.source + val sourceSide = sideValue(sourceText) + val configRows = listOf( + IosDetail( + title = stringResource(R.string.name), + subtitle = if (nameSide == null) profile.name.ifBlank { null } else null, + value = nameSide, + onClick = onName, + ), + IosDetail( + title = stringResource(R.string.url), + subtitle = if (sourceSide == null) sourceText.ifBlank { null } else null, + value = sourceSide, + enabled = !file, + onClick = if (urlEditable) onUrl else null, + ), + IosDetail( + title = stringResource(R.string.auto_update), + value = intervalLabel, + enabled = !file, + onClick = if (file) null else onInterval, + ), + IosDetail( + title = stringResource(R.string.age_secret_key), + // Named reuse: webdav_password_set is the existing 已设置 / Set string. + value = stringResource( + if (ageSet) R.string.webdav_password_set else R.string.webdav_unset, + ), + onClick = onAgeKey, + ), + ) + val contentRows = buildList { + if (isShareableSubscriptionUrl(profile.source)) { + add( + IosDetail( + title = stringResource(R.string.share_subscription_qr), + subtitle = stringResource(R.string.share_subscription_qr_tips), + onClick = onShare, + ), + ) + } + add( + IosDetail( + title = stringResource(R.string.browse_files), + subtitle = stringResource(R.string.browse_configuration_providers), + onClick = onBrowse, + ), + ) + } + Column( + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(top = 12.dp, bottom = 24.dp), + ) { + HtmlFootnote(tips) + Spacer(Modifier.height(8.dp)) + SectionLabel(stringResource(R.string.profile)) + PreferenceGroup(configRows) + Spacer(Modifier.height(22.dp)) + PreferenceGroup(contentRows) + } +} + +/** Right-side value is one line and at most 160dp. Wider text stays under the title. */ +private fun sideValue(text: String): String? = text.takeIf { it.fitsTrailingValue() } + +@Composable +private fun PreferenceGroup(rows: List) { + val count = rows.size + rows.forEachIndexed { index, row -> + IosPreferenceRow(row = row, index = index, count = count) + } +} + +@Composable +private fun SectionLabel(text: String) { + Text( + text = text, + style = IosType.footnote, + color = IosTheme.colors.secondaryLabel, + modifier = Modifier.padding(start = 32.dp, end = 32.dp, bottom = 6.dp), + ) +} + +@Composable +private fun HtmlFootnote(text: Spanned) { + val color = IosTheme.colors.secondaryLabel.toArgb() + AndroidView( + modifier = Modifier.padding(horizontal = 32.dp, vertical = 4.dp), + factory = { context -> + TextView(context).apply { + this.text = text + setTextColor(color) + textSize = 13f + setBackgroundColor(android.graphics.Color.TRANSPARENT) + } + }, + update = { view -> + view.text = text + view.setTextColor(color) + }, + ) +} + +@Composable +private fun BarText(text: String, onClick: () -> Unit) { + val interaction = remember { MutableInteractionSource() } + Box( + Modifier + .heightIn(min = 44.dp) + .semantics { role = Role.Button } + .clickable(interactionSource = interaction, indication = null, onClick = onClick) + .padding(horizontal = 8.dp), + contentAlignment = Alignment.Center, + ) { + Text(text = text, style = IosType.body, color = IosTheme.colors.blue) + } +} diff --git a/design/src/main/java/com/github/kr328/clash/design/ios/ProvidersScreen.kt b/design/src/main/java/com/github/kr328/clash/design/ios/ProvidersScreen.kt new file mode 100644 index 0000000000..e15aec19e9 --- /dev/null +++ b/design/src/main/java/com/github/kr328/clash/design/ios/ProvidersScreen.kt @@ -0,0 +1,175 @@ +package com.github.kr328.clash.design.ios + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.github.kr328.clash.core.model.Provider +import com.github.kr328.clash.design.R +import com.github.kr328.clash.design.ui.Insets +import com.github.kr328.clash.design.util.elapsedIntervalString + +data class ProviderRowUi( + val index: Int, + val provider: Provider, + val updatedAt: Long, + val updating: Boolean, +) + +@Composable +fun ProvidersScreen( + insets: Insets, + title: String, + rows: List, + now: Long, + onBack: () -> Unit, + onUpdate: (Int) -> Unit, + onUpdateAll: () -> Unit, +) { + val proxyRows = rows.filter { it.provider.type == Provider.Type.Proxy } + val ruleRows = rows.filter { it.provider.type == Provider.Type.Rule } + val proxyTitle = stringResource(R.string.proxy) + val ruleTitle = stringResource(R.string.rule) + IosScreenScaffold( + insets = insets, + title = title, + onBack = onBack, + trailing = if (rows.isEmpty()) { + null + } else { + { NavAction(stringResource(R.string.update_all), onClick = onUpdateAll) } + }, + ) { + if (rows.isEmpty()) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + IosEmptyState(message = stringResource(R.string.list_empty)) + } + } else { + LazyColumn( + Modifier.fillMaxSize(), + contentPadding = PaddingValues(bottom = 12.dp), + ) { + providerSection( + key = "proxy", + title = proxyTitle, + rows = proxyRows, + now = now, + onUpdate = onUpdate, + ) + providerSection( + key = "rule", + title = ruleTitle, + rows = ruleRows, + now = now, + onUpdate = onUpdate, + ) + } + } + } +} + +private fun LazyListScope.providerSection( + key: String, + title: String, + rows: List, + now: Long, + onUpdate: (Int) -> Unit, +) { + if (rows.isEmpty()) return + item(key = key) { + Text( + text = title, + style = IosType.footnote, + color = IosTheme.colors.secondaryLabel, + modifier = Modifier.padding(start = 32.dp, top = 18.dp, end = 16.dp, bottom = 6.dp), + ) + } + items(count = rows.size, key = { rows[it].index }) { index -> + ProviderLine( + row = rows[index], + now = now, + showDivider = index < rows.lastIndex, + onUpdate = onUpdate, + ) + } +} + +@Composable +private fun ProviderLine( + row: ProviderRowUi, + now: Long, + showDivider: Boolean, + onUpdate: (Int) -> Unit, +) { + if (row.updating) { + IosProgressRow(row.provider.name) + return + } + val inline = row.provider.vehicleType == Provider.VehicleType.Inline + val context = LocalContext.current + val updated = if (inline || row.updatedAt <= 0L) { + null + } else { + (now - row.updatedAt).elapsedIntervalString(context) + } + IosListRow( + row = IosDetail( + title = row.provider.name, + subtitle = providerTypeLabel(row.provider), + value = updated, + trailing = if (inline) null else stringResource(R.string.update), + onTrailing = if (inline) null else { + { onUpdate(row.index) } + }, + ), + showDivider = showDivider, + ) +} + +@Composable +private fun providerTypeLabel(provider: Provider): String { + val type = when (provider.type) { + Provider.Type.Proxy -> stringResource(R.string.proxy) + Provider.Type.Rule -> stringResource(R.string.rule) + } + val vehicle = when (provider.vehicleType) { + Provider.VehicleType.HTTP -> stringResource(R.string.http) + Provider.VehicleType.File -> stringResource(R.string.file) + Provider.VehicleType.Inline -> stringResource(R.string.inline) + Provider.VehicleType.Compatible -> stringResource(R.string.compatible) + } + return stringResource(R.string.format_provider_type, type, vehicle) +} + +@Composable +private fun NavAction(text: String, onClick: () -> Unit) { + val interaction = remember(text) { MutableInteractionSource() } + Text( + text = text, + style = IosType.body, + color = IosTheme.colors.blue, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .semantics { role = Role.Button } + .clickable(interactionSource = interaction, indication = null, onClick = onClick) + .padding(horizontal = 6.dp, vertical = 10.dp), + ) +} diff --git a/design/src/main/java/com/github/kr328/clash/design/ios/ProxyScreen.kt b/design/src/main/java/com/github/kr328/clash/design/ios/ProxyScreen.kt new file mode 100644 index 0000000000..cbcb0e3280 --- /dev/null +++ b/design/src/main/java/com/github/kr328/clash/design/ios/ProxyScreen.kt @@ -0,0 +1,322 @@ +package com.github.kr328.clash.design.ios + +import androidx.activity.OnBackPressedCallback +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.github.kr328.clash.core.model.Proxy +import com.github.kr328.clash.core.model.ProxySort +import com.github.kr328.clash.design.R +import com.github.kr328.clash.design.ui.Insets + +data class ProxyPageUi( + val name: String, + val type: String, + val now: String, + val proxies: List, + val selectable: Boolean, + val loaded: Boolean, + val loading: Boolean, + val urlTesting: Boolean, +) + +data class ProxyLinkUi( + val name: String, + val loaded: Boolean, + val now: String, +) + +@Composable +fun ProxyScreen( + insets: Insets, + title: String, + groupNames: List, + current: Int, + page: ProxyPageUi?, + links: List, + sort: ProxySort, + excludeNotSelectable: Boolean, + onBack: () -> Unit, + onPickGroup: (Int) -> Unit, + onSelect: (String) -> Unit, + onUrlTest: () -> Unit, + onSort: (ProxySort) -> Unit, + onExclude: (Boolean) -> Unit, +) { + var picking by remember { mutableStateOf(false) } + var menu by remember { mutableStateOf(false) } + var query by remember(current) { mutableStateOf("") } + var groupQuery by remember { mutableStateOf("") } + val activity = LocalContext.current as? AppCompatActivity + DisposableEffect(picking, activity) { + if (activity == null || !picking) { + onDispose { } + } else { + val callback = object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + picking = false + } + } + activity.onBackPressedDispatcher.addCallback(callback) + onDispose { callback.remove() } + } + } + val testing = page?.urlTesting == true + val hasGroups = groupNames.isNotEmpty() + + IosScreenScaffold( + insets = insets, + title = title, + onBack = { + if (picking) picking = false else onBack() + }, + trailing = { + Row(verticalAlignment = Alignment.CenterVertically) { + IosNavIcon( + icon = painterResource(R.drawable.ic_baseline_more_vert), + description = stringResource(R.string.more), + onClick = { menu = true }, + ) + if (hasGroups && !testing && !picking) { + IosNavIcon( + icon = painterResource(R.drawable.ic_baseline_flash_on), + description = stringResource(R.string.delay_test), + onClick = onUrlTest, + ) + } + } + }, + ) { + when { + !hasGroups -> { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + IosEmptyState(message = stringResource(R.string.proxy_empty_tips)) + } + } + page == null -> IosProgressRow(stringResource(R.string.loading)) + picking -> { + GroupPicker( + names = groupNames, + current = current, + query = groupQuery, + onQuery = { groupQuery = it }, + onPick = { index -> + picking = false + groupQuery = "" + onPickGroup(index) + }, + ) + } + else -> { + Column(Modifier.fillMaxSize()) { + val typeLabel = page.type.takeIf { page.loaded && it.isNotEmpty() } + val nowLabel = page.now.takeIf { page.loaded && it.isNotEmpty() } + val nowSide = nowLabel?.let { sideValue(it) } + val nowBelow = if (nowLabel != null && nowSide == null) nowLabel else null + IosPreferenceRow( + row = IosDetail( + title = page.name, + subtitle = when { + typeLabel != null && nowBelow != null -> "$typeLabel\n$nowBelow" + nowBelow != null -> nowBelow + else -> typeLabel + }, + value = nowSide, + onClick = if (groupNames.size > 1) { + { + groupQuery = "" + picking = true + } + } else { + null + }, + ), + ) + if (testing) { + IosProgressRow(stringResource(R.string.delay_test)) + } + IosSearchField( + value = query, + onValueChange = { query = it }, + placeholder = stringResource(R.string.search), + ) + if (page.loading || !page.loaded) { + IosProgressRow(stringResource(R.string.loading)) + } else { + val shown = page.proxies.filter { it.matches(query) } + if (shown.isEmpty()) { + Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { + IosEmptyState( + message = stringResource( + if (query.isEmpty()) R.string.list_empty else R.string.search_empty, + ), + ) + } + } else { + LazyColumn(Modifier.weight(1f)) { + items(count = shown.size) { index -> + val proxy = shown[index] + IosListRow( + row = IosDetail( + title = if (proxy.isGroup) proxy.name else proxy.title, + subtitle = proxySubtitle(proxy, links), + value = delayText(proxy.delay), + selected = proxy.name == page.now, + disclosure = false, + onClick = if (page.selectable) { + { onSelect(proxy.name) } + } else { + null + }, + ), + showDivider = index < shown.lastIndex, + ) + } + } + } + } + } + } + } + } + + if (menu) { + ProxyMenuSheet( + sort = sort, + excludeNotSelectable = excludeNotSelectable, + onSort = { + menu = false + onSort(it) + }, + onExclude = { + menu = false + onExclude(it) + }, + onDismiss = { menu = false }, + ) + } +} + +@Composable +private fun GroupPicker( + names: List, + current: Int, + query: String, + onQuery: (String) -> Unit, + onPick: (Int) -> Unit, +) { + val filtered = names.mapIndexed { index, name -> index to name } + .filter { (_, name) -> query.isEmpty() || name.contains(query, ignoreCase = true) } + Column(Modifier.fillMaxSize()) { + IosSearchField( + value = query, + onValueChange = onQuery, + placeholder = stringResource(R.string.search), + ) + if (query.isNotEmpty() && filtered.isEmpty()) { + Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { + IosEmptyState(message = stringResource(R.string.search_empty)) + } + } else { + LazyColumn(Modifier.weight(1f)) { + items(count = filtered.size) { index -> + val (groupIndex, name) = filtered[index] + IosListRow( + row = IosDetail( + title = name, + selected = groupIndex == current, + disclosure = false, + onClick = { onPick(groupIndex) }, + ), + showDivider = index < filtered.lastIndex, + ) + } + } + } + } +} + +@Composable +private fun ProxyMenuSheet( + sort: ProxySort, + excludeNotSelectable: Boolean, + onSort: (ProxySort) -> Unit, + onExclude: (Boolean) -> Unit, + onDismiss: () -> Unit, +) { + val options = listOf( + ProxySort.Default to stringResource(R.string.default_), + ProxySort.Title to stringResource(R.string.name), + ProxySort.Delay to stringResource(R.string.delay), + ) + IosSheet(onDismiss = onDismiss) { + Text( + text = stringResource(R.string.sort), + style = IosType.footnote, + color = IosTheme.colors.secondaryLabel, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + ) + val dismiss = LocalIosSheetDismiss.current + options.forEachIndexed { index, (value, label) -> + IosListRow( + row = IosDetail( + title = label, + selected = sort == value, + disclosure = false, + onClick = { dismiss { onSort(value) } }, + ), + showDivider = index < options.lastIndex, + ) + } + Text( + text = stringResource(R.string.filter), + style = IosType.footnote, + color = IosTheme.colors.secondaryLabel, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + ) + IosSwitchRow( + title = stringResource(R.string.not_selectable), + summary = null, + checked = excludeNotSelectable, + onCheckedChange = { checked -> dismiss { onExclude(checked) } }, + ) + } +} + +/** Right-side value is one line and at most 160dp. A long node name stays under the title. */ +private fun sideValue(text: String): String? = text.takeIf { it.fitsTrailingValue() } + +private fun Proxy.matches(query: String): Boolean { + if (query.isEmpty()) return true + return name.contains(query, ignoreCase = true) || title.contains(query, ignoreCase = true) +} + +private fun proxySubtitle(proxy: Proxy, links: List): String { + if (!proxy.isGroup) return proxy.subtitle + val link = links.find { it.name == proxy.name } ?: return proxy.type + if (!link.loaded) return proxy.type + return "%s(%s)".format(proxy.type, link.now.ifEmpty { "*" }) +} + +private fun delayText(delay: Int): String? { + if (delay !in 0..Short.MAX_VALUE) return null + return delay.toString() +} diff --git a/design/src/main/java/com/github/kr328/clash/design/ios/SettingsScreen.kt b/design/src/main/java/com/github/kr328/clash/design/ios/SettingsScreen.kt new file mode 100644 index 0000000000..295269f0dd --- /dev/null +++ b/design/src/main/java/com/github/kr328/clash/design/ios/SettingsScreen.kt @@ -0,0 +1,73 @@ +package com.github.kr328.clash.design.ios + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.github.kr328.clash.design.R +import com.github.kr328.clash.design.ui.Insets + +@Composable +fun SettingsScreen( + insets: Insets, + title: String, + onBack: () -> Unit, + onApp: () -> Unit, + onNetwork: () -> Unit, + onOverride: () -> Unit, + onMeta: () -> Unit, + onWebdav: () -> Unit, +) { + IosScreenScaffold(insets = insets, title = title, onBack = onBack) { + Column( + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(top = 12.dp, bottom = 24.dp), + ) { + IosInsetGroup( + title = stringResource(R.string.section_preferences), + rows = listOf( + IosDetail( + title = stringResource(R.string.app), + subtitle = stringResource(R.string.settings_app_summary), + onClick = onApp, + ), + IosDetail( + title = stringResource(R.string.network), + subtitle = stringResource(R.string.settings_network_summary), + onClick = onNetwork, + ), + ), + ) + Spacer(Modifier.height(22.dp)) + IosInsetGroup( + title = stringResource(R.string.section_advanced), + rows = listOf( + IosDetail( + title = stringResource(R.string.override), + subtitle = stringResource(R.string.settings_override_summary), + onClick = onOverride, + ), + IosDetail( + title = stringResource(R.string.meta_features), + subtitle = stringResource(R.string.settings_meta_summary), + onClick = onMeta, + ), + IosDetail( + title = stringResource(R.string.webdav_sync), + subtitle = stringResource(R.string.settings_webdav_summary), + onClick = onWebdav, + ), + ), + ) + } + } +} diff --git a/design/src/main/java/com/github/kr328/clash/design/ios/WebdavSettingsScreen.kt b/design/src/main/java/com/github/kr328/clash/design/ios/WebdavSettingsScreen.kt new file mode 100644 index 0000000000..6881671838 --- /dev/null +++ b/design/src/main/java/com/github/kr328/clash/design/ios/WebdavSettingsScreen.kt @@ -0,0 +1,253 @@ +package com.github.kr328.clash.design.ios + +import android.graphics.Typeface +import android.text.Spanned +import android.text.style.StyleSpan +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.github.kr328.clash.design.R +import com.github.kr328.clash.design.ui.Insets +import com.github.kr328.clash.design.util.getHtml + +data class WebdavSettingsUiState( + val url: String?, + val lanUrl: String?, + val username: String?, + val passwordEmpty: Boolean, + val remotePath: String?, + val autoSync: Boolean, + val status: String, +) + +@Composable +fun WebdavSettingsScreen( + insets: Insets, + title: String, + state: WebdavSettingsUiState, + onBack: () -> Unit, + onUrl: () -> Unit, + onLanUrl: () -> Unit, + onUsername: () -> Unit, + onPassword: () -> Unit, + onRemotePath: () -> Unit, + onAutoSync: (Boolean) -> Unit, + onTest: () -> Unit, + onSync: () -> Unit, + onPull: () -> Unit, + onPush: () -> Unit, +) { + val insetBottom = with(LocalDensity.current) { insets.bottom.toDp() } + // Scaffold already applies a non-zero bottom inset. Zero leaves the last action on the screen edge. + val bottomPad = if (insetBottom < 24.dp) 72.dp else 24.dp + IosScreenScaffold(insets = insets, title = title, onBack = onBack) { + Column( + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(top = 8.dp, bottom = bottomPad), + ) { + HtmlTip(R.string.webdav_tips, top = 8.dp, bottom = 8.dp) + Text( + text = state.status, + style = IosType.subhead, + color = IosTheme.colors.label, + modifier = Modifier + .fillMaxWidth() + .padding(start = 32.dp, end = 32.dp, top = 4.dp, bottom = 16.dp), + ) + GroupTitle(stringResource(R.string.webdav_server)) + ValueRow( + title = stringResource(R.string.webdav_url), + summary = fieldSummary( + state.url, + placeholder = R.string.webdav_url_placeholder, + empty = R.string.webdav_url_placeholder, + ), + index = 0, + count = 2, + onClick = onUrl, + ) + ValueRow( + title = stringResource(R.string.webdav_lan_url), + summary = fieldSummary( + state.lanUrl, + placeholder = R.string.webdav_unset, + empty = R.string.webdav_unset, + ), + index = 1, + count = 2, + onClick = onLanUrl, + ) + HtmlTip(R.string.webdav_lan_hint, top = 6.dp, bottom = 8.dp) + ValueRow( + title = stringResource(R.string.webdav_username), + summary = fieldSummary( + state.username, + placeholder = null, + empty = R.string.webdav_unset, + ), + index = 0, + count = 3, + onClick = onUsername, + ) + ValueRow( + title = stringResource(R.string.webdav_password), + summary = stringResource( + if (state.passwordEmpty) R.string.webdav_unset else R.string.webdav_password_set, + ), + index = 1, + count = 3, + onClick = onPassword, + ) + ValueRow( + title = stringResource(R.string.webdav_remote_path), + summary = fieldSummary( + state.remotePath, + placeholder = R.string.webdav_remote_path_default, + empty = R.string.webdav_remote_path_default, + ), + index = 2, + count = 3, + onClick = onRemotePath, + ) + Spacer(Modifier.height(22.dp)) + GroupTitle(stringResource(R.string.webdav_sync)) + IosSwitchRow( + title = stringResource(R.string.webdav_auto_sync), + summary = stringResource(R.string.webdav_auto_sync_summary), + checked = state.autoSync, + index = 0, + count = 5, + onCheckedChange = onAutoSync, + ) + IosPreferenceRow( + IosDetail( + title = stringResource(R.string.webdav_test), + onClick = onTest, + ), + index = 1, + count = 5, + ) + IosPreferenceRow( + IosDetail( + title = stringResource(R.string.webdav_sync_now), + onClick = onSync, + ), + index = 2, + count = 5, + ) + IosPreferenceRow( + IosDetail( + title = stringResource(R.string.webdav_pull), + onClick = onPull, + ), + index = 3, + count = 5, + ) + IosPreferenceRow( + IosDetail( + title = stringResource(R.string.webdav_push), + onClick = onPush, + ), + index = 4, + count = 5, + ) + } + } +} + +@Composable +private fun ValueRow( + title: String, + summary: String?, + index: Int, + count: Int, + onClick: () -> Unit, +) { + val side = summary?.takeIf { it.fitsTrailingValue() } + IosPreferenceRow( + IosDetail( + title = title, + subtitle = if (side == null) summary else null, + value = side, + onClick = onClick, + ), + index = index, + count = count, + ) +} + +@Composable +private fun fieldSummary( + text: String?, + @StringRes placeholder: Int?, + @StringRes empty: Int, +): String? { + if (text == null) { + return if (placeholder == null) null else stringResource(placeholder) + } + if (text.isEmpty()) return stringResource(empty) + return text +} + +@Composable +private fun HtmlTip( + @StringRes id: Int, + top: Dp = 8.dp, + bottom: Dp = 18.dp, +) { + val context = LocalContext.current + val text = remember(id, context) { context.getHtml(id).asIosText() } + Text( + text = text, + style = IosType.footnote, + color = IosTheme.colors.secondaryLabel, + modifier = Modifier + .fillMaxWidth() + .padding(start = 32.dp, end = 32.dp, top = top, bottom = bottom), + ) +} + +@Composable +private fun GroupTitle(title: String) { + Text( + text = title, + style = IosType.footnote, + color = IosTheme.colors.secondaryLabel, + modifier = Modifier.padding(start = 32.dp, end = 32.dp, bottom = 6.dp), + ) +} + +private fun Spanned.asIosText(): AnnotatedString { + val value = toString() + return buildAnnotatedString { + append(value) + getSpans(0, length, StyleSpan::class.java).forEach { span -> + if ((span.style and Typeface.BOLD) == 0) return@forEach + val start = getSpanStart(span).coerceIn(0, value.length) + val end = getSpanEnd(span).coerceIn(start, value.length) + if (start < end) { + addStyle(SpanStyle(fontWeight = FontWeight.Bold), start, end) + } + } + } +} diff --git a/design/src/main/java/com/github/kr328/clash/design/model/ProfilePageState.kt b/design/src/main/java/com/github/kr328/clash/design/model/ProfilePageState.kt deleted file mode 100644 index c61b13178b..0000000000 --- a/design/src/main/java/com/github/kr328/clash/design/model/ProfilePageState.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.github.kr328.clash.design.model - -class ProfilePageState { - var allUpdating = false -} \ No newline at end of file diff --git a/design/src/main/java/com/github/kr328/clash/design/model/ProviderState.kt b/design/src/main/java/com/github/kr328/clash/design/model/ProviderState.kt deleted file mode 100644 index fca111f61e..0000000000 --- a/design/src/main/java/com/github/kr328/clash/design/model/ProviderState.kt +++ /dev/null @@ -1,28 +0,0 @@ -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) - } -} \ No newline at end of file diff --git a/design/src/main/java/com/github/kr328/clash/design/model/ProxyPageState.kt b/design/src/main/java/com/github/kr328/clash/design/model/ProxyPageState.kt deleted file mode 100644 index 296cfcbddd..0000000000 --- a/design/src/main/java/com/github/kr328/clash/design/model/ProxyPageState.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.github.kr328.clash.design.model - -class ProxyPageState { - var bottom = false - var urlTesting = false -} \ No newline at end of file diff --git a/design/src/main/java/com/github/kr328/clash/design/preference/Value.kt b/design/src/main/java/com/github/kr328/clash/design/preference/Value.kt index cf2dcb79fc..2381df6d32 100644 --- a/design/src/main/java/com/github/kr328/clash/design/preference/Value.kt +++ b/design/src/main/java/com/github/kr328/clash/design/preference/Value.kt @@ -14,8 +14,9 @@ interface NullableTextAdapter { override fun to(text: String?): Int? { if (text == null) return null + if (text.isEmpty()) return 0 - return text.toIntOrNull() ?: 0 + return text.toIntOrNull() } } diff --git a/design/src/main/java/com/github/kr328/clash/design/util/ActivityBar.kt b/design/src/main/java/com/github/kr328/clash/design/util/ActivityBar.kt deleted file mode 100644 index 1024b051ee..0000000000 --- a/design/src/main/java/com/github/kr328/clash/design/util/ActivityBar.kt +++ /dev/null @@ -1,21 +0,0 @@ -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(R.id.activity_bar_close_view)?.apply { - setOnClickListener { - context.onBackPressed() - } - } - findViewById(R.id.activity_bar_title_view)?.apply { - text = context.title - } - } -} \ No newline at end of file diff --git a/design/src/main/java/com/github/kr328/clash/design/util/Elevation.kt b/design/src/main/java/com/github/kr328/clash/design/util/Elevation.kt deleted file mode 100644 index 759914ca20..0000000000 --- a/design/src/main/java/com/github/kr328/clash/design/util/Elevation.kt +++ /dev/null @@ -1,59 +0,0 @@ -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 - } -} \ No newline at end of file diff --git a/design/src/main/java/com/github/kr328/clash/design/util/ScrollView.kt b/design/src/main/java/com/github/kr328/clash/design/util/ScrollView.kt deleted file mode 100644 index e361eec711..0000000000 --- a/design/src/main/java/com/github/kr328/clash/design/util/ScrollView.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.github.kr328.clash.design.util - -import com.github.kr328.clash.design.view.ObservableScrollView - -val ObservableScrollView.isTop: Boolean - get() = scrollX == 0 && scrollY == 0 diff --git a/design/src/main/java/com/github/kr328/clash/design/view/ActionLabel.kt b/design/src/main/java/com/github/kr328/clash/design/view/ActionLabel.kt deleted file mode 100644 index 1c1344209d..0000000000 --- a/design/src/main/java/com/github/kr328/clash/design/view/ActionLabel.kt +++ /dev/null @@ -1,62 +0,0 @@ -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() - } - } - } -} \ No newline at end of file diff --git a/design/src/main/java/com/github/kr328/clash/design/view/ActionTextField.kt b/design/src/main/java/com/github/kr328/clash/design/view/ActionTextField.kt deleted file mode 100644 index 673ec19191..0000000000 --- a/design/src/main/java/com/github/kr328/clash/design/view/ActionTextField.kt +++ /dev/null @@ -1,87 +0,0 @@ -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() - } - } - } -} \ No newline at end of file diff --git a/design/src/main/java/com/github/kr328/clash/design/view/ActivityBarLayout.kt b/design/src/main/java/com/github/kr328/clash/design/view/ActivityBarLayout.kt deleted file mode 100644 index 7b0f6a1742..0000000000 --- a/design/src/main/java/com/github/kr328/clash/design/view/ActivityBarLayout.kt +++ /dev/null @@ -1,28 +0,0 @@ -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 - } -} \ No newline at end of file diff --git a/design/src/main/java/com/github/kr328/clash/design/view/AppRecyclerView.kt b/design/src/main/java/com/github/kr328/clash/design/view/AppRecyclerView.kt deleted file mode 100644 index e69fd234c8..0000000000 --- a/design/src/main/java/com/github/kr328/clash/design/view/AppRecyclerView.kt +++ /dev/null @@ -1,17 +0,0 @@ -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 - } -} diff --git a/design/src/main/java/com/github/kr328/clash/design/view/LargeActionCard.kt b/design/src/main/java/com/github/kr328/clash/design/view/LargeActionCard.kt deleted file mode 100644 index 0b75496dc9..0000000000 --- a/design/src/main/java/com/github/kr328/clash/design/view/LargeActionCard.kt +++ /dev/null @@ -1,65 +0,0 @@ -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(com.google.android.material.R.attr.colorSurface)) - } -} \ No newline at end of file diff --git a/design/src/main/java/com/github/kr328/clash/design/view/LargeActionLabel.kt b/design/src/main/java/com/github/kr328/clash/design/view/LargeActionLabel.kt deleted file mode 100644 index 4dd43432e0..0000000000 --- a/design/src/main/java/com/github/kr328/clash/design/view/LargeActionLabel.kt +++ /dev/null @@ -1,75 +0,0 @@ -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() - } - } - } -} \ No newline at end of file diff --git a/design/src/main/java/com/github/kr328/clash/design/view/ObservableScrollView.kt b/design/src/main/java/com/github/kr328/clash/design/view/ObservableScrollView.kt deleted file mode 100644 index 23c0883b44..0000000000 --- a/design/src/main/java/com/github/kr328/clash/design/view/ObservableScrollView.kt +++ /dev/null @@ -1,32 +0,0 @@ -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 = 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) - } -} \ No newline at end of file diff --git a/design/src/main/java/nota/ui/ios/ShellIosColors.kt b/design/src/main/java/nota/ui/ios/ShellIosColors.kt new file mode 100644 index 0000000000..15d8a2e97c --- /dev/null +++ b/design/src/main/java/nota/ui/ios/ShellIosColors.kt @@ -0,0 +1,71 @@ +package nota.ui.ios + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color +import nota.ui.list.ListRowColors +import nota.ui.settings.PreferenceItemColors +import nota.ui.switch.SwitchColors + +/** + * Set by ClashMetaXTheme from `UiStore.darkMode`. + * The library used `isSystemInDarkTheme()`, which ignores the in-app override. + */ +val LocalShellIosDark = staticCompositionLocalOf { false } + +internal fun iosColor(argb: Long): Color = Color(argb) + +@Composable +fun shellIosSwitchColors(tint: Color? = null): SwitchColors { + val dark = LocalShellIosDark.current + val onTrack = tint ?: iosColor(if (dark) ShellIosPalette.GreenDark else ShellIosPalette.Green) + val offTrack = iosColor(if (dark) ShellIosPalette.SwitchOffDark else ShellIosPalette.SwitchOff) + val thumb = iosColor(ShellIosPalette.Card) + return SwitchColors( + checkedTrackColor = onTrack, + uncheckedTrackColor = offTrack, + checkedThumbColor = thumb, + uncheckedThumbColor = thumb, + disabledCheckedTrackColor = onTrack.copy(alpha = 0.4f), + disabledUncheckedTrackColor = offTrack.copy(alpha = 0.4f), + disabledCheckedThumbColor = thumb.copy(alpha = 0.8f), + disabledUncheckedThumbColor = thumb.copy(alpha = 0.8f), + ) +} + +@Composable +fun shellIosPreferenceColors(): PreferenceItemColors { + val dark = LocalShellIosDark.current + val title = iosColor(if (dark) ShellIosPalette.LabelDark else ShellIosPalette.Label) + val summary = iosColor(if (dark) ShellIosPalette.SecondaryLabelDark else ShellIosPalette.SecondaryLabel) + val arrow = iosColor(if (dark) ShellIosPalette.ChevronDark else ShellIosPalette.Chevron) + return PreferenceItemColors( + titleColor = title, + summaryColor = summary, + arrowColor = arrow, + dividerColor = iosColor(if (dark) ShellIosPalette.SeparatorDark else ShellIosPalette.Separator), + containerColor = iosColor(if (dark) ShellIosPalette.CardDark else ShellIosPalette.Card), + disabledTitleColor = title.copy(alpha = 0.4f), + disabledSummaryColor = summary.copy(alpha = 0.4f), + disabledArrowColor = arrow.copy(alpha = 0.4f), + ) +} + +@Composable +fun shellIosListRowColors(): ListRowColors { + val dark = LocalShellIosDark.current + val title = iosColor(if (dark) ShellIosPalette.LabelDark else ShellIosPalette.Label) + val summary = iosColor(if (dark) ShellIosPalette.SecondaryLabelDark else ShellIosPalette.SecondaryLabel) + val trailing = iosColor(if (dark) ShellIosPalette.ChevronDark else ShellIosPalette.Chevron) + return ListRowColors( + containerColor = iosColor(if (dark) ShellIosPalette.CardDark else ShellIosPalette.Card), + titleColor = title, + summaryColor = summary, + trailingColor = trailing, + dividerColor = iosColor(if (dark) ShellIosPalette.SeparatorDark else ShellIosPalette.Separator), + disabledTitleColor = title.copy(alpha = 0.4f), + disabledSummaryColor = summary.copy(alpha = 0.4f), + disabledTrailingColor = trailing.copy(alpha = 0.4f), + ) +} + diff --git a/design/src/main/java/nota/ui/ios/ShellIosPalette.kt b/design/src/main/java/nota/ui/ios/ShellIosPalette.kt new file mode 100644 index 0000000000..e932a4359b --- /dev/null +++ b/design/src/main/java/nota/ui/ios/ShellIosPalette.kt @@ -0,0 +1,34 @@ +package nota.ui.ios + +/** + * iOS system colors as ARGB longs. Light values follow the system palette; + * dark values are the elevated counterparts used by grouped lists and controls. + */ +object ShellIosPalette { + const val Blue: Long = 0xFF007AFF + const val BlueDark: Long = 0xFF0A84FF + const val Green: Long = 0xFF34C759 + const val GreenDark: Long = 0xFF30D158 + const val Red: Long = 0xFFFF3B30 + const val RedDark: Long = 0xFFFF453A + const val Label: Long = 0xFF000000 + const val LabelDark: Long = 0xFFFFFFFF + const val SecondaryLabel: Long = 0x993C3C43 + const val SecondaryLabelDark: Long = 0x99EBEBF5 + const val GroupedBackground: Long = 0xFFF2F2F7 + const val GroupedBackgroundDark: Long = 0xFF000000 + const val Card: Long = 0xFFFFFFFF + const val CardDark: Long = 0xFF1C1C1E + const val SecondaryFill: Long = 0xFF2C2C2E + const val TertiaryFill: Long = 0xFFE5E5EA + const val TertiaryFillDark: Long = 0xFF3A3A3C + const val Separator: Long = 0xFFC6C6C8 + const val SeparatorDark: Long = 0xFF38383A + const val SwitchOff: Long = 0xFFE9E9EA + const val SwitchOffDark: Long = 0xFF39393D + const val Chevron: Long = 0xFFC7C7CC + const val ChevronDark: Long = 0xFF636366 + const val SegmentTrack: Long = 0x1F767680 + const val SegmentTrackDark: Long = 0x3D767680 + const val SegmentSelectedDark: Long = 0xFF636366 +} diff --git a/design/src/main/java/nota/ui/ios/ShellIosSpecs.kt b/design/src/main/java/nota/ui/ios/ShellIosSpecs.kt new file mode 100644 index 0000000000..6058d620e1 --- /dev/null +++ b/design/src/main/java/nota/ui/ios/ShellIosSpecs.kt @@ -0,0 +1,106 @@ +package nota.ui.ios + +import nota.ui.list.ListRowSpec +import nota.ui.settings.PreferenceGroupPosition +import nota.ui.settings.PreferenceItemSpec +import nota.ui.settings.resolvePreferenceContentHorizontalPadding +import nota.ui.switch.SwitchTrackSpec +import universe.agentic.ui.search.SearchFieldDimens + +/** UISwitch: 51×31 track, 27 thumb, 2 inset, 44 touch height. */ +fun shellIosSwitchSpec(): SwitchTrackSpec = + SwitchTrackSpec( + trackHeight = 31f, + trackWidth = 51f, + thumbOnHeight = 27f, + thumbOffHeight = 27f, + thumbOnWidth = 27f, + thumbOffWidth = 27f, + thumbOnCorner = 13.5f, + thumbOffCorner = 13.5f, + thumbMorphEnabled = false, + trackCorner = 15.5f, + horizontalInset = 2f, + minTouchTarget = 44f, + ) + +/** + * Inset grouped settings row. + * + * Card sits 16dp in from the screen. Text sits another 16dp in from the card, + * so [resolvePreferenceContentHorizontalPadding] is 16. Corners are 10dp. + * Hairline separators start at the title and end at the card's trailing edge. + */ +fun shellIosPreferenceSpec(): PreferenceItemSpec = + PreferenceItemSpec( + horizontalPadding = 16f, + immersiveHorizontalPadding = 32f, + immersiveCardInset = 16f, + verticalPaddingTitleOnly = 11f, + verticalPaddingWithSummary = 10f, + titleSummaryGap = 2f, + titleTextSize = 17f, + summaryTextSize = 15f, + summaryLineSpacingExtra = 0f, + dividerThickness = 0.5f, + dividerInsetStart = 32f, + dividerInsetEnd = 16f, + arrowSize = 13f, + widgetMinWidth = 0f, + widgetGap = 8f, + widgetGapImmersive = 8f, + leadingGap = 12f, + iconSize = 29f, + iconSizeSingleline = 29f, + iconCornerRadius = 7f, + minHeightTitleOnly = 44f, + minHeightWithSummary = 58f, + groupCornerRadius = 10f, + groupPaddingOuter = 11f, + groupPaddingInner = 11f, + groupPaddingOuterWithSummary = 10f, + groupPaddingInnerWithSummary = 10f, + ) + +/** Plain list row: 17/15 type, 44 minimum, hairline under the title. */ +fun shellIosListRowSpec(): ListRowSpec = + ListRowSpec( + horizontalPadding = 16f, + verticalPaddingTitleOnly = 12f, + verticalPaddingWithSummary = 8f, + titleSummaryGap = 2f, + titleTextSize = 17f, + summaryTextSize = 15f, + minHeight = 44f, + dividerThickness = 0.5f, + dividerInsetStart = 16f, + dividerInsetEnd = 0f, + arrowSize = 13f, + trailingGap = 8f, + leadingGap = 12f, + ) + +/** Search bar: 36 tall, 10 corner, 8 inner padding. Fill still comes from the host theme. */ +fun shellIosSearchDimens(): SearchFieldDimens = + SearchFieldDimens( + barHeightDefault = 36f, + touchTargetMin = 44f, + cornerPopup = 10f, + spaceM = 8f, + spaceS = 0f, + spaceXs = 6f, + iconSize = 15f, + clearButtonSize = 20f, + ) + +/** Head / middle / foot / alone for an inset group of [count] rows. */ +fun shellIosGroupPosition(index: Int, count: Int): PreferenceGroupPosition { + require(count > 0) { "count must be positive" } + require(index in 0 until count) { "index $index out of 0 until $count" } + return when { + count == 1 -> PreferenceGroupPosition.Alone + index == 0 -> PreferenceGroupPosition.Head + index == count - 1 -> PreferenceGroupPosition.Foot + else -> PreferenceGroupPosition.Middle + } +} diff --git a/design/src/main/java/nota/ui/list/ListRowColors.kt b/design/src/main/java/nota/ui/list/ListRowColors.kt new file mode 100644 index 0000000000..535d70b971 --- /dev/null +++ b/design/src/main/java/nota/ui/list/ListRowColors.kt @@ -0,0 +1,47 @@ +package nota.ui.list + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.Color + +@Immutable +data class ListRowColors( + val containerColor: Color, + val titleColor: Color, + val summaryColor: Color, + val trailingColor: Color, + val dividerColor: Color, + val disabledTitleColor: Color, + val disabledSummaryColor: Color, + val disabledTrailingColor: Color, +) + +object ListRowDefaults { + private const val DisabledContentAlpha = 0.38f + + /** + * Light iOS fallbacks. Callers that follow the app theme pass [nota.ui.ios.shellIosListRowColors]. + */ + @Composable + fun colors( + containerColor: Color = Color.White, + titleColor: Color = Color.Black, + summaryColor: Color = Color(0x993C3C43), + trailingColor: Color = Color(0xFFC7C7CC), + dividerColor: Color = Color(0xFFC6C6C8), + disabledAlpha: Float = DisabledContentAlpha, + disabledTitleColor: Color = titleColor.copy(alpha = disabledAlpha), + disabledSummaryColor: Color = summaryColor.copy(alpha = disabledAlpha), + disabledTrailingColor: Color = summaryColor.copy(alpha = disabledAlpha), + ): ListRowColors = + ListRowColors( + containerColor = containerColor, + titleColor = titleColor, + summaryColor = summaryColor, + trailingColor = trailingColor, + dividerColor = dividerColor, + disabledTitleColor = disabledTitleColor, + disabledSummaryColor = disabledSummaryColor, + disabledTrailingColor = disabledTrailingColor, + ) +} diff --git a/design/src/main/java/nota/ui/list/ListRowLayout.kt b/design/src/main/java/nota/ui/list/ListRowLayout.kt new file mode 100644 index 0000000000..f11444ede9 --- /dev/null +++ b/design/src/main/java/nota/ui/list/ListRowLayout.kt @@ -0,0 +1,40 @@ +package nota.ui.list + +/** Vertical padding (one side) from summary presence. */ +fun resolveListRowVerticalPadding( + hasSummary: Boolean, + spec: ListRowSpec = ListRowSpec(), +): Float = + if (hasSummary) spec.verticalPaddingWithSummary else spec.verticalPaddingTitleOnly + +/** Vertical padding (one side) from [ListRowVariant]. */ +fun resolveListRowVerticalPaddingForVariant( + variant: ListRowVariant, + spec: ListRowSpec = ListRowSpec(), +): Float = + resolveListRowVerticalPadding( + hasSummary = variant == ListRowVariant.DoubleLine, + spec = spec, + ) + +fun listRowHorizontalPadding(spec: ListRowSpec = ListRowSpec()): Float = spec.horizontalPadding + +fun listRowMinHeight(spec: ListRowSpec = ListRowSpec()): Float = spec.minHeight + +fun listRowTitleTextSize(spec: ListRowSpec = ListRowSpec()): Float = spec.titleTextSize + +fun listRowSummaryTextSize(spec: ListRowSpec = ListRowSpec()): Float = spec.summaryTextSize + +fun listRowTitleSummaryGap(spec: ListRowSpec = ListRowSpec()): Float = spec.titleSummaryGap + +fun listRowDividerThickness(spec: ListRowSpec = ListRowSpec()): Float = spec.dividerThickness + +fun listRowDividerInsetStart(spec: ListRowSpec = ListRowSpec()): Float = spec.dividerInsetStart + +fun listRowDividerInsetEnd(spec: ListRowSpec = ListRowSpec()): Float = spec.dividerInsetEnd + +fun listRowArrowSize(spec: ListRowSpec = ListRowSpec()): Float = spec.arrowSize + +fun listRowTrailingGap(spec: ListRowSpec = ListRowSpec()): Float = spec.trailingGap + +fun listRowLeadingGap(spec: ListRowSpec = ListRowSpec()): Float = spec.leadingGap diff --git a/design/src/main/java/nota/ui/list/ListRowSpec.kt b/design/src/main/java/nota/ui/list/ListRowSpec.kt new file mode 100644 index 0000000000..02a31fa227 --- /dev/null +++ b/design/src/main/java/nota/ui/list/ListRowSpec.kt @@ -0,0 +1,56 @@ +package nota.ui.list + +import nota.ui.theme.ShellSpacing + +/** + * Geometry SSOT for list rows. Values are logical dp (compose applies `.dp`). + * ref: meizu-common AAR `mz_basics_list_item_*` + `values.xml` dimen + */ +data class ListRowSpec( + /** ref: Widget.Flyme.List.Layout paddingStart/End → mz_list_publish_padding */ + val horizontalPadding: Float = ShellSpacing.Large, + /** ref: mz_list_item_content_vertical_padding_level5 (single-line) */ + val verticalPaddingTitleOnly: Float = 18f, + /** + * DoubleLine aligns with `mz_basics_list_item_double_line_large.xml` + * (`ContainerLinearLayout.Large` → level4 = 16dip). The normal variant is + * asymmetric 12/13 and is not a separate [ListRowVariant] yet. + */ + val verticalPaddingWithSummary: Float = 16f, + /** ref: mz_list_summary_item_text_padding_top_level1 */ + val titleSummaryGap: Float = 2f, + /** ref: fd_sys_typescale_body_large_size / mz_list_item_title_text_size */ + val titleTextSize: Float = 17f, + /** + * ref: TextAppearance.Flyme.Body.Mini → fd_sys_typescale_body_mini_size, the summary + * style `layout-v24/mz_preference.xml` applies. The pre-v24 layout uses + * `mz_list_item_text_3_size` = 12sp, which no v24+ device loads. + */ + val summaryTextSize: Float = 14f, + /** + * Library-own touch-target floor, not Flyme parity: every `mz_basics_list_item_*` root + * is `wrap_content`. 56 is the arithmetic of 2 × level5(18dip) + 17sp. + */ + val minHeight: Float = 56f, + /** ref: mz_list_item_divider_height */ + val dividerThickness: Float = 0.67f, + /** ref: mz_page_padding_default (mz_preference_category_no_title divider) */ + val dividerInsetStart: Float = 20f, + /** ref: mz_page_padding_default */ + val dividerInsetEnd: Float = 20f, + /** + * Deliberate deviation. `mz_basics_list_item_single_line_arrow.xml` uses an + * `ImageFontCompatView`, whose `fontTextSize` resolves through + * `mzIconFontCompatViewStyle` → `Widget.Flyme.IconFontStyle` = 24dp (class fallback + * `DEFAULT_TEXT_SIZE_IF_FONT` = 25 off a Flyme theme). Shell draws a vector chevron + * rather than an icon glyph, so it uses a smaller box. + */ + val arrowSize: Float = 16f, + /** ref: mz_basics_list_item_single_line_arrow 20dp spacer View */ + val trailingGap: Float = 20f, + /** + * Library-own: no `mz_basics_list_item_*` layout has an icon node. Value follows the + * nearest Flyme icon gap, `mz_preference_icon_margin_horizontal` = 16dip. + */ + val leadingGap: Float = 16f, +) diff --git a/design/src/main/java/nota/ui/list/ListRowVariant.kt b/design/src/main/java/nota/ui/list/ListRowVariant.kt new file mode 100644 index 0000000000..65cb2dd80d --- /dev/null +++ b/design/src/main/java/nota/ui/list/ListRowVariant.kt @@ -0,0 +1,16 @@ +package nota.ui.list + +enum class ListRowVariant { + /** ref: mz_basics_list_item_single_line_normal */ + SingleLine, + + /** ref: mz_basics_list_item_single_line_arrow */ + SingleLineArrow, + + /** + * Title + optional summary. + * Geometry aligns with `mz_basics_list_item_double_line_large.xml` (16/16, title 17sp), + * not `..._double_line_normal.xml` (12/13, title 14sp). + */ + DoubleLine, +} diff --git a/design/src/main/java/nota/ui/list/ShellListRow.kt b/design/src/main/java/nota/ui/list/ShellListRow.kt new file mode 100644 index 0000000000..de26313c20 --- /dev/null +++ b/design/src/main/java/nota/ui/list/ShellListRow.kt @@ -0,0 +1,340 @@ +@file:OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class) + +package nota.ui.list + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.disabled +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import nota.ui.theme.SHELL_LINE_HEIGHT_RATIO +import nota.ui.theme.shellPressBackground + +@Composable +fun ShellListRow( + onClick: (() -> Unit)?, + modifier: Modifier = Modifier, + variant: ListRowVariant = ListRowVariant.SingleLine, + enabled: Boolean = true, + showDivider: Boolean = false, + onLongClick: (() -> Unit)? = null, + leading: @Composable (() -> Unit)? = null, + trailing: @Composable (() -> Unit)? = null, + spec: ListRowSpec = ListRowSpec(), + colors: ListRowColors = ListRowDefaults.colors(), + content: @Composable RowScope.() -> Unit, +) { + ShellListRowImpl( + onClick = onClick, + modifier = modifier, + variant = variant, + enabled = enabled, + showDivider = showDivider, + onLongClick = onLongClick, + leading = leading, + trailing = trailing, + spec = spec, + colors = colors, + content = content, + ) +} + +/** + * Convenience overload — title + optional summary with Origin double-line typography. + * When [variant] is [ListRowVariant.DoubleLine], callers may still use the slot API and + * place two lines inside [content] themselves. + */ +@Composable +fun ShellListRow( + title: String, + onClick: (() -> Unit)?, + modifier: Modifier = Modifier, + summary: String? = null, + summaryAnnotated: AnnotatedString? = null, + variant: ListRowVariant = + if (summary.isNullOrEmpty() && summaryAnnotated.isNullOrEmpty()) { + ListRowVariant.SingleLine + } else { + ListRowVariant.DoubleLine + }, + enabled: Boolean = true, + showDivider: Boolean = false, + showArrow: Boolean = false, + onLongClick: (() -> Unit)? = null, + leading: @Composable (() -> Unit)? = null, + trailing: @Composable (() -> Unit)? = null, + spec: ListRowSpec = ListRowSpec(), + colors: ListRowColors = ListRowDefaults.colors(), +) { + val effectiveVariant = + when { + showArrow && variant == ListRowVariant.SingleLine -> ListRowVariant.SingleLineArrow + else -> variant + } + val effectiveTrailing = + trailing ?: if (showArrow && variant == ListRowVariant.DoubleLine) { + val trailingColor = if (enabled) colors.trailingColor else colors.disabledTrailingColor + { ListRowChevron(color = trailingColor, size = spec.arrowSize) } + } else { + null + } + + ShellListRowImpl( + onClick = onClick, + modifier = modifier, + variant = effectiveVariant, + enabled = enabled, + showDivider = showDivider, + onLongClick = onLongClick, + leading = leading, + trailing = effectiveTrailing, + spec = spec, + colors = colors, + ) { + ListRowTitleSummaryContent( + title = title, + summary = summary, + summaryAnnotated = summaryAnnotated, + spec = spec, + colors = colors, + enabled = enabled, + useDoubleLineLayout = variant == ListRowVariant.DoubleLine, + ) + } +} + +@Composable +private fun ShellListRowImpl( + onClick: (() -> Unit)?, + modifier: Modifier, + variant: ListRowVariant, + enabled: Boolean, + showDivider: Boolean, + onLongClick: (() -> Unit)?, + leading: @Composable (() -> Unit)?, + trailing: @Composable (() -> Unit)?, + spec: ListRowSpec, + colors: ListRowColors, + content: @Composable RowScope.() -> Unit, +) { + val verticalPadding = resolveListRowVerticalPaddingForVariant(variant, spec) + val trailingColor = if (enabled) colors.trailingColor else colors.disabledTrailingColor + val interactionSource = remember { MutableInteractionSource() } + val isInteractive = onClick != null || onLongClick != null + + val effectiveTrailing = + trailing ?: if (variant == ListRowVariant.SingleLineArrow) { + { ListRowChevron(color = trailingColor, size = spec.arrowSize) } + } else { + null + } + + Column( + modifier = + modifier + .fillMaxWidth() + .background(colors.containerColor), + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .defaultMinSize(minHeight = spec.minHeight.dp) + .shellPressBackground( + enabled = isInteractive && enabled, + interactionSource = interactionSource, + ) + .listRowClickable( + enabled = enabled, + interactionSource = interactionSource, + onClick = onClick, + onLongClick = onLongClick, + ) + .padding( + horizontal = spec.horizontalPadding.dp, + vertical = verticalPadding.dp, + ) + .semantics { + if (!enabled) disabled() + if (isInteractive) role = Role.Button + }, + verticalAlignment = Alignment.CenterVertically, + ) { + if (leading != null) { + leading() + Spacer(Modifier.width(spec.leadingGap.dp)) + } + + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically, + content = content, + ) + + if (effectiveTrailing != null) { + Spacer(Modifier.width(spec.trailingGap.dp)) + effectiveTrailing() + } + } + + if (showDivider) { + Box( + modifier = + Modifier + .fillMaxWidth() + .padding(start = spec.dividerInsetStart.dp, end = spec.dividerInsetEnd.dp) + .height(spec.dividerThickness.dp) + .background(colors.dividerColor), + ) + } + } +} + +@Composable +private fun RowScope.ListRowTitleSummaryContent( + title: String, + summary: String?, + summaryAnnotated: AnnotatedString?, + spec: ListRowSpec, + colors: ListRowColors, + enabled: Boolean, + useDoubleLineLayout: Boolean, +) { + val titleColor = if (enabled) colors.titleColor else colors.disabledTitleColor + val summaryColor = if (enabled) colors.summaryColor else colors.disabledSummaryColor + val annotated = summaryAnnotated?.takeIf { it.isNotEmpty() } + val hasSummary = annotated != null || !summary.isNullOrEmpty() + + val titleLineHeight = spec.titleTextSize * SHELL_LINE_HEIGHT_RATIO + val summaryLineHeight = spec.summaryTextSize * SHELL_LINE_HEIGHT_RATIO + + if (useDoubleLineLayout) { + Column(modifier = Modifier.weight(1f)) { + BasicText( + text = title, + style = + TextStyle( + color = titleColor, + fontSize = spec.titleTextSize.sp, + fontWeight = FontWeight.Medium, + lineHeight = titleLineHeight.sp, + ), + ) + if (hasSummary) { + Spacer(Modifier.height(spec.titleSummaryGap.dp)) + val summaryStyle = TextStyle( + color = summaryColor, + fontSize = spec.summaryTextSize.sp, + fontWeight = FontWeight.Normal, + lineHeight = summaryLineHeight.sp, + ) + if (annotated != null) { + BasicText(text = annotated, style = summaryStyle) + } else { + BasicText(text = summary.orEmpty(), style = summaryStyle) + } + } + } + } else { + BasicText( + text = title, + modifier = Modifier.weight(1f), + style = + TextStyle( + color = titleColor, + fontSize = spec.titleTextSize.sp, + fontWeight = FontWeight.Medium, + lineHeight = titleLineHeight.sp, + ), + ) + } +} + +private fun Modifier.listRowClickable( + enabled: Boolean, + interactionSource: MutableInteractionSource, + onClick: (() -> Unit)?, + onLongClick: (() -> Unit)?, +): Modifier = + when { + onClick != null && onLongClick != null -> + combinedClickable( + enabled = enabled, + interactionSource = interactionSource, + indication = null, + onClick = onClick, + onLongClick = onLongClick, + ) + onClick != null -> + clickable( + enabled = enabled, + interactionSource = interactionSource, + indication = null, + onClick = onClick, + ) + onLongClick != null -> + pointerInput(enabled, onLongClick) { + if (!enabled) return@pointerInput + detectTapGestures(onLongPress = { onLongClick() }) + } + else -> this + } + +@Composable +private fun ListRowChevron( + color: androidx.compose.ui.graphics.Color, + size: Float, +) { + Canvas(Modifier.size(size.dp)) { + val strokeWidth = (size.dp.toPx() * 0.09f).coerceAtLeast(1f) + val path = + Path().apply { + val w = this@Canvas.size.width + val h = this@Canvas.size.height + moveTo(w * 0.32f, h * 0.22f) + lineTo(w * 0.68f, h * 0.5f) + lineTo(w * 0.32f, h * 0.78f) + } + drawPath( + path = path, + color = color, + style = + Stroke( + width = strokeWidth, + cap = StrokeCap.Round, + join = StrokeJoin.Round, + ), + ) + } +} diff --git a/design/src/main/java/nota/ui/settings/PreferenceChevron.kt b/design/src/main/java/nota/ui/settings/PreferenceChevron.kt new file mode 100644 index 0000000000..c81a246413 --- /dev/null +++ b/design/src/main/java/nota/ui/settings/PreferenceChevron.kt @@ -0,0 +1,57 @@ +package nota.ui.settings + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp + +/** + * Right-pointing chevron drawn as vector strokes (ref: mz_right_arrow). + * Character glyphs such as `›` are forbidden. + */ +@Composable +internal fun PreferenceChevron( + color: Color, + chevronSize: Dp, + modifier: Modifier = Modifier, +) { + val rtl = LocalLayoutDirection.current == LayoutDirection.Rtl + Canvas(modifier.size(chevronSize)) { + val width = size.width + val height = size.height + val strokeWidth = (width * 0.1f).coerceAtLeast(1.25f) + val path = + Path().apply { + val insetX = width * 0.3f + val insetY = height * 0.22f + if (rtl) { + moveTo(width - insetX, insetY) + lineTo(insetX, height / 2f) + lineTo(width - insetX, height - insetY) + } else { + moveTo(insetX, insetY) + lineTo(width - insetX, height / 2f) + lineTo(insetX, height - insetY) + } + } + drawPath( + path = path, + color = color, + style = + Stroke( + width = strokeWidth, + cap = StrokeCap.Round, + join = StrokeJoin.Round, + ), + ) + } +} diff --git a/design/src/main/java/nota/ui/settings/PreferenceGroupPosition.kt b/design/src/main/java/nota/ui/settings/PreferenceGroupPosition.kt new file mode 100644 index 0000000000..1aa3088e4b --- /dev/null +++ b/design/src/main/java/nota/ui/settings/PreferenceGroupPosition.kt @@ -0,0 +1,19 @@ +package nota.ui.settings + +/** + * Immersive preference group card corners. + * ref: immersive_mz_preference_singleline_{head,middle,foot,alone}.xml + */ +enum class PreferenceGroupPosition { + /** Top card: outer top padding + top 10dp smooth corners. */ + Head, + + /** Mid card: inner padding, square corners. */ + Middle, + + /** Bottom card: outer bottom padding + bottom 10dp smooth corners. */ + Foot, + + /** Single-item card: outer padding + 10dp smooth corners on all sides. */ + Alone, +} diff --git a/design/src/main/java/nota/ui/settings/PreferenceItemColors.kt b/design/src/main/java/nota/ui/settings/PreferenceItemColors.kt new file mode 100644 index 0000000000..6890a57083 --- /dev/null +++ b/design/src/main/java/nota/ui/settings/PreferenceItemColors.kt @@ -0,0 +1,45 @@ +package nota.ui.settings + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.Color + +@Immutable +data class PreferenceItemColors( + val titleColor: Color, + val summaryColor: Color, + val arrowColor: Color, + val dividerColor: Color, + val containerColor: Color, + val disabledTitleColor: Color, + val disabledSummaryColor: Color, + val disabledArrowColor: Color, +) + +object PreferenceItemDefaults { + /** + * Light iOS fallbacks. Callers that follow the app theme pass [nota.ui.ios.shellIosPreferenceColors]. + */ + @Composable + fun colors( + titleColor: Color = Color.Black, + summaryColor: Color = Color(0x993C3C43), + arrowColor: Color = Color(0xFFC7C7CC), + dividerColor: Color = Color(0xFFC6C6C8), + containerColor: Color = Color.White, + disabledAlpha: Float = 0.4f, + disabledTitleColor: Color = titleColor.copy(alpha = disabledAlpha), + disabledSummaryColor: Color = summaryColor.copy(alpha = disabledAlpha), + disabledArrowColor: Color = arrowColor.copy(alpha = disabledAlpha), + ): PreferenceItemColors = + PreferenceItemColors( + titleColor = titleColor, + summaryColor = summaryColor, + arrowColor = arrowColor, + dividerColor = dividerColor, + containerColor = containerColor, + disabledTitleColor = disabledTitleColor, + disabledSummaryColor = disabledSummaryColor, + disabledArrowColor = disabledArrowColor, + ) +} diff --git a/design/src/main/java/nota/ui/settings/PreferenceItemSpec.kt b/design/src/main/java/nota/ui/settings/PreferenceItemSpec.kt new file mode 100644 index 0000000000..aebe06d27c --- /dev/null +++ b/design/src/main/java/nota/ui/settings/PreferenceItemSpec.kt @@ -0,0 +1,193 @@ +package nota.ui.settings + +import nota.ui.theme.ShellCorner + +/** + * Geometry SSOT for settings preference rows. + * Values are logical dp (compose applies `.dp`). + * ref: meizu-common AAR `mz_preference*.xml` / `immersive_mz_preference_singleline_*.xml` + */ +data class PreferenceItemSpec( + /** ref: mz_page_padding_default — non-immersive `mz_preference.xml` */ + val horizontalPadding: Float = 20f, + /** ref: immersive_mz_preference_* paddingStart / paddingEnd — literal 32dp */ + val immersiveHorizontalPadding: Float = 32f, + /** + * ref: immersive_item_bkg_{head,middle,foot,alone} `app:immersiveItemPadding` = 16dp; + * `ImmersiveListItemDrawable.onBoundsChange` insets the card by it on both sides, so + * [immersiveHorizontalPadding] is measured from the item, not the visible card edge. + */ + val immersiveCardInset: Float = 16f, + /** + * Title-only vertical padding. + * AAR `mz_preference_singleline` uses level3 = 14dip (developer doc says 18). + */ + val verticalPaddingTitleOnly: Float = 14f, + /** ref: mz_list_item_content_vertical_padding_level4 via Preference.ContentLinearLayout */ + val verticalPaddingWithSummary: Float = 16f, + /** ref: mz_list_summary_item_text_padding_top_level1 / immersive summary marginTop */ + val titleSummaryGap: Float = 2f, + /** ref: fd_sys_typescale_body_large_size */ + val titleTextSize: Float = 17f, + /** ref: mz_list_item_summary_text_size */ + val summaryTextSize: Float = 14f, + /** ref: mz_list_desc_line_spacing_extra */ + val summaryLineSpacingExtra: Float = 4f, + /** ref: mz_list_item_divider_height */ + val dividerThickness: Float = 0.67f, + /** ref: mz_page_padding_default */ + val dividerInsetStart: Float = 20f, + /** ref: mz_page_padding_default */ + val dividerInsetEnd: Float = 20f, + /** + * Deliberate deviation. Origin `ImageFontCompatView` resolves `fontTextSize` through + * `mzIconFontCompatViewStyle` → `Widget.Flyme.IconFontStyle` = 24dp (class fallback + * `DEFAULT_TEXT_SIZE_IF_FONT` = 25 when the host theme is not a Flyme theme). Shell's + * chevron is a vector path rather than an icon glyph, so it uses a smaller box. + */ + val arrowSize: Float = 16f, + /** + * `layout-v24/mz_preference.xml` widget_frame declares no `minWidth`; the 43dp + * `preference_widget_width` only survives in the pre-v24 layout, which no v24+ device loads. + */ + val widgetMinWidth: Float = 0f, + /** ref: mz_list_item_divider_margin_end — `layout-v24/mz_preference.xml` widget_frame paddingStart */ + val widgetGap: Float = 20f, + /** ref: preference_item_padding_inner — immersive layouts put it on the title `paddingEnd` */ + val widgetGapImmersive: Float = 14f, + /** ref: mz_preference_icon_margin_horizontal / mz_list_icon_and_text_padding */ + val leadingGap: Float = 16f, + /** ref: mz_list_icon_medium_width — `mz_preference.xml` icon */ + val iconSize: Float = 46f, + /** ref: mz_preference_singleline_image_size */ + val iconSizeSingleline: Float = 32f, + /** ref: mz_list_icon_item_icon_corner_radius */ + val iconCornerRadius: Float = 7f, + /** + * Deliberate deviation. `mz_preference_singleline_height` (56dip) is only read by + * `mz_preference_edittext_singleline.xml`; the modelled `mz_preference_singleline.xml` + * root is `wrap_content` with no `minHeight`. Kept as a library-own touch-target floor. + */ + val minHeightTitleOnly: Float = 56f, + /** + * Deliberate deviation. `mz_preference_min_height` (66dip) is only read by + * `mz_preference_edittext.xml`; `mz_preference.xml` sets no `minHeight`. + */ + val minHeightWithSummary: Float = 66f, + /** + * ref: fd_sys_radius_corner_smooth_small — immersive head/foot/alone. + * Origin builds the shape with Material `RoundedCornerTreatment` + * (`ImmersiveListItemDrawable.inflate`) despite the dimen name, so the curve is a + * plain arc rather than a smooth corner. + */ + val groupCornerRadius: Float = ShellCorner.Small, + /** ref: immersive_mz_preference_singleline_head/alone paddingTop */ + val groupPaddingOuter: Float = 26f, + /** ref: immersive_mz_preference_singleline_middle padding / head bottom / foot top */ + val groupPaddingInner: Float = 18f, + /** ref: layout-v24/immersive_mz_preference_head/alone paddingTop — summary variant */ + val groupPaddingOuterWithSummary: Float = 24f, + /** ref: layout-v24/immersive_mz_preference_middle padding — summary variant */ + val groupPaddingInnerWithSummary: Float = 16f, +) + +/** Vertical content padding for one preference row edge (top or bottom). */ +fun resolvePreferenceVerticalPadding( + hasSummary: Boolean, + spec: PreferenceItemSpec = PreferenceItemSpec(), +): Float = + if (hasSummary) spec.verticalPaddingWithSummary else spec.verticalPaddingTitleOnly + +/** Minimum row height including vertical padding and text block. */ +fun resolvePreferenceMinHeight( + hasSummary: Boolean, + spec: PreferenceItemSpec = PreferenceItemSpec(), +): Float = + if (hasSummary) spec.minHeightWithSummary else spec.minHeightTitleOnly + +/** Horizontal padding measured from the item edge, as the origin layouts declare it. */ +fun resolvePreferenceHorizontalPadding( + spec: PreferenceItemSpec = PreferenceItemSpec(), + groupPosition: PreferenceGroupPosition? = null, +): Float = + if (groupPosition != null) spec.immersiveHorizontalPadding else spec.horizontalPadding + +/** + * Horizontal padding measured from the visible card edge. The immersive background is + * inset by [PreferenceItemSpec.immersiveCardInset] on both sides, so the layout's 32dp + * leaves 32 − 16 = 16dp between the card edge and the text. + */ +fun resolvePreferenceContentHorizontalPadding( + spec: PreferenceItemSpec = PreferenceItemSpec(), + groupPosition: PreferenceGroupPosition? = null, +): Float = + resolvePreferenceHorizontalPadding(spec, groupPosition) - + if (groupPosition != null) spec.immersiveCardInset else 0f + +/** Gap between the text block and the trailing widget. */ +fun resolvePreferenceWidgetGap( + spec: PreferenceItemSpec = PreferenceItemSpec(), + groupPosition: PreferenceGroupPosition? = null, +): Float = if (groupPosition != null) spec.widgetGapImmersive else spec.widgetGap + +fun resolvePreferencePaddingTop( + hasSummary: Boolean, + spec: PreferenceItemSpec = PreferenceItemSpec(), + groupPosition: PreferenceGroupPosition? = null, +): Float = + when (groupPosition) { + PreferenceGroupPosition.Head, PreferenceGroupPosition.Alone -> + resolvePreferenceGroupPaddingOuter(hasSummary, spec) + PreferenceGroupPosition.Middle, PreferenceGroupPosition.Foot -> + resolvePreferenceGroupPaddingInner(hasSummary, spec) + null -> resolvePreferenceVerticalPadding(hasSummary, spec) + } + +fun resolvePreferencePaddingBottom( + hasSummary: Boolean, + spec: PreferenceItemSpec = PreferenceItemSpec(), + groupPosition: PreferenceGroupPosition? = null, +): Float = + when (groupPosition) { + PreferenceGroupPosition.Foot, PreferenceGroupPosition.Alone -> + resolvePreferenceGroupPaddingOuter(hasSummary, spec) + PreferenceGroupPosition.Head, PreferenceGroupPosition.Middle -> + resolvePreferenceGroupPaddingInner(hasSummary, spec) + null -> resolvePreferenceVerticalPadding(hasSummary, spec) + } + +/** + * Outer edge padding of an immersive card: 26dp title-only + * (`immersive_mz_preference_singleline_*`), 24dp with summary + * (`layout-v24/immersive_mz_preference_*`). + */ +private fun resolvePreferenceGroupPaddingOuter( + hasSummary: Boolean, + spec: PreferenceItemSpec, +): Float = if (hasSummary) spec.groupPaddingOuterWithSummary else spec.groupPaddingOuter + +/** Inner edge padding of an immersive card: 18dp title-only, 16dp with summary. */ +private fun resolvePreferenceGroupPaddingInner( + hasSummary: Boolean, + spec: PreferenceItemSpec, +): Float = if (hasSummary) spec.groupPaddingInnerWithSummary else spec.groupPaddingInner + +/** Top-edge smooth-corner radius for [groupPosition]; middle / null → 0. */ +fun resolvePreferenceGroupCornerTop( + groupPosition: PreferenceGroupPosition?, + spec: PreferenceItemSpec = PreferenceItemSpec(), +): Float = + when (groupPosition) { + PreferenceGroupPosition.Head, PreferenceGroupPosition.Alone -> spec.groupCornerRadius + else -> 0f + } + +/** Bottom-edge smooth-corner radius for [groupPosition]; middle / null → 0. */ +fun resolvePreferenceGroupCornerBottom( + groupPosition: PreferenceGroupPosition?, + spec: PreferenceItemSpec = PreferenceItemSpec(), +): Float = + when (groupPosition) { + PreferenceGroupPosition.Foot, PreferenceGroupPosition.Alone -> spec.groupCornerRadius + else -> 0f + } diff --git a/design/src/main/java/nota/ui/settings/ShellPreferenceItem.kt b/design/src/main/java/nota/ui/settings/ShellPreferenceItem.kt new file mode 100644 index 0000000000..1430290d83 --- /dev/null +++ b/design/src/main/java/nota/ui/settings/ShellPreferenceItem.kt @@ -0,0 +1,263 @@ +package nota.ui.settings + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.disabled +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import nota.ui.theme.SHELL_LINE_HEIGHT_RATIO +import nota.ui.theme.shellPressBackground + +/** + * Settings preference row — title + optional summary + trailing widget + divider. + * ref: mz_preference / mz_preference_singleline / immersive_mz_preference_singleline_* + * + * Pass [groupPosition] for Flyme immersive grouped cards (10dp rounded corners, inset + * horizontally by [PreferenceItemSpec.immersiveCardInset]). + */ +@Composable +fun ShellPreferenceItem( + title: String, + onClick: (() -> Unit)?, + modifier: Modifier = Modifier, + summary: String? = null, + enabled: Boolean = true, + showDivider: Boolean? = null, + showArrow: Boolean = true, + groupPosition: PreferenceGroupPosition? = null, + leading: @Composable (() -> Unit)? = null, + trailing: @Composable (() -> Unit)? = null, + spec: PreferenceItemSpec = PreferenceItemSpec(), + colors: PreferenceItemColors = PreferenceItemDefaults.colors(), +) { + val hasSummary = !summary.isNullOrEmpty() + val paddingTop = resolvePreferencePaddingTop(hasSummary = hasSummary, spec = spec, groupPosition = groupPosition) + val paddingBottom = resolvePreferencePaddingBottom(hasSummary = hasSummary, spec = spec, groupPosition = groupPosition) + val horizontalPadding = resolvePreferenceContentHorizontalPadding(spec = spec, groupPosition = groupPosition) + val widgetGap = resolvePreferenceWidgetGap(spec = spec, groupPosition = groupPosition) + val minHeight = resolvePreferenceMinHeight(hasSummary = hasSummary, spec = spec) + val effectiveShowDivider = showDivider ?: (groupPosition == null) + + val titleColor = if (enabled) colors.titleColor else colors.disabledTitleColor + val summaryColor = if (enabled) colors.summaryColor else colors.disabledSummaryColor + val arrowColor = if (enabled) colors.arrowColor else colors.disabledArrowColor + val titleLineHeight = spec.titleTextSize * SHELL_LINE_HEIGHT_RATIO + val summaryLineHeight = spec.summaryTextSize * SHELL_LINE_HEIGHT_RATIO + spec.summaryLineSpacingExtra + + val interactionSource = remember { MutableInteractionSource() } + val groupShape = preferenceGroupShape(groupPosition, spec) + val rowBackground = + if (groupPosition != null) { + Modifier + .padding(horizontal = spec.immersiveCardInset.dp) + .clip(groupShape) + .background(colors.containerColor, groupShape) + } else { + Modifier + } + + Column(modifier = modifier.fillMaxWidth()) { + val rowModifier = + Modifier + .fillMaxWidth() + .then(rowBackground) + .shellPressBackground( + shape = groupShape, + enabled = onClick != null && enabled, + interactionSource = interactionSource, + ) + .defaultMinSize(minHeight = minHeight.dp) + .then( + if (onClick != null && enabled) { + Modifier.clickable( + interactionSource = interactionSource, + indication = null, + onClick = onClick, + ) + } else { + Modifier + }, + ) + .padding( + start = horizontalPadding.dp, + top = paddingTop.dp, + end = horizontalPadding.dp, + bottom = paddingBottom.dp, + ) + .semantics(mergeDescendants = true) { + if (onClick != null) role = Role.Button + if (!enabled) disabled() + } + + Row( + modifier = rowModifier, + verticalAlignment = Alignment.CenterVertically, + ) { + if (leading != null) { + leading() + Spacer(modifier = Modifier.width(spec.leadingGap.dp)) + } + + Column(modifier = Modifier.weight(1f)) { + BasicText( + text = title, + style = + TextStyle( + color = titleColor, + fontSize = spec.titleTextSize.sp, + fontWeight = FontWeight.Medium, + lineHeight = titleLineHeight.sp, + ), + ) + if (hasSummary) { + Spacer(modifier = Modifier.height(spec.titleSummaryGap.dp)) + PreferenceSummaryText( + text = summary.orEmpty(), + color = summaryColor, + fontSizeSp = spec.summaryTextSize, + lineHeightSp = summaryLineHeight, + ) + } + } + + when { + trailing != null -> { + Spacer(modifier = Modifier.width(widgetGap.dp)) + Box( + modifier = Modifier.defaultMinSize(minWidth = spec.widgetMinWidth.dp), + contentAlignment = Alignment.Center, + ) { + trailing() + } + } + showArrow -> { + Spacer(modifier = Modifier.width(widgetGap.dp)) + Box( + modifier = Modifier.defaultMinSize(minWidth = spec.widgetMinWidth.dp), + contentAlignment = Alignment.Center, + ) { + PreferenceChevron( + color = arrowColor, + chevronSize = spec.arrowSize.dp, + ) + } + } + } + } + + if (effectiveShowDivider) { + Box( + modifier = + Modifier + .fillMaxWidth() + .padding(start = spec.dividerInsetStart.dp, end = spec.dividerInsetEnd.dp) + .height(spec.dividerThickness.dp) + .background(colors.dividerColor), + ) + } + } +} + +/** + * Keeps a one-character last line off preference summaries. + * The adjustment uses the text that was actually drawn. If that line still + * fits when the font is at least 92% of the requested size, it stays one line. + * Otherwise the last line takes about two extra ems from the line above. + */ +@Composable +private fun PreferenceSummaryText( + text: String, + color: Color, + fontSizeSp: Float, + lineHeightSp: Float, +) { + val density = LocalDensity.current + BoxWithConstraints(Modifier.fillMaxWidth()) { + val maxPx = constraints.maxWidth + var scale by remember(text, maxPx) { mutableFloatStateOf(1f) } + var endPad by remember(text, maxPx) { mutableStateOf(0.dp) } + BasicText( + text = text, + modifier = Modifier + .fillMaxWidth() + .padding(end = endPad), + style = + TextStyle( + color = color, + fontSize = (fontSizeSp * scale).sp, + fontWeight = FontWeight.Normal, + lineHeight = (lineHeightSp * scale).sp, + ), + onTextLayout = { result -> + if (result.lineCount <= 1 || maxPx <= 0) return@BasicText + val last = result.lineCount - 1 + val start = result.getLineStart(last) + val end = result.getLineEnd(last, visibleEnd = true).coerceAtMost(text.length) + if (text.substring(start, end).trim().length > 1) return@BasicText + val natural = result.multiParagraph.maxIntrinsicWidth + val fitted = if (natural > 0f) (maxPx - 4f) / natural else 0f + val next = minOf(fitted, scale * 0.97f) + if (next >= 0.92f && next < scale - 0.004f) { + scale = next + return@BasicText + } + if (endPad > 0.dp) return@BasicText + val em = with(density) { (fontSizeSp * scale).sp.toPx() } + val lastWidth = result.getLineRight(last) - result.getLineLeft(last) + val guard = (lastWidth + em).coerceIn(0f, maxPx * 0.34f) + endPad = with(density) { guard.toDp() } + }, + ) + } +} + +/** + * ref: `ImmersiveListItemDrawable.inflate` builds the card with + * `ShapeAppearanceModel.setAllCorners(RoundedCornerTreatment())` — a plain arc, not a + * smooth corner, despite the `fd_sys_radius_corner_smooth_small` dimen name. + */ +private fun preferenceGroupShape( + groupPosition: PreferenceGroupPosition?, + spec: PreferenceItemSpec, +): Shape { + val top = resolvePreferenceGroupCornerTop(groupPosition, spec).dp + val bottom = resolvePreferenceGroupCornerBottom(groupPosition, spec).dp + if (top == 0.dp && bottom == 0.dp) return RectangleShape + return RoundedCornerShape( + topStart = top, + topEnd = top, + bottomEnd = bottom, + bottomStart = bottom, + ) +} diff --git a/design/src/main/java/nota/ui/switch/ShellSwitch.kt b/design/src/main/java/nota/ui/switch/ShellSwitch.kt new file mode 100644 index 0000000000..2968f1d662 --- /dev/null +++ b/design/src/main/java/nota/ui/switch/ShellSwitch.kt @@ -0,0 +1,193 @@ +package nota.ui.switch + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.spring +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.draggable +import androidx.compose.foundation.gestures.rememberDraggableState +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.disabled +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.toggleableState +import androidx.compose.ui.state.ToggleableState +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import kotlin.math.abs +import kotlinx.coroutines.launch + +@Composable +fun ShellSwitch( + checked: Boolean, + onCheckedChange: ((Boolean) -> Unit)?, + modifier: Modifier = Modifier, + enabled: Boolean = true, + colors: SwitchColors = SwitchDefaults.colors(), + spec: SwitchTrackSpec = SwitchTrackSpec(), + interactionSource: MutableInteractionSource? = null, +) { + BindSwitchHaptic() + + val progress = remember { Animatable(if (checked) 1f else 0f) } + var dragging by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + val density = LocalDensity.current + val layoutDirection = LocalLayoutDirection.current + val currentChecked by rememberUpdatedState(checked) + val currentOnCheckedChange by rememberUpdatedState(onCheckedChange) + val actualInteractionSource = interactionSource ?: remember { MutableInteractionSource() } + + LaunchedEffect(checked, dragging) { + if (!dragging) { + val target = if (checked) 1f else 0f + if (abs(progress.value - target) < 0.001f) { + progress.snapTo(target) + } else { + progress.animateTo( + targetValue = target, + animationSpec = + spring( + dampingRatio = SwitchAnimationSpec.springDamping(checked), + stiffness = SwitchAnimationSpec.SpringStiffness, + ), + ) + } + } + } + + val touchTargetWidth = switchTouchTargetWidth(spec) + val touchTargetHeight = switchTouchTargetHeight(spec) + val travelDp = switchThumbTravel(spec) + val travelPx = with(density) { travelDp.dp.toPx() } + // Compose 1.7 ViewConfiguration has no minimumFlingVelocity. 50dp matches the + // platform scaled minimum fling velocity the library compared against. + val minFling = with(density) { 50.dp.toPx() } + val rtl = layoutDirection == LayoutDirection.Rtl + + val activeTrackColor = + if (enabled) colors.checkedTrackColor else colors.disabledCheckedTrackColor + val inactiveTrackColor = + if (enabled) colors.uncheckedTrackColor else colors.disabledUncheckedTrackColor + val activeThumbColor = + if (enabled) colors.checkedThumbColor else colors.disabledCheckedThumbColor + val inactiveThumbColor = + if (enabled) colors.uncheckedThumbColor else colors.disabledUncheckedThumbColor + + val draggableState = + rememberDraggableState { delta -> + val dragAmount = if (rtl) -delta else delta + val next = + progressAfterDrag( + startProgress = progress.value, + dragDelta = dragAmount, + travel = travelPx, + ) + scope.launch { progress.snapTo(next) } + } + + val switchModifier = + modifier + .semantics(mergeDescendants = false) { + role = Role.Switch + toggleableState = if (checked) ToggleableState.On else ToggleableState.Off + if (!enabled) disabled() + } + .then( + if (onCheckedChange != null && enabled) { + Modifier + .draggable( + state = draggableState, + orientation = Orientation.Horizontal, + enabled = true, + interactionSource = actualInteractionSource, + startDragImmediately = false, + onDragStarted = { dragging = true }, + onDragStopped = { velocity -> + val target = + checkedAfterRelease( + progress = progress.value, + velocityPxPerSec = velocity, + minFlingVelocityPxPerSec = minFling, + rtl = rtl, + ) + scope.launch { + // Meizu stopDrag → setChecked(..., useAnim=false): snap to edge. + progress.snapTo(if (target) 1f else 0f) + dragging = false + val callback = currentOnCheckedChange + if (callback != null && target != currentChecked) { + performSwitchHaptic() + callback(target) + } + } + }, + ) + .clickable( + interactionSource = actualInteractionSource, + indication = null, + role = Role.Switch, + onClick = { + performSwitchHaptic() + currentOnCheckedChange?.invoke(!currentChecked) + }, + ) + } else { + Modifier + }, + ) + .size(width = touchTargetWidth.dp, height = touchTargetHeight.dp) + .wrapContentSize(Alignment.Center) + + Box(modifier = switchModifier) { + Canvas(Modifier.size(spec.trackWidth.dp, spec.trackHeight.dp)) { + val p = progress.value + val geometry = resolveThumbGeometry(checked = checked, progress = p, spec = spec) + + val trackColor = lerp(inactiveTrackColor, activeTrackColor, p) + val thumbColor = lerp(inactiveThumbColor, activeThumbColor, p) + + // mc_switch_anim_track.xml is a GradientDrawable capsule, not a smooth corner. + drawRoundRect( + color = trackColor, + cornerRadius = CornerRadius(spec.trackCorner.dp.toPx()), + ) + + val thumbWidthPx = geometry.thumbWidth.dp.toPx() + val thumbHeightPx = geometry.thumbHeight.dp.toPx() + val thumbCornerPx = geometry.thumbCorner.dp.toPx() + val thumbTop = (size.height - thumbHeightPx) / 2f + val thumbLeft = geometry.thumbOffsetX.dp.toPx() + // DrawScope does not mirror; Switch.java:866-876 lays the thumb out from the right. + // iOS thumb corner is half the thumb size, so a round rect is the circle. + val mirroredLeft = if (rtl) size.width - thumbLeft - thumbWidthPx else thumbLeft + drawRoundRect( + color = thumbColor, + topLeft = Offset(mirroredLeft, thumbTop), + size = Size(thumbWidthPx, thumbHeightPx), + cornerRadius = CornerRadius(thumbCornerPx), + ) + } + } +} diff --git a/design/src/main/java/nota/ui/switch/SwitchAnimationSpec.kt b/design/src/main/java/nota/ui/switch/SwitchAnimationSpec.kt new file mode 100644 index 0000000000..812999e247 --- /dev/null +++ b/design/src/main/java/nota/ui/switch/SwitchAnimationSpec.kt @@ -0,0 +1,20 @@ +package nota.ui.switch + +/** + * Motion constants aligned to Flyme `com.meizu.common.widget.Switch` (meizu-common). + * + * Source: SpringAnimation + SpringForce — not a fixed tween duration. + */ +object SwitchAnimationSpec { + /** ref: Switch.STIFFNESS_DEFAULT */ + const val SpringStiffness: Float = 570f + + /** ref: Switch.DAMPING_CHECK_ON_DEFAULT — animate toward checked */ + const val SpringDampingOn: Float = 0.81f + + /** ref: Switch.DAMPING_CHECK_OFF_DEFAULT — animate toward unchecked */ + const val SpringDampingOff: Float = 0.69f + + fun springDamping(checked: Boolean): Float = + if (checked) SpringDampingOn else SpringDampingOff +} diff --git a/design/src/main/java/nota/ui/switch/SwitchColors.kt b/design/src/main/java/nota/ui/switch/SwitchColors.kt new file mode 100644 index 0000000000..687e237c47 --- /dev/null +++ b/design/src/main/java/nota/ui/switch/SwitchColors.kt @@ -0,0 +1,50 @@ +package nota.ui.switch + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.Color + +@Immutable +data class SwitchColors( + val checkedTrackColor: Color, + val uncheckedTrackColor: Color, + val checkedThumbColor: Color, + val uncheckedThumbColor: Color, + val disabledCheckedTrackColor: Color, + val disabledUncheckedTrackColor: Color, + val disabledCheckedThumbColor: Color, + val disabledUncheckedThumbColor: Color, +) + +object SwitchDefaults { + /** + * Light fallbacks. iOS screens pass [nota.ui.ios.shellIosSwitchColors]. + */ + @Composable + fun colors( + checkedTrackColor: Color = Color(0xFF34C759), + uncheckedTrackColor: Color = Color(0xFFE9E9EA), + checkedThumbColor: Color = Color.White, + uncheckedThumbColor: Color = Color.White, + disabledAlpha: Float = 0.25f, + disabledCheckedTrackColor: Color = checkedTrackColor.copy(alpha = disabledAlpha), + disabledUncheckedTrackColor: Color = uncheckedTrackColor.copy(alpha = disabledAlpha), + disabledThumbAlpha: Float = 1f, + disabledCheckedThumbColor: Color = checkedThumbColor.copy( + alpha = checkedThumbColor.alpha * disabledThumbAlpha, + ), + disabledUncheckedThumbColor: Color = uncheckedThumbColor.copy( + alpha = uncheckedThumbColor.alpha * disabledThumbAlpha, + ), + ): SwitchColors = + SwitchColors( + checkedTrackColor = checkedTrackColor, + uncheckedTrackColor = uncheckedTrackColor, + checkedThumbColor = checkedThumbColor, + uncheckedThumbColor = uncheckedThumbColor, + disabledCheckedTrackColor = disabledCheckedTrackColor, + disabledUncheckedTrackColor = disabledUncheckedTrackColor, + disabledCheckedThumbColor = disabledCheckedThumbColor, + disabledUncheckedThumbColor = disabledUncheckedThumbColor, + ) +} diff --git a/design/src/main/java/nota/ui/switch/SwitchGeometry.kt b/design/src/main/java/nota/ui/switch/SwitchGeometry.kt new file mode 100644 index 0000000000..31f362cf9a --- /dev/null +++ b/design/src/main/java/nota/ui/switch/SwitchGeometry.kt @@ -0,0 +1,86 @@ +package nota.ui.switch + +import kotlin.math.abs + +data class ThumbGeometry( + val thumbWidth: Float, + val thumbHeight: Float, + val thumbCorner: Float, + /** Offset from the track's left edge. */ + val thumbOffsetX: Float, +) + +/** + * @param checked Host intent; geometry uses [progress] as the sole interpolation source. + * @param progress 0 = fully unchecked, 1 = fully checked; mid-animation values allowed. + * + * The thumb keeps its on-state size unless [SwitchTrackSpec.thumbMorphEnabled] is set — see that + * flag for the AAR / token-sheet split. + */ +fun resolveThumbGeometry( + checked: Boolean, + progress: Float, + spec: SwitchTrackSpec = SwitchTrackSpec(), +): ThumbGeometry { + val p = progress.coerceIn(0f, 1f) + val morph = if (spec.thumbMorphEnabled) p else 1f + val thumbHeight = lerp(spec.thumbOffHeight, spec.thumbOnHeight, morph) + val thumbWidth = lerp(spec.thumbOffWidth, spec.thumbOnWidth, morph) + val thumbCorner = lerp(spec.thumbOffCorner, spec.thumbOnCorner, morph) + val travel = spec.trackWidth - 2f * spec.horizontalInset - thumbWidth + val thumbOffsetX = spec.horizontalInset + travel * p + return ThumbGeometry( + thumbWidth = thumbWidth, + thumbHeight = thumbHeight, + thumbCorner = thumbCorner, + thumbOffsetX = thumbOffsetX, + ) +} + +/** + * Control box (dp). Flyme measures the switch at `mSwitchWidth × mSwitchHeight` = 40 × 22 + * (`Switch.java:481-483`); [SwitchTrackSpec.minTouchTarget] expands it on both axes. + */ +fun switchTouchTargetWidth(spec: SwitchTrackSpec = SwitchTrackSpec()): Float = + maxOf(spec.minTouchTarget, spec.trackWidth) + +fun switchTouchTargetHeight(spec: SwitchTrackSpec = SwitchTrackSpec()): Float = + maxOf(spec.minTouchTarget, spec.trackHeight) + +/** + * Horizontal travel (dp) for thumb drag → progress mapping. + * Uses on-state thumb width (Switch.java getThumbScrollRange uses constant mThumbWidth = to_width). + */ +fun switchThumbTravel(spec: SwitchTrackSpec = SwitchTrackSpec()): Float = + (spec.trackWidth - 2f * spec.horizontalInset - spec.thumbOnWidth).coerceAtLeast(0f) + +/** Maps a horizontal drag delta (same unit as [travel]) onto progress in `0f..1f`. */ +fun progressAfterDrag( + startProgress: Float, + dragDelta: Float, + travel: Float, +): Float { + if (travel <= 0f) return startProgress.coerceIn(0f, 1f) + return (startProgress + dragDelta / travel).coerceIn(0f, 1f) +} + +/** + * Resolve checked intent after drag release — Flyme/AOSP `stopDrag` semantics. + * + * 1. If `|velocity| > minFlingVelocity`, direction wins (RTL flips sign). + * 2. Else midpoint: `progress > 0.5` → on (strict `>`, matches Meizu `getTargetCheckedState`). + */ +fun checkedAfterRelease( + progress: Float, + velocityPxPerSec: Float = 0f, + minFlingVelocityPxPerSec: Float = 0f, + rtl: Boolean = false, +): Boolean { + if (minFlingVelocityPxPerSec > 0f && abs(velocityPxPerSec) > minFlingVelocityPxPerSec) { + return if (rtl) velocityPxPerSec < 0f else velocityPxPerSec > 0f + } + return progress.coerceIn(0f, 1f) > 0.5f +} + +private fun lerp(start: Float, end: Float, fraction: Float): Float = + start + (end - start) * fraction diff --git a/design/src/main/java/nota/ui/switch/SwitchHaptic.kt b/design/src/main/java/nota/ui/switch/SwitchHaptic.kt new file mode 100644 index 0000000000..671cbfda3b --- /dev/null +++ b/design/src/main/java/nota/ui/switch/SwitchHaptic.kt @@ -0,0 +1,26 @@ +package nota.ui.switch + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback + +private var switchHapticHandler: (() -> Unit)? = null + +internal fun performSwitchHaptic() { + switchHapticHandler?.invoke() +} + +@Composable +internal fun BindSwitchHaptic() { + val hapticFeedback = LocalHapticFeedback.current + DisposableEffect(hapticFeedback) { + switchHapticHandler = { + // Compose 1.7 only has LongPress and TextHandleMove. + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + } + onDispose { + switchHapticHandler = null + } + } +} diff --git a/design/src/main/java/nota/ui/switch/SwitchTrackSpec.kt b/design/src/main/java/nota/ui/switch/SwitchTrackSpec.kt new file mode 100644 index 0000000000..7432abea40 --- /dev/null +++ b/design/src/main/java/nota/ui/switch/SwitchTrackSpec.kt @@ -0,0 +1,58 @@ +package nota.ui.switch + +import nota.ui.theme.ShellCorner +import nota.ui.theme.ShellSpacing + +/** + * Track and thumb dimension SSOT. + * + * ref: Token table — track 22dp, on thumb 14dp, off thumb 4dp, corners Regular/Mini/ExtraMini. + * trackWidth: Widget.MeizuCommon.Switch `mcSwitchMinWidth` = 40dip (values.xml); + * Switch.java onMeasure → max(minWidth, 2×thumbToWidth + insets) = max(40, 36) = 40dp. + */ +data class SwitchTrackSpec( + val trackHeight: Float = 22f, + /** mcSwitchMinWidth (values.xml) / Switch.java onMeasure */ + val trackWidth: Float = 40f, + /** mz_switch_thumb_to_height (values.xml) */ + val thumbOnHeight: Float = 14f, + /** mz_switch_thumb_from_height (values.xml) — only read when [thumbMorphEnabled]. */ + val thumbOffHeight: Float = 4f, + /** mz_switch_thumb_to_width (values.xml) */ + val thumbOnWidth: Float = 14f, + /** mz_switch_thumb_from_width (values.xml) — only read when [thumbMorphEnabled]. */ + val thumbOffWidth: Float = 9f, + /** mz_switch_thumb_to_corner_radius → fd_sys_radius_corner_smooth_mini */ + val thumbOnCorner: Float = ShellCorner.Mini, + /** mz_switch_thumb_from_corner_radius → fd_sys_radius_corner_smooth_extra_mini */ + val thumbOffCorner: Float = ShellCorner.ExtraMini, + /** + * Interpolate the thumb between the `from_*` and `to_*` dimens while the track animates. + * + * Defaults to the AAR 8.1.0 behaviour, which is **no morph**: `Switch.java:286-291` builds + * `mParams` once from `mz_switch_thumb_to_height/_to_width/_to_corner_radius` and + * `setThumbPosition` (`Switch.java:669-693`) re-applies those same constants every frame, so + * only x and the track colorFilter move. The three `mz_switch_thumb_from_*` dimens are + * zero-referenced across `res/` and `sources/`. + * + * ref: rejected origin — `flyme/F11 控件 token 整理-OCR.md` ("开关关闭状态:内圆高度 4dp,圆角 + * 4dp") does specify the morph; set this to true to follow the design token sheet instead. + */ + val thumbMorphEnabled: Boolean = false, + /** + * mc_switch_anim_track.xml `` — a plain + * `GradientDrawable` capsule, exactly half the track height. + * + * ref: rejected origin — the OCR token sheet says "圆角大于 11dp 即可,这里使用 cornerRegular + * 12dp"; `GradientDrawable` would clamp 12dp back down to the 11dp capsule anyway. + */ + val trackCorner: Float = 11f, + /** mz_switch_thumb_padding_left / right (values.xml) */ + val horizontalInset: Float = ShellSpacing.Mini, + /** + * Library-own accessibility floor (dp) for the control box; **not** Flyme parity, so it is off + * by default. Flyme measures the switch at `mSwitchWidth × mSwitchHeight` = 40dp × 22dp + * (`Switch.java:481-483`) with no square expansion. + */ + val minTouchTarget: Float = 0f, +) diff --git a/design/src/main/java/nota/ui/theme/ShellCorner.kt b/design/src/main/java/nota/ui/theme/ShellCorner.kt new file mode 100644 index 0000000000..16f7132a31 --- /dev/null +++ b/design/src/main/java/nota/ui/theme/ShellCorner.kt @@ -0,0 +1,23 @@ +package nota.ui.theme + +/** Corner radius scale (dp). Library SSOT; `ref:` → Flyme `fd_sys_radius_corner_*`. */ +object ShellCorner { + /** ref: fd_sys_radius_corner_none */ + const val None: Float = 0f + /** ref: fd_sys_radius_corner_smooth_extra_mini */ + const val ExtraMini: Float = 4f + /** ref: fd_sys_radius_corner_smooth_mini */ + const val Mini: Float = 8f + /** ref: fd_sys_radius_corner_smooth_small */ + const val Small: Float = 10f + /** ref: fd_sys_radius_corner_smooth_regular */ + const val Regular: Float = 12f + /** ref: fd_sys_radius_corner_smooth_medium */ + const val Medium: Float = 16f + /** ref: fd_sys_radius_corner_smooth_large */ + const val Large: Float = 20f + /** ref: fd_sys_radius_corner_smooth_heavy */ + const val Heavy: Float = 24f + /** ref: fd_sys_radius_corner_smooth_extra_heavy */ + const val ExtraHeavy: Float = 32f +} diff --git a/design/src/main/java/nota/ui/theme/ShellLineHeight.kt b/design/src/main/java/nota/ui/theme/ShellLineHeight.kt new file mode 100644 index 0000000000..746dd87e5d --- /dev/null +++ b/design/src/main/java/nota/ui/theme/ShellLineHeight.kt @@ -0,0 +1,7 @@ +package nota.ui.theme + +/** + * Platform default line spacing used by the copied text styles. + * Copied from AgenticUILibrary `ShellTypographyScale`. + */ +const val SHELL_LINE_HEIGHT_RATIO: Float = 1.2f diff --git a/design/src/main/java/nota/ui/theme/ShellPress.kt b/design/src/main/java/nota/ui/theme/ShellPress.kt new file mode 100644 index 0000000000..7acc292f4f --- /dev/null +++ b/design/src/main/java/nota/ui/theme/ShellPress.kt @@ -0,0 +1,58 @@ +package nota.ui.theme + +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.drawOutline + +/** + * Press tint for copied list rows and preference cards. + * + * The library reads this from `LocalShellTheme`. This module supplies the tint + * through [LocalShellPressTint] so screens can follow `UiStore.darkMode`. + */ +val LocalShellPressTint = staticCompositionLocalOf { Color(0x14000000) } + +private val PressEasing = CubicBezierEasing(0.2f, 0f, 0f, 1f) +private const val PressDurationMs = 80 +private const val ReleaseDurationMs = 160 + +@Composable +fun Modifier.shellPressBackground( + shape: Shape = RectangleShape, + enabled: Boolean = true, + interactionSource: MutableInteractionSource? = null, +): Modifier { + if (!enabled) return this + + val tint = LocalShellPressTint.current + val source = interactionSource ?: remember { MutableInteractionSource() } + val pressed by source.collectIsPressedAsState() + val progress by animateFloatAsState( + targetValue = if (pressed) 1f else 0f, + animationSpec = tween( + durationMillis = if (pressed) PressDurationMs else ReleaseDurationMs, + easing = PressEasing, + ), + label = "shellPressBackground", + ) + + return drawBehind { + if (progress <= 0f) return@drawBehind + drawOutline( + outline = shape.createOutline(size, layoutDirection, this), + color = tint.copy(alpha = tint.alpha * progress), + ) + } +} diff --git a/design/src/main/java/nota/ui/theme/ShellSpacing.kt b/design/src/main/java/nota/ui/theme/ShellSpacing.kt new file mode 100644 index 0000000000..b84d9933df --- /dev/null +++ b/design/src/main/java/nota/ui/theme/ShellSpacing.kt @@ -0,0 +1,27 @@ +package nota.ui.theme + +/** Spacing scale (dp). Library SSOT; `ref:` → Flyme `fd_sys_margin_*`. */ +object ShellSpacing { + /** ref: fd_sys_margin_none */ + const val None: Float = 0f + /** ref: fd_sys_margin_extra_mini */ + const val ExtraMini: Float = 2f + /** ref: fd_sys_margin_mini */ + const val Mini: Float = 4f + /** ref: fd_sys_margin_small */ + const val Small: Float = 8f + /** ref: fd_sys_margin_regular */ + const val Regular: Float = 12f + /** ref: fd_sys_margin_medium */ + const val Medium: Float = 16f + /** ref: fd_sys_margin_large */ + const val Large: Float = 20f + /** ref: fd_sys_margin_heavy */ + const val Heavy: Float = 24f + /** ref: fd_sys_margin_extra_heavy */ + const val ExtraHeavy: Float = 32f + /** ref: fd_sys_margin_bottom_default */ + const val BottomDefault: Float = 48f + /** ref: fd_sys_margin_bottom_max */ + const val BottomMax: Float = 64f +} diff --git a/design/src/main/java/universe/agentic/ui/search/SearchField.kt b/design/src/main/java/universe/agentic/ui/search/SearchField.kt new file mode 100644 index 0000000000..89443b7d05 --- /dev/null +++ b/design/src/main/java/universe/agentic/ui/search/SearchField.kt @@ -0,0 +1,188 @@ +@file:Suppress("FunctionName") + +package universe.agentic.ui.search + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +/** + * Cross-client single-line search / filter chrome ([BasicTextField]). + * + * Public API is brand-neutral — no `Singular*` / `Singularity*` symbols. + * Product icons enter via [leadingIcon]; the library does not bind product resources. + */ +@Composable +fun SearchField( + value: String, + onValueChange: (String) -> Unit, + placeholder: String, + modifier: Modifier = Modifier, + variant: SearchFieldVariant = SearchFieldVariant.Filled, + dimens: SearchFieldDimens = SearchFieldDimens.Default, + showSearchIcon: Boolean = true, + showClearButton: Boolean = true, + leadingIcon: (@Composable () -> Unit)? = null, + rowHeight: Dp? = null, + focusRequester: FocusRequester? = null, + imeAction: ImeAction = ImeAction.Search, + onImeSearch: (() -> Unit)? = null, + trailing: (@Composable RowScope.() -> Unit)? = null, + enabled: Boolean = true, + textStyle: TextStyle? = null, + containerColor: Color = Color(0xFFE5E5EA), + contentColor: Color = Color.Black, + placeholderColor: Color = Color(0x993C3C43), + cursorColor: Color = Color(0xFF007AFF), + clearTint: Color = Color(0x993C3C43), + clearContentDescription: String = "Clear", + testTag: String? = null, + onFocusChanged: ((Boolean) -> Unit)? = null, +) { + val resolvedTextStyle = textStyle ?: TextStyle(fontSize = 17.sp, lineHeight = 22.sp) + val fieldShape = RoundedCornerShape(dimens.cornerPopup.dp) + val showClear = showClearButton && value.isNotEmpty() + val minHeight = rowHeight ?: dimens.barHeightDefault.dp + + val variantChrome = + when { + hasFilledOuterMargin(variant) -> { + Modifier + .padding(horizontal = dimens.spaceM.dp, vertical = dimens.spaceS.dp) + .clip(fieldShape) + .background(containerColor) + } + usesFilledBackground(variant) -> { + Modifier + .clip(fieldShape) + .background(containerColor) + } + else -> Modifier + } + + val fieldModifier = + modifier + .fillMaxWidth() + .then(variantChrome) + .height(minHeight) + .padding( + start = dimens.spaceM.dp, + end = innerEndPadding(showClear, dimens.spaceXs, dimens.spaceM).dp, + ) + + Row( + modifier = fieldModifier, + verticalAlignment = Alignment.CenterVertically, + ) { + if (showSearchIcon) { + if (leadingIcon != null) { + leadingIcon() + } else { + Box(modifier = Modifier.size(dimens.iconSize.dp)) + } + } + val textPadding = + if (showSearchIcon) { + Modifier.padding(horizontal = dimens.spaceXs.dp) + } else { + Modifier.padding(vertical = dimens.spaceXs.dp) + } + val textFieldModifier = + Modifier + .weight(1f) + .then(textPadding) + .then(if (testTag != null) Modifier.testTag(testTag) else Modifier) + .then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier) + .then( + if (onFocusChanged != null) { + Modifier.onFocusChanged { focusState -> + onFocusChanged(focusState.isFocused) + } + } else { + Modifier + }, + ) + + BasicTextField( + value = value, + onValueChange = onValueChange, + enabled = enabled, + singleLine = true, + textStyle = resolvedTextStyle.copy(color = contentColor), + cursorBrush = SolidColor(cursorColor), + keyboardOptions = KeyboardOptions(imeAction = imeAction), + keyboardActions = + KeyboardActions( + onSearch = { onImeSearch?.invoke() }, + ), + modifier = textFieldModifier, + decorationBox = { inner -> + Box(contentAlignment = Alignment.CenterStart) { + if (value.isEmpty()) { + BasicText( + text = placeholder, + style = resolvedTextStyle.copy(color = placeholderColor), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + inner() + } + }, + ) + if (showClear) { + val effectiveRowHeight = (rowHeight ?: dimens.barHeightDefault.dp).value + val clearSlot = resolveClearSlot(dimens, effectiveRowHeight) + SearchFieldClearButton( + onClick = { onValueChange("") }, + enabled = enabled, + tint = clearTint, + contentDescription = clearContentDescription, + layoutWidth = clearSlot.layoutWidth.dp, + visualHeight = clearSlot.layoutHeight.dp, + touchTargetSize = clearSlot.touchSize.dp, + iconSize = dimens.iconSize.dp, + ) + val trailingGap = + resolveClearTrailingGap( + showClear = true, + hasTrailing = trailing != null, + spaceXs = dimens.spaceXs, + ) + if (trailingGap > 0f) { + Spacer(modifier = Modifier.width(trailingGap.dp)) + } + } + if (trailing != null) { + trailing() + } + } +} diff --git a/design/src/main/java/universe/agentic/ui/search/SearchFieldClearButton.kt b/design/src/main/java/universe/agentic/ui/search/SearchFieldClearButton.kt new file mode 100644 index 0000000000..f58ed88871 --- /dev/null +++ b/design/src/main/java/universe/agentic/ui/search/SearchFieldClearButton.kt @@ -0,0 +1,91 @@ +@file:Suppress("FunctionName") + +package universe.agentic.ui.search + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * Clear (✕) control with parent-owned horizontal hit slot. + * + * - [layoutWidth] — measured width reported to the parent Row (= touch). + * - [visualHeight] — measured height reported to the parent (keep ≤ row height). + * - [touchTargetSize] — centered interactive extent; may overflow vertically via + * `requiredSize` without stretching the search row. + */ +@Composable +fun SearchFieldClearButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + layoutWidth: Dp = SearchFieldDimens.Default.touchTargetMin.dp, + visualHeight: Dp = SearchFieldDimens.Default.clearButtonSize.dp, + touchTargetSize: Dp = SearchFieldDimens.Default.touchTargetMin.dp, + iconSize: Dp = SearchFieldDimens.Default.iconSize.dp, + tint: Color = Color(0x993C3C43), + contentDescription: String = "Clear", +) { + val interactionSource = remember { MutableInteractionSource() } + Box( + modifier = + modifier + .width(layoutWidth) + .height(visualHeight), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = + Modifier + .requiredSize(touchTargetSize) + .semantics { this.contentDescription = contentDescription } + .clip(CircleShape) + .clickable( + enabled = enabled, + role = Role.Button, + interactionSource = interactionSource, + indication = null, + onClick = onClick, + ), + contentAlignment = Alignment.Center, + ) { + Canvas(modifier = Modifier.size(iconSize)) { + val inset = size.minDimension * 0.22f + val strokeWidth = size.minDimension * 0.12f + drawLine( + color = tint, + start = Offset(inset, inset), + end = Offset(size.width - inset, size.height - inset), + strokeWidth = strokeWidth, + cap = StrokeCap.Round, + ) + drawLine( + color = tint, + start = Offset(size.width - inset, inset), + end = Offset(inset, size.height - inset), + strokeWidth = strokeWidth, + cap = StrokeCap.Round, + ) + } + } + } +} diff --git a/design/src/main/java/universe/agentic/ui/search/SearchFieldDimens.kt b/design/src/main/java/universe/agentic/ui/search/SearchFieldDimens.kt new file mode 100644 index 0000000000..cd062a5def --- /dev/null +++ b/design/src/main/java/universe/agentic/ui/search/SearchFieldDimens.kt @@ -0,0 +1,21 @@ +package universe.agentic.ui.search + +/** + * Layout tokens for [SearchField] (values in density-independent pixels). + * + * Stored as [Float] so commonMain stays Compose-free; compose converts via `.dp`. + */ +data class SearchFieldDimens( + val barHeightDefault: Float = 36f, + val touchTargetMin: Float = 40f, + val cornerPopup: Float = 8f, + val spaceM: Float = 12f, + val spaceS: Float = 8f, + val spaceXs: Float = 4f, + val iconSize: Float = 16f, + val clearButtonSize: Float = 24f, +) { + companion object { + val Default: SearchFieldDimens = SearchFieldDimens() + } +} diff --git a/design/src/main/java/universe/agentic/ui/search/SearchFieldLayout.kt b/design/src/main/java/universe/agentic/ui/search/SearchFieldLayout.kt new file mode 100644 index 0000000000..2593f1e436 --- /dev/null +++ b/design/src/main/java/universe/agentic/ui/search/SearchFieldLayout.kt @@ -0,0 +1,68 @@ +package universe.agentic.ui.search + +import kotlin.math.max +import kotlin.math.min + +/** Inner Row end padding: [spaceXs] when clear visible, else [spaceM]. */ +fun innerEndPadding( + showClear: Boolean, + spaceXs: Float, + spaceM: Float, +): Float = if (showClear) spaceXs else spaceM + +/** Filled applies outer margin; Overlay and Plain do not. */ +fun hasFilledOuterMargin(variant: SearchFieldVariant): Boolean = + variant == SearchFieldVariant.Filled + +/** Filled and Overlay use surfaceContainerHigh pill. */ +fun usesFilledBackground(variant: SearchFieldVariant): Boolean = + variant == SearchFieldVariant.Filled || variant == SearchFieldVariant.Overlay + +/** + * Parent-owned clear end-zone geometry. + * + * - [layoutWidth] — width reported to the Row (= touch; horizontal ownership). + * - [layoutHeight] — height reported to the Row (≤ row; must not stretch chrome). + * - [touchSize] — clickable edge length (may overflow vertically only). + */ +data class ClearSlot( + val layoutWidth: Float, + val layoutHeight: Float, + val touchSize: Float, +) + +fun resolveClearLayoutSize( + dimens: SearchFieldDimens, + rowHeight: Float? = null, +): Float { + val effectiveRow = rowHeight ?: dimens.barHeightDefault + return min(dimens.clearButtonSize, effectiveRow) +} + +fun resolveClearLayoutHeight( + dimens: SearchFieldDimens, + rowHeight: Float? = null, +): Float = resolveClearLayoutSize(dimens, rowHeight) + +fun resolveClearLayoutWidth(dimens: SearchFieldDimens): Float = resolveClearTouchSize(dimens) + +fun resolveClearTouchSize(dimens: SearchFieldDimens): Float = + max(dimens.touchTargetMin, dimens.clearButtonSize) + +fun resolveClearSlot( + dimens: SearchFieldDimens, + rowHeight: Float? = null, +): ClearSlot { + val touch = resolveClearTouchSize(dimens) + return ClearSlot( + layoutWidth = touch, + layoutHeight = resolveClearLayoutHeight(dimens, rowHeight), + touchSize = touch, + ) +} + +fun resolveClearTrailingGap( + showClear: Boolean, + hasTrailing: Boolean, + spaceXs: Float, +): Float = if (showClear && hasTrailing) spaceXs else 0f diff --git a/design/src/main/java/universe/agentic/ui/search/SearchFieldVariant.kt b/design/src/main/java/universe/agentic/ui/search/SearchFieldVariant.kt new file mode 100644 index 0000000000..5457f6b273 --- /dev/null +++ b/design/src/main/java/universe/agentic/ui/search/SearchFieldVariant.kt @@ -0,0 +1,10 @@ +package universe.agentic.ui.search + +/** + * Visual variant for [SearchField] chrome. + */ +enum class SearchFieldVariant { + Plain, + Filled, + Overlay, +} diff --git a/design/src/main/res/layout/adapter_app.xml b/design/src/main/res/layout/adapter_app.xml deleted file mode 100644 index 1fd3f863ed..0000000000 --- a/design/src/main/res/layout/adapter_app.xml +++ /dev/null @@ -1,64 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/design/src/main/res/layout/adapter_app_set.xml b/design/src/main/res/layout/adapter_app_set.xml deleted file mode 100644 index d5cd9dcaab..0000000000 --- a/design/src/main/res/layout/adapter_app_set.xml +++ /dev/null @@ -1,77 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - diff --git a/design/src/main/res/layout/adapter_app_set_divider.xml b/design/src/main/res/layout/adapter_app_set_divider.xml deleted file mode 100644 index 0cb8336495..0000000000 --- a/design/src/main/res/layout/adapter_app_set_divider.xml +++ /dev/null @@ -1,7 +0,0 @@ - - diff --git a/design/src/main/res/layout/adapter_app_set_header.xml b/design/src/main/res/layout/adapter_app_set_header.xml deleted file mode 100644 index 7a709890ea..0000000000 --- a/design/src/main/res/layout/adapter_app_set_header.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - diff --git a/design/src/main/res/layout/adapter_apps_strategy_config.xml b/design/src/main/res/layout/adapter_apps_strategy_config.xml deleted file mode 100644 index c9b280f25c..0000000000 --- a/design/src/main/res/layout/adapter_apps_strategy_config.xml +++ /dev/null @@ -1,119 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/design/src/main/res/layout/adapter_file.xml b/design/src/main/res/layout/adapter_file.xml deleted file mode 100644 index 5e915430ec..0000000000 --- a/design/src/main/res/layout/adapter_file.xml +++ /dev/null @@ -1,105 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/design/src/main/res/layout/adapter_log_message.xml b/design/src/main/res/layout/adapter_log_message.xml deleted file mode 100644 index 3cc518610d..0000000000 --- a/design/src/main/res/layout/adapter_log_message.xml +++ /dev/null @@ -1,50 +0,0 @@ - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/design/src/main/res/layout/adapter_profile.xml b/design/src/main/res/layout/adapter_profile.xml deleted file mode 100644 index 53f5218b6b..0000000000 --- a/design/src/main/res/layout/adapter_profile.xml +++ /dev/null @@ -1,142 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/design/src/main/res/layout/adapter_profile_provider.xml b/design/src/main/res/layout/adapter_profile_provider.xml deleted file mode 100644 index 33aec55fae..0000000000 --- a/design/src/main/res/layout/adapter_profile_provider.xml +++ /dev/null @@ -1,44 +0,0 @@ - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/design/src/main/res/layout/adapter_provider.xml b/design/src/main/res/layout/adapter_provider.xml deleted file mode 100644 index 4625574f6e..0000000000 --- a/design/src/main/res/layout/adapter_provider.xml +++ /dev/null @@ -1,99 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/design/src/main/res/layout/adapter_sideload_provider.xml b/design/src/main/res/layout/adapter_sideload_provider.xml deleted file mode 100644 index 8ae6ef5147..0000000000 --- a/design/src/main/res/layout/adapter_sideload_provider.xml +++ /dev/null @@ -1,69 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/design/src/main/res/layout/common_activity_bar.xml b/design/src/main/res/layout/common_activity_bar.xml deleted file mode 100644 index 98b9842b7e..0000000000 --- a/design/src/main/res/layout/common_activity_bar.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/design/src/main/res/layout/common_recycler_list.xml b/design/src/main/res/layout/common_recycler_list.xml deleted file mode 100644 index 7f1d3f9e34..0000000000 --- a/design/src/main/res/layout/common_recycler_list.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/design/src/main/res/layout/component_action_label.xml b/design/src/main/res/layout/component_action_label.xml deleted file mode 100644 index 5d5f1a0ea6..0000000000 --- a/design/src/main/res/layout/component_action_label.xml +++ /dev/null @@ -1,39 +0,0 @@ - - - - - - - - - - - - - - \ No newline at end of file diff --git a/design/src/main/res/layout/component_action_text_field.xml b/design/src/main/res/layout/component_action_text_field.xml deleted file mode 100644 index e6fd5ca0b9..0000000000 --- a/design/src/main/res/layout/component_action_text_field.xml +++ /dev/null @@ -1,62 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/design/src/main/res/layout/component_large_action_label.xml b/design/src/main/res/layout/component_large_action_label.xml deleted file mode 100644 index 182387c220..0000000000 --- a/design/src/main/res/layout/component_large_action_label.xml +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - - - - - - - - - \ No newline at end of file diff --git a/design/src/main/res/layout/design_access_control.xml b/design/src/main/res/layout/design_access_control.xml deleted file mode 100644 index 6593afb8e6..0000000000 --- a/design/src/main/res/layout/design_access_control.xml +++ /dev/null @@ -1,70 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/design/src/main/res/layout/design_app_crashed.xml b/design/src/main/res/layout/design_app_crashed.xml deleted file mode 100644 index b9b98dc5f8..0000000000 --- a/design/src/main/res/layout/design_app_crashed.xml +++ /dev/null @@ -1,43 +0,0 @@ - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/design/src/main/res/layout/design_apps_strategy.xml b/design/src/main/res/layout/design_apps_strategy.xml deleted file mode 100644 index f9ca50a024..0000000000 --- a/design/src/main/res/layout/design_apps_strategy.xml +++ /dev/null @@ -1,60 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - diff --git a/design/src/main/res/layout/design_files.xml b/design/src/main/res/layout/design_files.xml deleted file mode 100644 index 34aa66213f..0000000000 --- a/design/src/main/res/layout/design_files.xml +++ /dev/null @@ -1,68 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/design/src/main/res/layout/design_logcat.xml b/design/src/main/res/layout/design_logcat.xml deleted file mode 100644 index 18ad94b1b9..0000000000 --- a/design/src/main/res/layout/design_logcat.xml +++ /dev/null @@ -1,102 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/design/src/main/res/layout/design_logs.xml b/design/src/main/res/layout/design_logs.xml deleted file mode 100644 index 32dfca998d..0000000000 --- a/design/src/main/res/layout/design_logs.xml +++ /dev/null @@ -1,89 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/design/src/main/res/layout/design_main.xml b/design/src/main/res/layout/design_main.xml deleted file mode 100644 index bd99ae8a83..0000000000 --- a/design/src/main/res/layout/design_main.xml +++ /dev/null @@ -1,167 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/design/src/main/res/layout/design_new_profile.xml b/design/src/main/res/layout/design_new_profile.xml deleted file mode 100644 index 7b1d772af2..0000000000 --- a/design/src/main/res/layout/design_new_profile.xml +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/design/src/main/res/layout/design_profiles.xml b/design/src/main/res/layout/design_profiles.xml deleted file mode 100644 index 0cbad4c4dd..0000000000 --- a/design/src/main/res/layout/design_profiles.xml +++ /dev/null @@ -1,84 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/design/src/main/res/layout/design_properties.xml b/design/src/main/res/layout/design_properties.xml deleted file mode 100644 index bb2b2eb003..0000000000 --- a/design/src/main/res/layout/design_properties.xml +++ /dev/null @@ -1,171 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/design/src/main/res/layout/design_providers.xml b/design/src/main/res/layout/design_providers.xml deleted file mode 100644 index c33e7e9473..0000000000 --- a/design/src/main/res/layout/design_providers.xml +++ /dev/null @@ -1,59 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/design/src/main/res/layout/design_proxy.xml b/design/src/main/res/layout/design_proxy.xml deleted file mode 100644 index 306444f971..0000000000 --- a/design/src/main/res/layout/design_proxy.xml +++ /dev/null @@ -1,134 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/design/src/main/res/layout/design_settings.xml b/design/src/main/res/layout/design_settings.xml deleted file mode 100644 index e2267e7aca..0000000000 --- a/design/src/main/res/layout/design_settings.xml +++ /dev/null @@ -1,80 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/design/src/main/res/layout/design_settings_common.xml b/design/src/main/res/layout/design_settings_common.xml deleted file mode 100644 index 9eff4cca77..0000000000 --- a/design/src/main/res/layout/design_settings_common.xml +++ /dev/null @@ -1,39 +0,0 @@ - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/design/src/main/res/layout/design_settings_meta_feature.xml b/design/src/main/res/layout/design_settings_meta_feature.xml deleted file mode 100644 index 50c869f7a2..0000000000 --- a/design/src/main/res/layout/design_settings_meta_feature.xml +++ /dev/null @@ -1,66 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/design/src/main/res/layout/design_settings_overide.xml b/design/src/main/res/layout/design_settings_overide.xml deleted file mode 100644 index eee1efd036..0000000000 --- a/design/src/main/res/layout/design_settings_overide.xml +++ /dev/null @@ -1,66 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/design/src/main/res/layout/dialog_files_menu.xml b/design/src/main/res/layout/dialog_files_menu.xml deleted file mode 100644 index f55fe726f9..0000000000 --- a/design/src/main/res/layout/dialog_files_menu.xml +++ /dev/null @@ -1,65 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/design/src/main/res/layout/dialog_profiles_menu.xml b/design/src/main/res/layout/dialog_profiles_menu.xml deleted file mode 100644 index 7ebc6258cd..0000000000 --- a/design/src/main/res/layout/dialog_profiles_menu.xml +++ /dev/null @@ -1,68 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/design/src/main/res/layout/dialog_search.xml b/design/src/main/res/layout/dialog_search.xml deleted file mode 100644 index 8fe68b8685..0000000000 --- a/design/src/main/res/layout/dialog_search.xml +++ /dev/null @@ -1,52 +0,0 @@ - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/design/src/main/res/menu/menu_access_control.xml b/design/src/main/res/menu/menu_access_control.xml deleted file mode 100644 index 1edbb66b25..0000000000 --- a/design/src/main/res/menu/menu_access_control.xml +++ /dev/null @@ -1,56 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/design/src/main/res/menu/menu_proxy.xml b/design/src/main/res/menu/menu_proxy.xml deleted file mode 100644 index 6c5db536e2..0000000000 --- a/design/src/main/res/menu/menu_proxy.xml +++ /dev/null @@ -1,60 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/design/src/main/res/values-ja-rJP/strings.xml b/design/src/main/res/values-ja-rJP/strings.xml index 99300a87d1..71b492b700 100644 --- a/design/src/main/res/values-ja-rJP/strings.xml +++ b/design/src/main/res/values-ja-rJP/strings.xml @@ -180,7 +180,7 @@ クリア クリップボードからインポート クリップボードにエクスポート - Clash Meta for Android + ClashMetaX ドキュメント フィードバック ソース @@ -190,7 +190,7 @@ Google Play Clash Meta Core Clashプロファイルのみご利用可能です(プロキシルールを含む)]]> - 無料アプリです。当開発者は一切のプロキシサービスを提供していません。 サービスに関する問い合わせはご遠慮ください。]]> + 無料アプリです。当開発者は一切のプロキシサービスを提供していません。 サービスに関する問い合わせはご遠慮ください。]]> アプリ サービス ダークモード @@ -308,4 +308,7 @@ リモートを削除 %1$d/%2$d リモートを削除 %1$d/%2$d · %3$s アプリ一覧は保存しましたが、Android が VPN の作り直しを拒否しました。動作中のトンネルは以前の一覧のままです。 + 戻る + 該当なし + 何もありません diff --git a/design/src/main/res/values-ko-rKR/strings.xml b/design/src/main/res/values-ko-rKR/strings.xml index b73843bb26..a84898ea57 100644 --- a/design/src/main/res/values-ko-rKR/strings.xml +++ b/design/src/main/res/values-ko-rKR/strings.xml @@ -180,7 +180,7 @@ 모두 해제 클립보드에서 가져오기 클립보드로 내보내기 - Clash Meta for Android + ClashMetaX 문서 피드백 소스 @@ -190,7 +190,7 @@ Google Play Clash Meta 코어 Clash 구성 파일(프록시/규칙 포함)만 추가할 수 있습니다.]]> - 프리웨어이며, 우회 서비스 제공자가 아닙니다. 따라서, 앱 자체 이외의 문제에 대한 피드백은 받지 않습니다.]]> + 프리웨어이며, 우회 서비스 제공자가 아닙니다. 따라서, 앱 자체 이외의 문제에 대한 피드백은 받지 않습니다.]]> 앱 서비스 다크 모드 @@ -308,4 +308,7 @@ 원격 파일 삭제 %1$d/%2$d 원격 파일 삭제 %1$d/%2$d · %3$s 앱 목록은 저장되었지만 Android가 VPN을 다시 만들지 않았습니다. 실행 중인 터널은 이전 목록을 그대로 사용합니다. + 뒤로 + 검색 결과 없음 + 비어 있음 diff --git a/design/src/main/res/values-ru/strings.xml b/design/src/main/res/values-ru/strings.xml index 867649f3d3..b85f52749b 100644 --- a/design/src/main/res/values-ru/strings.xml +++ b/design/src/main/res/values-ru/strings.xml @@ -213,8 +213,7 @@ Импорт из буфера обмена Экспорт в буфер обмена - Clash Meta для Android - + ClashMetaX Документ Обратная связь Исходный код @@ -228,8 +227,7 @@ https://github.com/MetaCubeX/Clash.Meta конфиг Clash (включая Прокси/Правила)]]> - бесплатен и мы НЕ предоставляем по нему поддержку]]> - + бесплатен и мы НЕ предоставляем по нему поддержку]]> https://play.google.com/store/apps/details?id=com.github.kr328.clash Приложение @@ -372,4 +370,7 @@ Удаление на сервере %1$d/%2$d Удаление на сервере %1$d/%2$d · %3$s Список приложений сохранён, но Android не дал пересоздать VPN. Работающий туннель всё ещё использует прежний список. + Назад + Ничего не найдено + Здесь пусто diff --git a/design/src/main/res/values-vi/strings.xml b/design/src/main/res/values-vi/strings.xml index efeb073a7f..ed31cfb1eb 100644 --- a/design/src/main/res/values-vi/strings.xml +++ b/design/src/main/res/values-vi/strings.xml @@ -12,13 +12,13 @@ Mips Chế độ xếp chồng dữ liệu Hệ thống - Clash Meta - Clash Meta + ClashMetaX Alpha + ClashMetaX Clash Meta Core - Clash Meta + ClashMetaX Clash Meta Wiki - Clash Meta - Clash Meta + ClashMetaX Alpha + ClashMetaX Mới Thông tin Chỉ chấp nhận http(s) @@ -233,7 +233,7 @@ Proxy hệ thống Đính kèm proxy,http vào hệ thống Vpn Chạm để kết nối - Clash Meta là một phần mềm miễn phí và chúng tôi KHÔNG cung cấp bất kỳ dịch vụ trả phí nào cho nó + ClashMetaX là một phần mềm miễn phí và chúng tôi KHÔNG cung cấp bất kỳ dịch vụ trả phí nào cho nó Chỉ chấp nhận cấu hình Clash bao gồm Proxy và Quy tắc Cổng TProxy Không thể khởi động thành phần VPN @@ -295,4 +295,7 @@ Xóa tệp từ xa %1$d/%2$d Xóa tệp từ xa %1$d/%2$d · %3$s Danh sách ứng dụng đã được lưu, nhưng Android không cho tạo lại VPN. Đường hầm đang chạy vẫn dùng danh sách cũ. + Quay lại + Không có kết quả + Không có mục nào diff --git a/design/src/main/res/values-zh-rHK/strings.xml b/design/src/main/res/values-zh-rHK/strings.xml index c22d1336df..d0a1b8422a 100644 --- a/design/src/main/res/values-zh-rHK/strings.xml +++ b/design/src/main/res/values-zh-rHK/strings.xml @@ -8,6 +8,9 @@ 國內常用軟件 已安裝 %1$d · 已選 %2$d 未安裝 + + 已選 %d 個應用 + 追加系統 DNS 應用損壞 自動更新 @@ -28,7 +31,7 @@ 已禁用 DNS 劫持 處理所有 DNS 數據包 - 複製 + 建立副本 編輯 空名稱 退出而不保存 @@ -67,20 +70,20 @@ 代理 近期 自動路由系統流量 - 通過 VpnService 自動路由所有系統流量 + 通過系統 VPN 路由全部流量 規則模式 運行中 設置 顯示流量 在通知中自動刷新流量 - 允許 Clash 自動重啓 + 允許 ClashMetaX 自動重啓 自動重啓 已停止 幫助 點此啓動 更新 - URL - VpnService 選項 + 訂閱 + VPN 選項 選項在 Clash 運行時不可用 查找 系統應用 @@ -102,7 +105,7 @@ Github Issues Clash 配置文件(包含代理/規則)]]> 載入中 - 免費軟件並且我們不為其提供任何服務, 請務必不要反饋非應用自身引起的問題]]> + 免費軟件。我們不為其提供任何服務,請務必不要反饋非應用自身引起的問題]]> 捐贈 允許所有應用 僅允許已選擇的應用 @@ -141,12 +144,12 @@ %d 個條目 策略 增強模式 - Name Server - Fallback Name Server - Default Name Server + 域名伺服器 + 備用域名伺服器 + 默認域名伺服器 FakeIP 過濾器 - GeoIP Fallback - IPCIDR Fallback + GeoIP 備用 + IPCIDR 備用 使用內置 Real-IP 至 域名映射 Fake-IP 至 域名映射 @@ -176,14 +179,14 @@ %d 月前 %d 年前 系統代理 - 為 VpnService 附加 HTTP 代理 + 額外提供本機 HTTP 代理 不修改 Redirect 端口 TProxy 端口 重置 使用 Hosts 認證 - 域名 Fallback + 域名備用 置空 從剪切板導入 導出至剪切板 @@ -227,21 +230,21 @@ 導入 源代碼 Clash Meta 核心 - Name Server 策略 + 域名伺服器策略 阻止本地迴環 阻止本地迴環連接 - Clash Meta for Android + ClashMetaX Clash Meta Wiki - Meta Features - Unified Delay - Geodata Mode - TCP Concurrent - Find Process Mode - Sniffer Setting - Sniffer + Meta 特性 + 統一延遲 + Geodata 模式 + TCP 並發 + 查找進程模式 + 嗅探設置 + 嗅探器 Sniffer Mode - Force Domain - Skip Domain + 強制解析域名 + 跳過域名 Disable Sniffer Load Sniffer From Config Override Sniffer Config @@ -249,25 +252,25 @@ GeoIp Url MMDB Url Geosite Url - Prefer h3 + H3 優先 Port Whitelist - Geo Files - Import GeoIP Database - Press to import... - Import GeoSite Database - Import Country Database - Import failed - Unknown Database format - Only %1$s are supported - %1$s imported + Geo 文件 + 導入 GeoIP 數據庫 + 點擊導入... + 導入 GeoSite 數據庫 + 導入 Country 數據庫 + 導入失敗 + 數據庫類型錯誤 + 只支援 %1$s 格式的 Geo 數據庫 + %1$s 已導入 Update profile %s completed Update profile %1$s failed: %2$s External Control Clash.Meta service started Clash.Meta service stopped - Force DNS Mapping - Parse Pure IP - Override Destination + 強制 DNS 映射 + 解析純 IP 連接 + 覆蓋目標地址 相機權限受限,請前往設定開啟。 發生系統未知異常,操作失敗。 新配置 @@ -288,7 +291,7 @@ 遠端目錄 /sync/Configs/clash-meta 自動同步 - 啟動後,以及訂閱或分組套件名稱變化後自動同步 + 啟動後自動同步。訂閱或分流策略變化後也會同步。 測試連線 立即同步 僅下載 @@ -305,4 +308,67 @@ 清理遠端 %1$d/%2$d 清理遠端 %1$d/%2$d · %3$s 應用名單已儲存,但系統拒絕重建 VPN。正在運行的隧道仍使用原來的應用名單。 + + 連接 + 斷開 + 連接後可用 + 正在使用 + 工具 + 其他 + 偏好 + 進階 + 規則 + 全域 + 直連 + 腳本 + 按規則決定流量走向 + 全部流量走代理 + 全部流量直連 + 按腳本決定流量走向 + 目前節點 + 上傳 + 下載 + 流量 + %1$s/s + 返回 + 沒有符合的結果 + 這裡是空的 + + 外部控制 + 外部控制 TLS + 外部控制來源 + 外部控制允許私網 + 密鑰 + FakeIP 過濾器模式 + GeoIP 備用區域代碼 + 黑名單 + 白名單 + HTTP 嗅探端口 + TLS 嗅探端口 + QUIC 嗅探端口 + HTTP 嗅探覆蓋目標 + TLS 嗅探覆蓋目標 + QUIC 嗅探覆蓋目標 + 跳過源 IP + 跳過目標 IP + 導入 ASN 數據庫 + 允許 IPv6 + 通過系統 VPN 代理 IPv6 流量 + 最近任務裏不顯示本應用 + 應用分流 + 僅本次工作階段有效 + 選擇應用 + 已過期 + 目前由「%1$s」接管。這裏改的是未選策略時的全域名單。 + 嗅探策略 + 選擇配置 + 即時日誌 + 點此開始擷取 + 全域應用名單 + 清除 + 介面、行為和通知 + 路由、協議堆疊和存取控制 + 覆蓋配置裏的欄位 + 嗅探、地理數據和內核選項 + 同步訂閱和分流策略 diff --git a/design/src/main/res/values-zh-rTW/strings.xml b/design/src/main/res/values-zh-rTW/strings.xml index aa32aac2c2..5d5e1e7662 100644 --- a/design/src/main/res/values-zh-rTW/strings.xml +++ b/design/src/main/res/values-zh-rTW/strings.xml @@ -8,6 +8,9 @@ 國內常用軟體 已安裝 %1$d · 已選 %2$d 未安裝 + + 已選 %d 個應用 + 附加作業系統 DNS 應用程式損毀 自動更新 @@ -28,7 +31,7 @@ 已停用 DNS 攔截攻擊 處理所有 DNS 封包 - 複製 + 建立副本 編輯 空白名稱 退出而不儲存 @@ -67,20 +70,20 @@ Proxy 最近 自動轉送作業系統流量 - 通過 VpnService 自動轉送所有作業系統流量 + 通過系統 VPN 路由全部流量 規則模式 執行中 設定 顯示流量 在通知中自動重新整理流量 - 允許 Clash 自動重新啟動 + 允許 ClashMetaX 自動重新啟動 自動重新啟動 已停止 協助 輕觸以啟動 更新 - URL - VpnService 選項 + 訂閱 + VPN 選項 選項在 Clash 執行時不可用 搜尋 系統應用 @@ -102,7 +105,7 @@ Github Issues Clash 設定檔 (包含Proxy /規則)]]> 載入中 - 免費應用程式並且我們不為其提供任何服務, 請務必不要回報非應用程式自身引起的問題]]> + 免費應用程式。我們不為其提供任何服務,請務必不要回報非應用程式自身引起的問題]]> 捐助 允許所有應用 僅允許已選擇的應用 @@ -176,14 +179,14 @@ %d 月前 %d 年前 作業系統 Proxy - 為 VpnService 附加 HTTP Proxy + 額外提供本機 HTTP 代理 不修改 Redirect 埠 TProxy 埠 重設 使用 Hosts 認證 - 域名 Fallback + 域名後饋 置空 從剪貼簿匯入 匯出至剪貼簿 @@ -227,21 +230,21 @@ 匯入 原始碼 Clash Meta 核心 - Name Server 政策 + 名稱伺服器政策 攔截本地回送 攔截本地回送連結 - Clash Meta for Android + ClashMetaX Clash Meta Wiki - Meta Features - Unified Delay - Geodata Mode - TCP Concurrent - Find Process Mode - Sniffer Setting - Sniffer + Meta 特性 + 統一延遲 + Geodata 模式 + TCP 並發 + 尋找進程模式 + 嗅探設定 + 嗅探器 Sniffer Mode - Force Domain - Skip Domain + 強制解析域名 + 跳過域名 Disable Sniffer Load Sniffer From Config Override Sniffer Config @@ -249,25 +252,25 @@ GeoIp Url MMDB Url Geosite Url - Prefer h3 + H3 優先 Port Whitelist - Geo Files - Import GeoIP Database - Press to import... - Import GeoSite Database - Import Country Database - Import failed - Unknown Database format - Only %1$s are supported - %1$s imported + Geo 檔案 + 匯入 GeoIP 資料庫 + 輕觸匯入... + 匯入 GeoSite 資料庫 + 匯入 Country 資料庫 + 匯入失敗 + 資料庫類型錯誤 + 只支援 %1$s 格式的 Geo 資料庫 + %1$s 已匯入 Update profile %s completed Update profile %1$s failed: %2$s External Control Clash.Meta service started Clash.Meta service stopped - Force DNS Mapping - Parse Pure IP - Override Destination + 強制 DNS 映射 + 解析純 IP 連線 + 覆蓋目標地址 相機權限受限,請前往設定開啟。 發生系統未知異常,操作失敗。 新配置 @@ -288,7 +291,7 @@ 遠端目錄 /sync/Configs/clash-meta 自動同步 - 啟動後,以及訂閱或分組套件名稱變化後自動同步 + 啟動後自動同步。訂閱或分流策略變化後也會同步。 測試連線 立即同步 僅下載 @@ -305,4 +308,67 @@ 清理遠端 %1$d/%2$d 清理遠端 %1$d/%2$d · %3$s 應用名單已儲存,但系統拒絕重建 VPN。正在運行的隧道仍使用原來的應用名單。 + + 連接 + 斷開 + 連接後可用 + 正在使用 + 工具 + 其他 + 偏好 + 進階 + 規則 + 全域 + 直連 + 腳本 + 按規則決定流量走向 + 全部流量走代理 + 全部流量直連 + 按腳本決定流量走向 + 目前節點 + 上傳 + 下載 + 流量 + %1$s/s + 返回 + 沒有符合的結果 + 這裡是空的 + + 外部控制 + 外部控制 TLS + 外部控制來源 + 外部控制允許私網 + 金鑰 + FakeIP 過濾器模式 + GeoIP 後饋區域代碼 + 黑名單 + 白名單 + HTTP 嗅探埠 + TLS 嗅探埠 + QUIC 嗅探埠 + HTTP 嗅探覆蓋目標 + TLS 嗅探覆蓋目標 + QUIC 嗅探覆蓋目標 + 跳過源 IP + 跳過目標 IP + 匯入 ASN 資料庫 + 允許 IPv6 + 通過系統 VPN 代理 IPv6 流量 + 最近任務裡不顯示本應用 + 應用分流 + 僅本次工作階段有效 + 選擇應用 + 已過期 + 目前由「%1$s」接管。這裡改的是未選策略時的全域名單。 + 嗅探策略 + 選擇設定檔 + 即時日誌 + 點此開始擷取 + 全域應用名單 + 清除 + 介面、行為和通知 + 路由、協議堆疊和存取控制 + 覆蓋設定裡的欄位 + 嗅探、地理資料和核心選項 + 同步訂閱和分流策略 diff --git a/design/src/main/res/values-zh/strings.xml b/design/src/main/res/values-zh/strings.xml index 4ef4280e41..9b0202e658 100644 --- a/design/src/main/res/values-zh/strings.xml +++ b/design/src/main/res/values-zh/strings.xml @@ -8,6 +8,9 @@ 国内常用软件 已安装 %1$d · 已选 %2$d 未安装 + + 已选 %d 个应用 + 追加系统 DNS 应用损坏 自动更新 @@ -28,7 +31,7 @@ 已禁用 DNS 劫持 处理所有 DNS 数据包 - 复制 + 创建副本 编辑 空名称 退出而不保存 @@ -44,7 +47,7 @@ 隐藏应用图标 可以在拨号盘输入 *#*#252746382#*#* 打开应用 从最近任务隐藏 - 在最近任务中隐藏应用 + 最近任务里不显示本应用 历史 从文件导入 从 URL 导入 @@ -71,20 +74,20 @@ 代理 近期 自动路由系统流量 - 通过 VpnService 自动路由所有系统流量 + 通过系统 VPN 路由全部流量 规则模式 运行中 设置 显示流量 在通知中自动刷新流量 - 允许 Clash 自动重启 + 允许 ClashMetaX 自动重启 自动重启 已停止 帮助 点此启动 更新 - URL - VpnService 选项 + 订阅 + VPN 选项 选项在 Clash 运行时不可用 查找 Age 私钥 @@ -102,12 +105,12 @@ 更新时间 应用包名称 安装时间 - Clash Meta for Android + ClashMetaX 反馈 Github Issues Clash 配置文件(包含代理/规则)]]> 载入中 - 免费软件并且我们不为其提供任何服务, 请务必不要反馈非应用自身引起的问题]]> + 免费软件。我们不为其提供任何服务,请务必不要反馈非应用自身引起的问题]]> 捐赠 允许所有应用 仅允许已选择的应用 @@ -146,13 +149,13 @@ %d 个条目 策略 增强模式 - Name Server - Fallback Name Server - Default Name Server + 域名服务器 + 备用域名服务器 + 默认域名服务器 FakeIP 过滤器 FakeIP 过滤器模式 - GeoIP Fallback - IPCIDR Fallback + GeoIP 备用 + IPCIDR 备用 使用内置 Real-IP 至 域名映射 Fake-IP 至 域名映射 @@ -184,14 +187,14 @@ %d 月前 %d 年前 系统代理 - 为 VpnService 附加 HTTP 代理 + 额外提供本机 HTTP 代理 不修改 Redirect 端口 TProxy 端口 重置 使用 Hosts 认证 - 域名 Fallback + 域名备用 置空 从剪切板导入 导出至剪切板 @@ -236,14 +239,19 @@ 导入 源代码 Clash Meta 核心 - Name Server 策略 + 域名服务器策略 阻止本地回环 阻止本地回环连接 - GeoIP Fallback 区域代码 + GeoIP 备用区域代码 允许应用绕过 允许其他应用绕过 VPN - 允许 Ipv6 - 通过 VpnService 代理 Ipv6 流量 + 允许 IPv6 + 通过系统 VPN 代理 IPv6 流量 + 协议栈 + 系统协议栈 + gVisor 协议栈 + 混合协议栈 + Mips 协议栈 Clash Meta Wiki Meta 特性 统一延迟 @@ -252,7 +260,6 @@ 查找进程模式 嗅探设置 嗅探器 - 嗅探 强制 DNS 映射 解析纯 IP 连接 覆盖目标地址 @@ -307,7 +314,7 @@ 远端目录 /sync/Configs/clash-meta 自动同步 - 启动后,以及订阅或分组包名变化后自动同步 + 启动后自动同步。订阅或分流策略变化后也会同步。 测试连接 立即同步 仅下载 @@ -324,4 +331,57 @@ 清理远端 %1$d/%2$d 清理远端 %1$d/%2$d · %3$s 应用名单已保存,但系统拒绝重建 VPN。正在运行的隧道仍使用原来的应用名单。 + + 连接 + 断开 + 连接后可用 + 正在使用 + 工具 + 其他 + 偏好 + 高级 + 规则 + 全局 + 直连 + 脚本 + 按规则决定流量走向 + 全部流量走代理 + 全部流量直连 + 按脚本决定流量走向 + 当前节点 + 上传 + 下载 + 流量 + %1$s/s + 返回 + 没有匹配结果 + 这里是空的 + + 外部控制 + 外部控制 TLS + 外部控制来源 + 外部控制允许私网 + 密钥 + HTTP 嗅探端口 + TLS 嗅探端口 + QUIC 嗅探端口 + HTTP 嗅探覆盖目标 + TLS 嗅探覆盖目标 + QUIC 嗅探覆盖目标 + 应用分流 + 仅本次会话有效 + 选择应用 + 已过期 + 当前由「%1$s」接管。这里改的是未选策略时的全局名单。 + 嗅探策略 + 选择配置 + 实时日志 + 点此开始捕捉 + 全局应用名单 + 清除 + 界面、行为和通知 + 路由、协议栈和访问控制 + 覆盖配置里的字段 + 嗅探、地理数据和内核选项 + 同步订阅和分流策略 diff --git a/design/src/main/res/values/attrs.xml b/design/src/main/res/values/attrs.xml index d0137325e4..b76005562f 100644 --- a/design/src/main/res/values/attrs.xml +++ b/design/src/main/res/values/attrs.xml @@ -6,41 +6,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/design/src/main/res/values/strings.xml b/design/src/main/res/values/strings.xml index ec8f39201b..ebc2ba6e13 100644 --- a/design/src/main/res/values/strings.xml +++ b/design/src/main/res/values/strings.xml @@ -4,11 +4,10 @@ %d apps selected - Clash Meta - Clash Meta Alpha - Clash Meta for Android - Clash Meta for Android Alpha - + ClashMetaX + ClashMetaX Alpha + ClashMetaX + ClashMetaX Alpha Stopped Tap to start Running @@ -64,6 +63,8 @@ Github Releases Profile + Select Profile + Expired Name Profile Name Profile URL @@ -122,6 +123,8 @@ Reset Clash Logcat + Live Log + Tap to start capturing History Logcat @@ -156,6 +159,10 @@ Access Control Mode Access Control Packages Configure access permission for apps + App Routing + Choose Apps + Global App List + \"%1$s\" is active. This edits the global list used when no strategy is selected. App sets Common Chinese apps Installed %1$d · Selected %2$d @@ -175,6 +182,7 @@ Allow LAN Bind Address Mode + Applies to this session only Log Level IPv6 External Controller @@ -187,6 +195,7 @@ Value Strategy + Sniff Strategy Listen Append System DNS Use Hosts @@ -238,8 +247,7 @@ Import from Clipboard Export to Clipboard - Clash Meta for Android - + ClashMetaX Document Feedback Sources @@ -253,8 +261,7 @@ https://github.com/MetaCubeX/Clash.Meta Clash Config(including Proxy/Rules)]]> - freeware and we do NOT provide any service for it]]> - + freeware and we do NOT provide any service for it]]> https://play.google.com/store/apps/details?id=com.github.kr328.clash App @@ -420,4 +427,35 @@ Removing remote files %1$d/%2$d Removing remote files %1$d/%2$d · %3$s The app list was saved, but Android kept the current VPN. The running tunnel still uses the previous app list. + + Connect + Disconnect + Available after connecting + In Use + Tools + More + Preferences + Advanced + Rule + Global + Direct + Script + Traffic follows the rules + All traffic goes through the proxy + All traffic connects directly + Traffic follows the script + Current node + Upload + Download + Traffic + %1$s/s + Back + No matches + Nothing here + Clear + Appearance, behavior, and notifications + Routing, stack, and access control + Fields written over the profile + Sniffing, Geo data, and kernel options + Sync subscriptions and app routing diff --git a/design/src/main/res/values/themes.xml b/design/src/main/res/values/themes.xml index 82d685f22c..c31ba8ce55 100644 --- a/design/src/main/res/values/themes.xml +++ b/design/src/main/res/values/themes.xml @@ -112,6 +112,26 @@ @android:color/transparent + + + + + + + + + +{HTML_BODY} + + + +""" + +output_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "index.html") +with open(output_path, "w", encoding="utf-8") as f: + f.write(html) + +print(f"Assembly complete! Written {len(html)} characters to {output_path}") diff --git a/ui-web-demo/generate.py b/ui-web-demo/generate.py new file mode 100644 index 0000000000..081aac7b1b --- /dev/null +++ b/ui-web-demo/generate.py @@ -0,0 +1,2297 @@ +import os + +html = """ + + + + + ClashMetaX UI Redesign - Interactive Prototype + + + + + +
+
+

+ ClashMetaX + UI 2.0 REDESIGN +

+ 交互式高保真 Web 原型 +
+ +
+ + + +
+
+ + +
+
+ + +
+ + +
+ 09:41 +
+
+ 🛡️ VPN +
+
+ 5G + + + + + + + + +
+
+ + +
+ + +
+ + + +
+
+
+
+ 运行中 (VPN) +
+ 01:24:32 +
+ +
+ +
安全网络已保护
+
配置:香港专线集群 · 规则模式
+
+ + +
+
规则分流
+
全局代理
+
直接连接
+
+
+ + +
+
+ + + + + + + 出口节点 · 点击快速切换 + + 38 ms +
+
+ 🇭🇰 + 香港 BGP 01 [IEPL专线] +
+
+ 出站 IP: 104.28.198.42 + 数据中心: Cloudflare HK +
+
+ + +
+
+
+ + + 实时下行 + + 2.4 MB/s +
+
+ + + 实时上行 + + 340.2 KB/s +
+
+ +
+ +
+ +
+ 累计下载: 1.42 GB + 累计上传: 280.4 MB +
+
+ + +
+
+
+ 订阅配置 + 18天到期 +
+
+
专线节点集群
+
剩余 358 GB / 500 GB
+
+
+
+
+
+ +
+
+ 应用分流 + 已生效 +
+
+
14 款应用代理
+
绕过国内应用与支付
+
+ 管理应用名单 → +
+
+
+ + +
+ + + +
+
🚀 节点选择 (12)
+
⚡ 自动优选 (8)
+
🎬 流媒体 (6)
+
🤖 AI 智能 (5)
+
🎯 全球直连
+
+ + +
+ +
+ + +
+
+ + +
+ + + +
+
+
+
+ 香港 IEPL 专线 · 年付 + 生效中 +
+
+ https://sub.airport.io/api/v1/client/subscribe... +
+
+ +
+
+
+
+
+ 已用 142 GB / 500 GB (28.4%) + 18天后到期 (2026-11-08) +
+
+ 包含 48 个节点 + 更新于 10分钟前 +
+
+ + +
+
+
+
+ 备用自建 VLESS 节点 +
+
+ 本地文件 / 存储卡导入 +
+
+ +
+
+ 包含 6 个节点 + 更新于 3天前 +
+
+ + +
+
+
+
WebDAV 云端同步
+
上次同步:今天 08:30 · 已启用自动同步
+
+ +
+
+
+ + +
+ + + +
+
分流接管模式
+
+
绕过大陆
+
仅代理选定
+
绕过选定
+
+
+ 国内应用直连,海外应用通过规则策略智能分流。 +
+
+ + +
+ 已安装应用 (5 已选) + 全选 / 反选 +
+ + +
+
+ + +
+ + +
+
核心网络 (TUN & DNS)
+
+
+
TUN 虚拟网卡模式
+
接管整机流量,支持 UDP 代理与游戏低延迟
+
+ +
+
+
+
自动绕过本地局域网
+
192.168.x.x 等私有 IP 不经过代理内核
+
+ +
+
+
+
DNS 智能防污染与嗅探
+
嗅探 TLS SNI 与 HTTP Host,修正分流规则
+
+ +
+
+ +
+
系统集成与体验
+
+
+
开机自动启动代理
+
设备重启后自动在后台拉起 VPN 隧道
+
+ +
+
+
+
通知栏快速磁贴 (Tile)
+
从下拉快捷控制面板一键启停和切模式
+
+ +
+
+ +
+
进阶诊断与工具
+
+
+
实时终端日志 (Logcat)
+
查看内核实时调度、连接分流判定与报错
+
+ → +
+
+
+
外部控制 RESTful API
+
监听 127.0.0.1:9090,支持 yacd / metacubexd
+
+ 9090 端口 +
+
+
+
关于 ClashMetaX
+
版本 2.11.35 · Mihomo Core 90a5afcc
+
+ v2.11 +
+
+
+ +
+ + +
+
+
+
+
快速切换节点
+ +
+
+
+ + +
+
+
+
实时内核日志流
+ +
+
+
+ + +
+
+
+
活跃连接监控 (4)
+ +
+
+
+ + + + +
+
+ + + + +""" + +with open("ui-web-demo/index.html", "w", encoding="utf-8") as f: + f.write(html) +print("ui-web-demo/index.html written successfully, bytes:", len(html)) diff --git a/ui-web-demo/index.html b/ui-web-demo/index.html new file mode 100644 index 0000000000..e6a632654c --- /dev/null +++ b/ui-web-demo/index.html @@ -0,0 +1,3081 @@ + + + + + + ClashMetaX UI 2.0 - 交互式网页原型系统 + + + + + +
+
+

+ ClashMetaX + UI 2.0 FULL +

+
+ 保留应用分流策略 + 访问控制应用集 + WebDAV 云同步 + 提供者/文件/age密钥 +
+
+ +
+ + + +
+
+ + +
+
+ + +
+ + +
+ 09:41 +
+
+ 🛡️ VPN +
+
+ 5G + + + + + + + + +
+
+ + +
+ + +
+ + + +
+
+
+
+ 运行中 (VPN) +
+ 01:24:32 +
+ +
+ +
安全网络已守护
+
生效配置:香港 IEPL 专线 · 规则模式
+
+ + +
+
规则分流
+
全局代理
+
直接连接
+
+
+ + +
+
+ + + + + + 出口感知 · 点击快捷切节点 + + 38 ms +
+
+ 🇭🇰 + 香港 BGP 01 [IEPL专线] +
+
+ 出站 IP: 104.28.198.42 + 数据中心: Cloudflare HK +
+
+ + +
+
+
+ + + 实时下行 + + 2.4 MB/s +
+
+ + + 实时上行 + + 340.2 KB/s +
+
+ +
+ +
+ +
+ 累计下载: 1.42 GB + 累计上传: 280.4 MB +
+
+ + +
+ +
+
+ 应用分流策略 + 已生效 +
+
+ 当前:海外应用加速
+ 7 款已选 · 白名单代理 +
+
+ + +
+
+ WebDAV 同步 + 自动 +
+
+ 已同步 2 份配置
+ 今天 08:30 已备份 +
+
+ + +
+
+ 提供者 (Providers) + 3 组正常 +
+
+ 节点集 48 个 · 规则集 3
+ 点击一键健康检查 +
+
+ + +
+
+ 文件与 Geo + 4 个 +
+
+ GeoIP / GeoSite.dat
+ age 密钥已就绪 +
+
+
+
+ + +
+ + + +
+
节点选择
+
提供者集合 (Providers)
+
+ + +
+
+
🚀 节点选择 (12)
+
⚡ 自动优选 (8)
+
🎬 流媒体 (6)
+
🤖 AI 智能 (5)
+
🎯 全球直连
+
+ +
+
+ + +
+
+ +
+
+ + + +
+ + +
+ + +
+
配置订阅 (Profiles)
+
外部资源与文件 (Files)
+
+ + +
+
+
+
+
+ 香港 IEPL 专线 · 年付 + 生效中 +
+
+ https://sub.airport.io/api/v1/subscribe... +
+
+ +
+
+
+
+
+ 已用 142 GB / 500 GB (28.4%) + 18天后到期 (2026-11-08) +
+
+ 包含 48 个节点 · age 加密已保护 + 更新于 10分钟前 +
+
+ +
+
+
+
备用自建 VLESS 节点
+
+ 文件配置 · 离线存储卡导入 +
+
+ +
+
+ 包含 6 个自建节点 · 无用量上限 + 3天前更新 +
+
+
+ + + +
+ + +
+ + +
+
策略配置方案
+
应用集合 (可编辑)
+
名单勾选配置
+
+ + +
+ +
+
+
+
+
全局访问控制名单
+ 默认基础 +
+
模式:仅允许选中的应用 (白名单) · 已选 8 个
+
+
●
+
+
+ 未启用任何策略时由全局名单接管 + +
+
+ + +
+ 多策略方案 (单选生效) + +
+ +
+ +
+ 💡 说明:当单选激活某份分流策略时,内核将优先由该策略接管分流路由;停用所有策略时自动回退为全局名单。离开应用名单列表时立即自动保存并执行热重载。 +
+
+ + + + + + +
+ + +
+ + + +
+
WebDAV 云端同步 (TIIEHenry 特性)
+
+
+
WebDAV 同步控制台
+
https://dav.jianguoyun.com/dav/ · 自动双向同步
+
+ 已就绪 → +
+
+ + +
+
网络与 TUN 核心 (Network)
+
+
+
TUN 虚拟网卡模式
+
接管整机 TCP/UDP 流量,游戏与低延迟保障
+
+ +
+
+
+
网络协议栈 (Network Stack)
+
当前:gVisor (推荐) · 兼容 Android VPN
+
+ gVisor → +
+
+
+
自动绕过本地局域网
+
192.168.x.x / 10.x.x 等私网 IP 不经代理
+
+ +
+
+
+
DNS 智能防污染与嗅探
+
嗅探 TLS SNI 与 HTTP Host,修正分流匹配
+
+ +
+
+ + +
+
覆写与 Meta 特性 (Override & Meta)
+
+
+
端口与嗅探策略覆写
+
Mixed: 7890 · SOCKS: 7891 · 纯嗅探与重定向
+
+ 配置 → +
+
+
+
Geo 数据库与外部控制
+
MetaCubeX GeoIP / GeoSite · REST 端口 9090
+
+ 配置 → +
+
+ + +
+
日志与系统
+
+
+
实时终端日志 (Logcat)
+
实时调度、连接分流判定、报错与调试
+
+ → +
+
+
+
通知栏快捷磁贴 (Quick Tile)
+
从下拉菜单一键启停和切模式
+
+ +
+
+
+
关于 ClashMetaX
+
版本 2.11.35 · Mihomo Core 90a5afcc
+
+ v2.11.35 +
+
+
+ +
+ + +
+
+
+
+
快速切换节点
+ +
+
+
+ + +
+
+
+
实时内核日志流 (Logcat)
+
+ + +
+
+
+
+ + +
+
+
+
活跃连接实时监控 (4)
+ +
+
+
+ + +
+
+
+
WebDAV 同步设置
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
启动与配置变更时自动同步
+
自动双向比对并合并多设备订阅
+
+ +
+
+ + + + +
+
+
+ + +
+
+
+
配置属性
+ +
+
+
+ + +
+
+ + +
+
+ +
+ 已设置 (age1ql3z7h68...) + +
+
+ +
+
+ + +
+
+
+
网络协议栈 (Network Stack)
+ +
+
+
+ 选择 Android TUN 虚拟网卡底层运行的 TCP/IP 协议栈实现: +
+
+
+ gVisor (推荐) + 默认 +
+
Google 用户态网络栈,稳定兼容 Android 10-15,高并发与游戏防丢包首选。
+
+
+
+ System (系统原生栈) +
+
利用 Android Linux 内核网络栈转发,CPU 开销稍低,个别设备可能存在 UDP 阻断。
+
+
+
+ Mixed (混合模式) +
+
TCP 采用系统原生栈,UDP 走 gVisor 用户栈,兼顾吞吐量与稳定性。
+
+
+
+ + +
+
+
+
配置覆写 (Config Overrides)
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
允许来自局域网的连接 (Allow LAN)
+
共享热点或局域网设备代理
+
+ +
+
+
+
全局域名嗅探 (Sniffer)
+
嗅探 TLS SNI 与 HTTP Host,防止 IP 分流误判
+
+ +
+
+
+
DNS Fake-IP 模式 (198.18.0.0/16)
+
秒级并发 DNS 解析与防污染
+
+ +
+ +
+
+ + +
+
+
+
Meta 内核特性 (Mihomo Core)
+ +
+
+
+ +
+ + +
+
+
+
+
进程包名提取 (FindProcess)
+
允许规则直接使用 PROCESS-NAME 匹配连接应用
+
+ +
+
+
+
TCP 并发连接 (TCP Concurrent)
+
并发多 IP 测速首包连接,大幅降低建连延迟
+
+ +
+
+
+
统一 RTT 延迟算法 (Unified Delay)
+
剔除握手偏差,提供高精度真实延迟排序
+
+ +
+ +
+
+ + +
+
+
+
新建 / 导入配置档案
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ + +
+
+
+
新建应用分流策略 (TIIEHenry)
+ +
+
+
+ + +
+
+ + +
+
+
+
立即启用该策略
+
在核心分流规则链中激活
+
+ +
+ +
+
+ + +
+
+
+
关于 ClashMetaX UI 2.0
+ +
+
+
+
⚡
+
ClashMetaX · TIIEHenry Fork
+
版本: v2.10.8-meta · 内核: Mihomo v1.18.9
+
+
+
完整保留的 Fork 核心特性:
+
✅ 应用分流策略 (Apps Strategy):支持多配置方案与白名单/黑名单模式切换
+
✅ 访问控制应用集 (Access Control):系统/国内应用筛选、一键反选与包名信息
+
✅ WebDAV 全量云同步:局域网/公网双地址容灾、双向比对自动同步
+
✅ age 敏感配置加密:支持安全密文存储与密钥热更新
+
✅ 全特性提供者与文件管理:Proxy/Rule Providers 批量健康检查与配置编辑
+
+
+ UI 2.0 原型系统 · Bento Grid 状态看板 · 零延迟无缝切流 +
+
+
+ + + +
+
+
+
编辑分流策略
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+
新建自定义应用集
+ +
+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+ + +
+
+
+
编辑应用集
+ +
+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+ + + + +
+
+ + + + diff --git a/ui-web-demo/parts_css.py b/ui-web-demo/parts_css.py new file mode 100644 index 0000000000..6683e07d19 --- /dev/null +++ b/ui-web-demo/parts_css.py @@ -0,0 +1,319 @@ +""" +parts_css.py - CSS styles for ClashMetaX (CMFA) UI 2.0 Web Demo +Theme: 现代极简原生 (Modern Minimalist Native) +Style Principles: +- True system dark & light grouped palettes (OLED #000000 / Grouped #F2F2F7) +- Clean flat surfaces, 0.5px/1px hairline separation, zero gaudy gradients or neon glow +- Native system ergonomics: Grouped lists, iOS/Material segmented controls, native switches +- Restrained, purposeful color accents (System Blue, System Emerald Green, Orange, Red) +- Clean typographic scale with native system font stacks +""" + +CSS_BLOCK = """ + :root { + /* OLED Dark System Palette */ + --bg: #000000; + --card-bg: #15161a; + --card-subtle: #1f2026; + --card-border: rgba(255, 255, 255, 0.08); + --card-border-active: #0a84ff; + --card-border-green: #30d158; + --text-primary: #ffffff; + --text-secondary: #8e8e93; + --text-tertiary: #636366; + --accent-blue: #0a84ff; + --accent-blue-subtle: rgba(10, 132, 255, 0.12); + --accent-green: #30d158; + --accent-green-subtle: rgba(48, 209, 88, 0.12); + --accent-orange: #ff9f0a; + --accent-orange-subtle: rgba(255, 159, 10, 0.12); + --accent-red: #ff453a; + --accent-red-subtle: rgba(255, 69, 58, 0.12); + --accent-purple: #bf5af2; + --nav-bg: rgba(21, 22, 26, 0.85); + --nav-border: rgba(255, 255, 255, 0.06); + --sheet-bg: #18191e; + --badge-bg: rgba(255, 255, 255, 0.06); + --shadow: none; + --radius-xs: 6px; + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 16px; + --radius-xl: 22px; + --font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "SF Pro Display", "Roboto", "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif; + --font-mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Monaco, Consolas, monospace; + } + + [data-theme="light"] { + /* Native Light Grouped Palette */ + --bg: #f2f2f7; + --card-bg: #ffffff; + --card-subtle: #e9e9eb; + --card-border: rgba(0, 0, 0, 0.07); + --card-border-active: #007aff; + --card-border-green: #34c759; + --text-primary: #000000; + --text-secondary: #6c6c70; + --text-tertiary: #aeaeb2; + --accent-blue: #007aff; + --accent-blue-subtle: rgba(0, 122, 255, 0.1); + --accent-green: #34c759; + --accent-green-subtle: rgba(52, 199, 89, 0.1); + --accent-orange: #ff9500; + --accent-orange-subtle: rgba(255, 149, 0, 0.1); + --accent-red: #ff3b30; + --accent-red-subtle: rgba(255, 59, 48, 0.1); + --accent-purple: #af52de; + --nav-bg: rgba(255, 255, 255, 0.88); + --nav-border: rgba(0, 0, 0, 0.06); + --sheet-bg: #ffffff; + --badge-bg: rgba(0, 0, 0, 0.05); + --shadow: 0 1px 3px rgba(0, 0, 0, 0.04); + } + + * { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; user-select: none; } + body { background: #08080a; color: var(--text-primary); font-family: var(--font-family); min-height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: flex-start; overflow-x: hidden; padding: 12px 10px 36px; -webkit-font-smoothing: antialiased; } + + /* Top Demo Control Header */ + .demo-control-bar { width: 100%; max-width: 900px; display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px; padding: 10px 18px; margin-bottom: 16px; background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.08); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); border-radius: var(--radius-md); color: #fff; } + .demo-title-group h1 { font-size: 15px; font-weight: 600; display: flex; align-items: center; gap: 8px; letter-spacing: -0.2px; } + .demo-tag { font-size: 10px; font-weight: 600; padding: 2px 6px; background: var(--accent-blue); border-radius: 4px; letter-spacing: 0.2px; } + .demo-feature-pill { font-size: 11px; padding: 2px 7px; background: rgba(255, 255, 255, 0.08); border-radius: 6px; color: #aaa; font-weight: 500; } + .demo-actions { display: flex; align-items: center; gap: 8px; } + .demo-btn { background: rgba(255, 255, 255, 0.08); border: 1px solid rgba(255, 255, 255, 0.12); color: #fff; font-size: 12px; font-weight: 500; padding: 5px 11px; border-radius: 7px; cursor: pointer; display: inline-flex; align-items: center; gap: 5px; transition: all 0.15s ease; } + .demo-btn:hover { background: rgba(255, 255, 255, 0.14); } + .demo-btn:active { transform: scale(0.97); } + + /* Phone Shell (Ultra-Slim Minimalist Bezel) */ + .device-wrapper { position: relative; transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1); } + .device-phone { width: 400px; height: 860px; background: var(--bg); border-radius: 44px; box-shadow: 0 20px 50px rgba(0, 0, 0, 0.6), 0 0 0 4px #1c1d22, 0 0 0 5px rgba(255,255,255,0.06); overflow: hidden; display: flex; flex-direction: column; position: relative; } + .device-phone.fullscreen-mode { width: 100vw; max-width: 480px; height: 94vh; border-radius: 20px; box-shadow: 0 10px 30px rgba(0,0,0,0.3); border: 1px solid var(--card-border); } + + /* Native Status Bar */ + .status-bar { height: 42px; padding: 0 22px; display: flex; justify-content: space-between; align-items: center; font-size: 13px; font-weight: 600; color: var(--text-primary); z-index: 50; position: relative; } + .status-notch { position: absolute; top: 8px; left: 50%; transform: translateX(-50%); width: 104px; height: 22px; background: #000; border-radius: 14px; display: flex; align-items: center; justify-content: center; gap: 6px; padding: 0 8px; } + .status-camera { width: 9px; height: 9px; border-radius: 50%; background: #0d0d0f; border: 1px solid #1a1a1f; } + .status-island-icon { font-size: 9px; color: var(--accent-green); opacity: 0; transition: opacity 0.25s; } + .status-island-icon.active { opacity: 1; } + + /* App Screen & Pages */ + .app-screen { flex: 1; overflow-y: auto; overflow-x: hidden; padding: 6px 16px 84px; scroll-behavior: smooth; position: relative; } + .app-screen::-webkit-scrollbar { display: none; } + .page-view { display: none; animation: fadeIn 0.2s ease-out; } + .page-view.active { display: block; } + @keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } } + + /* Native Segmented Switcher (Top of Apps & elsewhere) */ + .sub-page-switcher { background: var(--card-subtle); border: 1px solid var(--card-border); border-radius: 9px; display: flex; padding: 2px; margin-bottom: 14px; } + .sub-switch-btn { flex: 1; text-align: center; padding: 6px 0; font-size: 12px; font-weight: 500; color: var(--text-secondary); border-radius: 7px; cursor: pointer; transition: all 0.18s cubic-bezier(0.16, 1, 0.3, 1); } + .sub-switch-btn.active { background: var(--card-bg); color: var(--text-primary); font-weight: 600; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12); border: 0.5px solid var(--card-border); } + + /* Native Bottom Tab Navigation Bar */ + .bottom-nav { position: absolute; bottom: 0; left: 0; right: 0; height: 74px; background: var(--nav-bg); backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px); border-top: 0.5px solid var(--nav-border); display: flex; justify-content: space-around; align-items: flex-start; padding-top: 7px; z-index: 40; } + .nav-item { display: flex; flex-direction: column; align-items: center; gap: 2px; color: var(--text-secondary); font-size: 10px; font-weight: 500; cursor: pointer; width: 58px; transition: color 0.15s; } + .nav-item svg { width: 21px; height: 21px; stroke-width: 1.8; transition: transform 0.15s; } + .nav-item:active svg { transform: scale(0.9); } + .nav-item.active { color: var(--accent-blue); font-weight: 600; } + .nav-indicator { position: absolute; bottom: 5px; left: 50%; transform: translateX(-50%); width: 120px; height: 4px; background: var(--text-primary); opacity: 0.22; border-radius: 2px; } + + /* Native Flat Cards */ + .card { background: var(--card-bg); border: 1px solid var(--card-border); border-radius: var(--radius-lg); padding: 14px 16px; margin-bottom: 10px; box-shadow: var(--shadow); transition: transform 0.15s, border-color 0.15s; } + .card-interactive:active { transform: scale(0.985); } + + /* Page Headers */ + .page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; padding: 2px 2px; } + .page-title { font-size: 22px; font-weight: 700; letter-spacing: -0.5px; } + .page-header-actions { display: flex; gap: 8px; } + .icon-btn { width: 34px; height: 34px; border-radius: 50%; background: var(--card-subtle); border: 1px solid var(--card-border); color: var(--text-primary); display: flex; align-items: center; justify-content: center; cursor: pointer; transition: all 0.15s; } + .icon-btn:active { transform: scale(0.92); } + + /* Hero Master Connect Card (Clean Minimalist Native) */ + .hero-connect-card { background: var(--card-bg); border: 1px solid var(--card-border); border-radius: var(--radius-xl); padding: 18px 16px; position: relative; overflow: hidden; margin-bottom: 12px; } + .connect-top-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; } + .connect-status-badge { display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; border-radius: 20px; font-size: 11px; font-weight: 600; background: var(--card-subtle); border: 1px solid var(--card-border); color: var(--text-secondary); transition: all 0.2s; } + .connect-status-badge.running { background: var(--accent-green-subtle); border-color: rgba(48, 209, 88, 0.25); color: var(--accent-green); } + .pulse-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--text-tertiary); transition: all 0.2s; } + .connect-status-badge.running .pulse-dot { background: var(--accent-green); } + .uptime-counter { font-size: 12px; font-family: var(--font-mono); color: var(--text-secondary); font-weight: 500; } + + /* Native Tactile Master Power Button */ + .connect-center { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 4px 0 14px; } + .power-button { width: 74px; height: 74px; border-radius: 50%; background: var(--card-subtle); border: 1.5px solid var(--card-border); color: var(--text-secondary); display: flex; align-items: center; justify-content: center; cursor: pointer; position: relative; transition: all 0.25s cubic-bezier(0.16, 1, 0.3, 1); } + .power-button svg { width: 32px; height: 32px; transition: all 0.2s; } + .power-button:active { transform: scale(0.92); } + .power-button.running { background: var(--accent-green); border-color: var(--accent-green); color: #ffffff; box-shadow: 0 4px 16px rgba(48, 209, 88, 0.28); } + .power-button.connecting { border-color: var(--accent-blue); color: var(--accent-blue); animation: spinBorder 1.2s linear infinite; } + @keyframes spinBorder { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } + .power-hint { margin-top: 10px; font-size: 14px; font-weight: 600; color: var(--text-primary); letter-spacing: -0.2px; } + .power-subhint { font-size: 11px; color: var(--text-secondary); margin-top: 2px; } + + /* Native iOS / Material Segmented Mode Picker */ + .mode-segmented-control { background: var(--card-subtle); border: 1px solid var(--card-border); border-radius: 9px; display: flex; padding: 2px; margin-top: 12px; } + .mode-tab { flex: 1; text-align: center; padding: 6px 0; font-size: 12px; font-weight: 500; color: var(--text-secondary); border-radius: 7px; cursor: pointer; transition: all 0.18s cubic-bezier(0.16, 1, 0.3, 1); } + .mode-tab.active { background: var(--card-bg); color: var(--text-primary); font-weight: 600; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12); border: 0.5px solid var(--card-border); } + + /* Outbound Node & Egress Perception Card */ + .outbound-card { background: var(--card-bg); border: 1px solid var(--card-border); border-radius: var(--radius-lg); padding: 13px 15px; margin-bottom: 10px; cursor: pointer; transition: all 0.18s; } + .outbound-card:hover { border-color: var(--card-border-active); } + .outbound-top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; } + .outbound-label { font-size: 11px; font-weight: 600; color: var(--text-secondary); display: flex; align-items: center; gap: 5px; } + .outbound-latency-pill { font-size: 11px; font-weight: 600; font-family: var(--font-mono); padding: 2px 7px; border-radius: 5px; background: var(--accent-green-subtle); color: var(--accent-green); } + .outbound-node-name { font-size: 14px; font-weight: 600; color: var(--text-primary); display: flex; align-items: center; gap: 7px; } + .outbound-meta-row { display: flex; justify-content: space-between; align-items: center; margin-top: 9px; padding-top: 7px; border-top: 0.5px solid var(--card-border); font-size: 11px; color: var(--text-secondary); } + .outbound-ip-tag { font-family: var(--font-mono); font-size: 11px; background: var(--badge-bg); padding: 1px 5px; border-radius: 4px; color: var(--text-primary); } + + /* Traffic Card (Clean Minimal Signal Line) */ + .traffic-card { background: var(--card-bg); border: 1px solid var(--card-border); border-radius: var(--radius-lg); padding: 13px 15px; margin-bottom: 10px; } + .traffic-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; } + .traffic-stat { display: flex; flex-direction: column; gap: 2px; } + .traffic-label { font-size: 11px; color: var(--text-secondary); display: flex; align-items: center; gap: 4px; font-weight: 500; } + .traffic-rate { font-size: 15px; font-weight: 700; font-family: var(--font-mono); } + .traffic-rate.down { color: var(--accent-blue); } + .traffic-rate.up { color: var(--accent-green); } + .canvas-container { width: 100%; height: 56px; margin: 4px 0; position: relative; } + canvas#trafficCanvas { width: 100%; height: 100%; display: block; } + .traffic-totals { display: flex; justify-content: space-between; font-size: 11px; color: var(--text-secondary); padding-top: 6px; border-top: 0.5px solid var(--card-border); } + + /* Bento Feature Row on Home */ + .dashboard-features-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 9px; margin-bottom: 12px; } + .feature-bento-item { background: var(--card-bg); border: 1px solid var(--card-border); border-radius: var(--radius-md); padding: 11px 13px; cursor: pointer; display: flex; flex-direction: column; justify-content: space-between; transition: all 0.15s; } + .feature-bento-item:active { transform: scale(0.98); } + .feature-bento-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; } + .feature-bento-title { font-size: 12px; font-weight: 600; color: var(--text-primary); } + .feature-bento-desc { font-size: 11px; color: var(--text-secondary); line-height: 1.35; } + + /* Proxies Tab */ + .filter-scroll-row { display: flex; gap: 6px; overflow-x: auto; padding: 2px 2px 10px; margin: 0 -2px; scrollbar-width: none; } + .filter-scroll-row::-webkit-scrollbar { display: none; } + .filter-chip { white-space: nowrap; padding: 5px 12px; border-radius: 16px; background: var(--card-subtle); border: 1px solid var(--card-border); font-size: 12px; font-weight: 500; color: var(--text-secondary); cursor: pointer; transition: all 0.15s; } + .filter-chip.active { background: var(--accent-blue); color: #fff; border-color: var(--accent-blue); font-weight: 600; } + .proxy-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } + .proxy-grid.list-view { grid-template-columns: 1fr; } + .node-card { background: var(--card-bg); border: 1px solid var(--card-border); border-radius: var(--radius-md); padding: 11px 12px; cursor: pointer; transition: all 0.15s; position: relative; display: flex; flex-direction: column; justify-content: space-between; } + .node-card:active { transform: scale(0.98); } + .node-card.selected { border-color: var(--accent-blue); background: var(--card-bg); box-shadow: 0 0 0 1px var(--accent-blue); } + .node-name { font-size: 12px; font-weight: 600; color: var(--text-primary); margin-bottom: 8px; line-height: 1.35; } + .node-footer { display: flex; align-items: center; justify-content: space-between; } + .node-type-pill { font-size: 9px; font-weight: 600; text-transform: uppercase; padding: 2px 5px; border-radius: 4px; background: var(--badge-bg); color: var(--text-secondary); } + .node-ping { font-size: 11px; font-weight: 600; font-family: var(--font-mono); display: flex; align-items: center; gap: 4px; } + .node-ping.fast { color: var(--accent-green); } + .node-ping.medium { color: var(--accent-orange); } + .node-ping.slow { color: var(--accent-red); } + + /* Provider & Profile Cards */ + .provider-card { background: var(--card-bg); border: 1px solid var(--card-border); border-radius: var(--radius-md); padding: 12px 14px; margin-bottom: 8px; display: flex; align-items: center; justify-content: space-between; } + .provider-info h4 { font-size: 13px; font-weight: 600; margin-bottom: 2px; display: flex; align-items: center; gap: 6px; } + .provider-info p { font-size: 11px; color: var(--text-secondary); } + .profile-card { background: var(--card-bg); border: 1px solid var(--card-border); border-radius: var(--radius-lg); padding: 14px 16px; margin-bottom: 10px; position: relative; cursor: pointer; } + .profile-card.active-profile { border-color: var(--accent-blue); box-shadow: 0 0 0 1px var(--accent-blue); } + .profile-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 8px; } + .progress-bar-track { width: 100%; height: 5px; background: var(--card-subtle); border-radius: 3px; overflow: hidden; margin: 6px 0; } + .progress-bar-fill { height: 100%; background: var(--accent-blue); border-radius: 3px; } + .profile-meta-row { display: flex; justify-content: space-between; font-size: 11px; color: var(--text-secondary); margin-top: 5px; } + .expiry-tag { color: var(--accent-orange); font-weight: 600; } + .file-row { display: flex; align-items: center; justify-content: space-between; padding: 11px 13px; background: var(--card-bg); border: 1px solid var(--card-border); border-radius: var(--radius-md); margin-bottom: 8px; } + + /* Strategy Cards & Radio (PRD-004) */ + .strategy-card { background: var(--card-bg); border: 1px solid var(--card-border); border-radius: var(--radius-md); padding: 13px 15px; margin-bottom: 10px; transition: all 0.15s ease; cursor: pointer; position: relative; } + .strategy-card.active-strat { border-color: var(--accent-green); background: var(--card-bg); box-shadow: 0 0 0 1px var(--accent-green); } + .strategy-card.active-strat::before { content: ""; position: absolute; left: 0; top: 10px; bottom: 10px; width: 3px; border-radius: 0 3px 3px 0; background: var(--accent-green); } + .radio-indicator { width: 20px; height: 20px; border-radius: 50%; border: 1.5px solid var(--card-border); display: flex; align-items: center; justify-content: center; font-size: 12px; color: transparent; transition: all 0.15s ease; } + .strategy-card.active-strat .radio-indicator { border-color: var(--accent-green); background: var(--accent-green); color: #fff; } + + /* App Items & Native Checkbox */ + .app-item-row { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; background: var(--card-bg); border: 1px solid var(--card-border); border-radius: var(--radius-sm); margin-bottom: 6px; cursor: pointer; transition: background 0.15s; } + .app-item-row:hover { background: var(--card-subtle); } + .checkbox { width: 20px; height: 20px; border-radius: 5px; border: 1.5px solid var(--card-border); display: flex; align-items: center; justify-content: center; transition: all 0.15s; background: transparent; } + .checkbox.checked { background: var(--accent-blue); border-color: var(--accent-blue); } + .checkbox svg { width: 13px; height: 13px; color: #fff; display: none; } + .checkbox.checked svg { display: block; } + .checkbox.indeterminate { background: var(--accent-blue); border-color: var(--accent-blue); } + .checkbox.indeterminate svg { display: none; } + .checkbox.indeterminate::after { content: ""; display: block; width: 9px; height: 2px; background: #fff; border-radius: 1px; } + + /* Native Grouped Settings (iOS / Material 3 Style) */ + .settings-group { margin-bottom: 16px; background: var(--card-bg); border: 1px solid var(--card-border); border-radius: var(--radius-md); overflow: hidden; } + .settings-group-title { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-secondary); margin: 14px 4px 6px; } + .settings-item { display: flex; align-items: center; justify-content: space-between; padding: 12px 14px; background: transparent; border: none; border-bottom: 0.5px solid var(--card-border); cursor: pointer; transition: background 0.12s; } + .settings-item:last-child { border-bottom: none; } + .settings-item:active { background: var(--card-subtle); } + .settings-main { display: flex; flex-direction: column; gap: 2px; } + .settings-title { font-size: 13px; font-weight: 500; color: var(--text-primary); } + .settings-desc { font-size: 11px; color: var(--text-secondary); } + + /* Native Switch Control */ + .switch { position: relative; display: inline-block; width: 44px; height: 25px; } + .switch input { opacity: 0; width: 0; height: 0; } + .slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: var(--card-subtle); border: 1px solid var(--card-border); transition: 0.25s cubic-bezier(0.16, 1, 0.3, 1); border-radius: 25px; } + .slider:before { position: absolute; content: ""; height: 19px; width: 19px; left: 2px; bottom: 2px; background-color: white; transition: 0.25s cubic-bezier(0.16, 1, 0.3, 1); border-radius: 50%; box-shadow: 0 1px 3px rgba(0,0,0,0.2); } + input:checked + .slider { background-color: var(--accent-green); border-color: var(--accent-green); } + input:checked + .slider:before { transform: translateX(19px); } + + /* Native Bottom Sheets & Modals */ + .bottom-sheet-backdrop { position: absolute; inset: 0; background: rgba(0, 0, 0, 0.55); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); z-index: 90; opacity: 0; pointer-events: none; transition: opacity 0.25s ease; } + .bottom-sheet-backdrop.show { opacity: 1; pointer-events: auto; } + .bottom-sheet { position: absolute; left: 0; right: 0; bottom: 0; max-height: 84%; background: var(--sheet-bg); border-top-left-radius: 24px; border-top-right-radius: 24px; padding: 14px 18px 32px; z-index: 100; transform: translateY(100%); transition: transform 0.25s cubic-bezier(0.16, 1, 0.3, 1); display: flex; flex-direction: column; border-top: 1px solid var(--card-border); } + .bottom-sheet.show { transform: translateY(0); } + .sheet-handle { width: 36px; height: 4px; background: var(--text-tertiary); border-radius: 2px; margin: 0 auto 12px; opacity: 0.45; } + .sheet-title-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; } + .sheet-title { font-size: 16px; font-weight: 600; letter-spacing: -0.2px; } + .sheet-content { overflow-y: auto; max-height: 60vh; padding-right: 2px; } + + /* Toast */ + .toast-container { position: absolute; top: 48px; left: 50%; transform: translateX(-50%); z-index: 120; display: flex; flex-direction: column; gap: 6px; pointer-events: none; width: 90%; max-width: 320px; } + .toast { background: rgba(26, 27, 34, 0.94); color: #fff; backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px); padding: 8px 14px; border-radius: 10px; font-size: 12px; font-weight: 500; display: flex; align-items: center; gap: 8px; border: 1px solid rgba(255, 255, 255, 0.08); animation: toastIn 0.25s cubic-bezier(0.16, 1, 0.3, 1); box-shadow: 0 4px 14px rgba(0,0,0,0.25); } + [data-theme="light"] .toast { background: rgba(255, 255, 255, 0.95); color: #1c1c1e; border-color: rgba(0,0,0,0.08); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); } + @keyframes toastIn { from { opacity: 0; transform: translateY(-10px) scale(0.96); } to { opacity: 1; transform: translateY(0); } } + + /* Form Inputs */ + .form-group { margin-bottom: 12px; } + .form-label { font-size: 11px; font-weight: 600; color: var(--text-secondary); margin-bottom: 5px; display: block; } + .form-input { width: 100%; background: var(--card-subtle); border: 1px solid var(--card-border); border-radius: var(--radius-sm); padding: 9px 11px; color: var(--text-primary); font-size: 13px; outline: none; transition: border-color 0.15s; } + .form-input:focus { border-color: var(--accent-blue); } + + /* Log Box */ + .log-box { background: #000; color: #30d158; font-family: var(--font-mono); font-size: 11px; line-height: 1.5; padding: 10px; border-radius: 8px; height: 260px; overflow-y: auto; border: 1px solid rgba(255, 255, 255, 0.08); } + .log-line { margin-bottom: 3px; word-break: break-all; } + .log-info { color: #5ac8fa; } + .log-warn { color: #ff9f0a; } + .log-err { color: #ff453a; } + + /* App Sets & Dedicated Management (PRD-003 & PRD-004) */ + .app-set-card { display: flex; justify-content: space-between; align-items: center; padding: 11px 13px; background: var(--card-subtle); border: 1px solid var(--card-border); border-radius: var(--radius-sm); margin-bottom: 7px; cursor: pointer; transition: all 0.15s ease; } + .app-set-card:hover { border-color: rgba(255, 255, 255, 0.14); } + .app-set-info { display: flex; align-items: center; gap: 10px; } + .app-set-icon { width: 32px; height: 32px; border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: 16px; background: var(--card-bg); border: 0.5px solid var(--card-border); } + .app-set-title { font-size: 12px; font-weight: 600; color: var(--text-primary); } + .app-set-subtitle { font-size: 11px; color: var(--text-secondary); margin-top: 1px; } + + .app-set-actions { display: flex; align-items: center; gap: 7px; } + .app-set-edit-btn { padding: 3px 7px; font-size: 11px; border-radius: 5px; background: var(--card-bg); border: 1px solid var(--card-border); color: var(--text-secondary); cursor: pointer; transition: all 0.15s; display: inline-flex; align-items: center; gap: 3px; } + .app-set-edit-btn:hover { color: var(--text-primary); border-color: var(--text-secondary); } + + .app-set-mgmt-card { background: var(--card-bg); border: 1px solid var(--card-border); border-radius: var(--radius-md); padding: 13px 15px; margin-bottom: 10px; transition: all 0.15s; } + .app-set-mgmt-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 7px; } + .app-set-mgmt-title-group { display: flex; align-items: center; gap: 9px; } + .app-set-mgmt-icon { width: 32px; height: 32px; border-radius: 7px; display: flex; align-items: center; justify-content: center; font-size: 16px; background: var(--card-subtle); border: 0.5px solid var(--card-border); } + .app-set-mgmt-title { font-size: 13px; font-weight: 600; color: var(--text-primary); } + .app-set-mgmt-desc { font-size: 11px; color: var(--text-secondary); margin-top: 1px; } + .app-set-member-preview { display: flex; flex-wrap: wrap; gap: 5px; margin: 9px 0; padding: 7px 9px; background: var(--card-subtle); border-radius: var(--radius-sm); border: 0.5px solid var(--card-border); } + .app-set-member-pill { font-size: 10px; padding: 2px 7px; border-radius: 4px; background: var(--card-bg); color: var(--text-primary); display: flex; align-items: center; gap: 3px; border: 0.5px solid var(--card-border); } + .app-set-mgmt-footer { display: flex; justify-content: space-between; align-items: center; padding-top: 8px; border-top: 0.5px solid var(--card-border); font-size: 11px; color: var(--text-tertiary); } + + /* App Pick List in Modals */ + .app-pick-list { max-height: 220px; overflow-y: auto; display: flex; flex-direction: column; gap: 5px; margin-top: 8px; padding-right: 2px; } + .app-pick-row { display: flex; align-items: center; justify-content: space-between; padding: 7px 9px; background: var(--card-subtle); border: 0.5px solid var(--card-border); border-radius: var(--radius-sm); cursor: pointer; transition: background 0.12s; } + .app-pick-row:hover { background: rgba(255, 255, 255, 0.05); } + .app-pick-info { display: flex; align-items: center; gap: 8px; font-size: 12px; } + .app-pick-name { font-weight: 500; color: var(--text-primary); } + .app-pick-pkg { font-size: 10px; color: var(--text-secondary); } + + /* App Tag Badges (Minimalist Flat Tints) */ + .app-tag-pill { font-size: 10px; font-weight: 600; padding: 1px 5px; border-radius: 3px; margin-left: 5px; } + .app-tag-system { background: var(--accent-blue-subtle); color: var(--accent-blue); } + .app-tag-domestic { background: var(--accent-orange-subtle); color: var(--accent-orange); } + .app-tag-custom { background: rgba(191, 90, 242, 0.12); color: var(--accent-purple); } + .app-tag-social { background: var(--accent-blue-subtle); color: var(--accent-blue); } + .app-tag-media { background: var(--accent-red-subtle); color: var(--accent-red); } + .app-tag-work { background: var(--accent-green-subtle); color: var(--accent-green); } +""" diff --git a/ui-web-demo/parts_html.py b/ui-web-demo/parts_html.py new file mode 100644 index 0000000000..092fb4e866 --- /dev/null +++ b/ui-web-demo/parts_html.py @@ -0,0 +1,1092 @@ +HTML_BODY = """ + +
+
+

+ ClashMetaX + UI 2.0 FULL +

+
+ 保留应用分流策略 + 访问控制应用集 + WebDAV 云同步 + 提供者/文件/age密钥 +
+
+ +
+ + + +
+
+ + +
+
+ + +
+ + +
+ 09:41 +
+
+ 🛡️ VPN +
+
+ 5G + + + + + + + + +
+
+ + +
+ + +
+ + + +
+
+
+
+ 运行中 (VPN) +
+ 01:24:32 +
+ +
+ +
安全网络已守护
+
生效配置:香港 IEPL 专线 · 规则模式
+
+ + +
+
规则分流
+
全局代理
+
直接连接
+
+
+ + +
+
+ + + + + + 出口感知 · 点击快捷切节点 + + 38 ms +
+
+ 🇭🇰 + 香港 BGP 01 [IEPL专线] +
+
+ 出站 IP: 104.28.198.42 + 数据中心: Cloudflare HK +
+
+ + +
+
+
+ + + 实时下行 + + 2.4 MB/s +
+
+ + + 实时上行 + + 340.2 KB/s +
+
+ +
+ +
+ +
+ 累计下载: 1.42 GB + 累计上传: 280.4 MB +
+
+ + +
+ +
+
+ 应用分流策略 + 已生效 +
+
+ 当前:海外应用加速
+ 7 款已选 · 白名单代理 +
+
+ + +
+
+ WebDAV 同步 + 自动 +
+
+ 已同步 2 份配置
+ 今天 08:30 已备份 +
+
+ + +
+
+ 提供者 (Providers) + 3 组正常 +
+
+ 节点集 48 个 · 规则集 3
+ 点击一键健康检查 +
+
+ + +
+
+ 文件与 Geo + 4 个 +
+
+ GeoIP / GeoSite.dat
+ age 密钥已就绪 +
+
+
+
+ + +
+ + + +
+
节点选择
+
提供者集合 (Providers)
+
+ + +
+
+
🚀 节点选择 (12)
+
⚡ 自动优选 (8)
+
🎬 流媒体 (6)
+
🤖 AI 智能 (5)
+
🎯 全球直连
+
+ +
+
+ + +
+
+ +
+
+ + + +
+ + +
+ + +
+
配置订阅 (Profiles)
+
外部资源与文件 (Files)
+
+ + +
+
+
+
+
+ 香港 IEPL 专线 · 年付 + 生效中 +
+
+ https://sub.airport.io/api/v1/subscribe... +
+
+ +
+
+
+
+
+ 已用 142 GB / 500 GB (28.4%) + 18天后到期 (2026-11-08) +
+
+ 包含 48 个节点 · age 加密已保护 + 更新于 10分钟前 +
+
+ +
+
+
+
备用自建 VLESS 节点
+
+ 文件配置 · 离线存储卡导入 +
+
+ +
+
+ 包含 6 个自建节点 · 无用量上限 + 3天前更新 +
+
+
+ + + +
+ + +
+ + +
+
策略配置方案
+
应用集合 (可编辑)
+
名单勾选配置
+
+ + +
+ +
+
+
+
+
全局访问控制名单
+ 默认基础 +
+
模式:仅允许选中的应用 (白名单) · 已选 8 个
+
+
●
+
+
+ 未启用任何策略时由全局名单接管 + +
+
+ + +
+ 多策略方案 (单选生效) + +
+ +
+ +
+ 💡 说明:当单选激活某份分流策略时,内核将优先由该策略接管分流路由;停用所有策略时自动回退为全局名单。离开应用名单列表时立即自动保存并执行热重载。 +
+
+ + + + + + +
+ + +
+ + + +
+
WebDAV 云端同步 (TIIEHenry 特性)
+
+
+
WebDAV 同步控制台
+
https://dav.jianguoyun.com/dav/ · 自动双向同步
+
+ 已就绪 → +
+
+ + +
+
网络与 TUN 核心 (Network)
+
+
+
TUN 虚拟网卡模式
+
接管整机 TCP/UDP 流量,游戏与低延迟保障
+
+ +
+
+
+
网络协议栈 (Network Stack)
+
当前:gVisor (推荐) · 兼容 Android VPN
+
+ gVisor → +
+
+
+
自动绕过本地局域网
+
192.168.x.x / 10.x.x 等私网 IP 不经代理
+
+ +
+
+
+
DNS 智能防污染与嗅探
+
嗅探 TLS SNI 与 HTTP Host,修正分流匹配
+
+ +
+
+ + +
+
覆写与 Meta 特性 (Override & Meta)
+
+
+
端口与嗅探策略覆写
+
Mixed: 7890 · SOCKS: 7891 · 纯嗅探与重定向
+
+ 配置 → +
+
+
+
Geo 数据库与外部控制
+
MetaCubeX GeoIP / GeoSite · REST 端口 9090
+
+ 配置 → +
+
+ + +
+
日志与系统
+
+
+
实时终端日志 (Logcat)
+
实时调度、连接分流判定、报错与调试
+
+ → +
+
+
+
通知栏快捷磁贴 (Quick Tile)
+
从下拉菜单一键启停和切模式
+
+ +
+
+
+
关于 ClashMetaX
+
版本 2.11.35 · Mihomo Core 90a5afcc
+
+ v2.11.35 +
+
+
+ +
+ + +
+
+
+
+
快速切换节点
+ +
+
+
+ + +
+
+
+
实时内核日志流 (Logcat)
+
+ + +
+
+
+
+ + +
+
+
+
活跃连接实时监控 (4)
+ +
+
+
+ + +
+
+
+
WebDAV 同步设置
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
启动与配置变更时自动同步
+
自动双向比对并合并多设备订阅
+
+ +
+
+ + + + +
+
+
+ + +
+
+
+
配置属性
+ +
+
+
+ + +
+
+ + +
+
+ +
+ 已设置 (age1ql3z7h68...) + +
+
+ +
+
+ + +
+
+
+
网络协议栈 (Network Stack)
+ +
+
+
+ 选择 Android TUN 虚拟网卡底层运行的 TCP/IP 协议栈实现: +
+
+
+ gVisor (推荐) + 默认 +
+
Google 用户态网络栈,稳定兼容 Android 10-15,高并发与游戏防丢包首选。
+
+
+
+ System (系统原生栈) +
+
利用 Android Linux 内核网络栈转发,CPU 开销稍低,个别设备可能存在 UDP 阻断。
+
+
+
+ Mixed (混合模式) +
+
TCP 采用系统原生栈,UDP 走 gVisor 用户栈,兼顾吞吐量与稳定性。
+
+
+
+ + +
+
+
+
配置覆写 (Config Overrides)
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
允许来自局域网的连接 (Allow LAN)
+
共享热点或局域网设备代理
+
+ +
+
+
+
全局域名嗅探 (Sniffer)
+
嗅探 TLS SNI 与 HTTP Host,防止 IP 分流误判
+
+ +
+
+
+
DNS Fake-IP 模式 (198.18.0.0/16)
+
秒级并发 DNS 解析与防污染
+
+ +
+ +
+
+ + +
+
+
+
Meta 内核特性 (Mihomo Core)
+ +
+
+
+ +
+ + +
+
+
+
+
进程包名提取 (FindProcess)
+
允许规则直接使用 PROCESS-NAME 匹配连接应用
+
+ +
+
+
+
TCP 并发连接 (TCP Concurrent)
+
并发多 IP 测速首包连接,大幅降低建连延迟
+
+ +
+
+
+
统一 RTT 延迟算法 (Unified Delay)
+
剔除握手偏差,提供高精度真实延迟排序
+
+ +
+ +
+
+ + +
+
+
+
新建 / 导入配置档案
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ + +
+
+
+
新建应用分流策略 (TIIEHenry)
+ +
+
+
+ + +
+
+ + +
+
+
+
立即启用该策略
+
在核心分流规则链中激活
+
+ +
+ +
+
+ + +
+
+
+
关于 ClashMetaX UI 2.0
+ +
+
+
+
⚡
+
ClashMetaX · TIIEHenry Fork
+
版本: v2.10.8-meta · 内核: Mihomo v1.18.9
+
+
+
完整保留的 Fork 核心特性:
+
✅ 应用分流策略 (Apps Strategy):支持多配置方案与白名单/黑名单模式切换
+
✅ 访问控制应用集 (Access Control):系统/国内应用筛选、一键反选与包名信息
+
✅ WebDAV 全量云同步:局域网/公网双地址容灾、双向比对自动同步
+
✅ age 敏感配置加密:支持安全密文存储与密钥热更新
+
✅ 全特性提供者与文件管理:Proxy/Rule Providers 批量健康检查与配置编辑
+
+
+ UI 2.0 原型系统 · Bento Grid 状态看板 · 零延迟无缝切流 +
+
+
+ + + +
+
+
+
编辑分流策略
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+
新建自定义应用集
+ +
+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+ + +
+
+
+
编辑应用集
+ +
+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+ + + + +
+
+""" diff --git a/ui-web-demo/parts_js.py b/ui-web-demo/parts_js.py new file mode 100644 index 0000000000..93728205a4 --- /dev/null +++ b/ui-web-demo/parts_js.py @@ -0,0 +1,1667 @@ +JS_BLOCK = r""" + const state = { + theme: "dark", + fullscreenPhone: false, + running: true, + mode: "Rule", + uptimeSeconds: 5072, + networkStack: "gVisor", + geoipSource: "MetaCubeX", + mixedPort: 7890, + socksPort: 7891, + restPort: 9090, + sniffer: true, + fakeIp: true, + allowLan: false, + + // Active Outbound Node + currentNode: { + id: "hk01", + group: "PROXY", + flag: "🇭🇰", + name: "香港 BGP 01 [IEPL专线]", + type: "SS", + ip: "104.28.198.42", + geo: "Cloudflare HK", + ping: 38 + }, + currentGroup: "PROXY", + trafficDown: 2.4 * 1024 * 1024, + trafficUp: 340 * 1024, + totalDown: 1420 * 1024 * 1024, + totalUp: 280 * 1024 * 1024, + samples: Array.from({ length: 40 }, () => ({ up: 200 + Math.random() * 300, down: 1200 + Math.random() * 2000 })), + isGrid: true, + + // Proxies List + nodes: [ + { id: "hk01", group: "PROXY", flag: "🇭🇰", name: "香港 BGP 01 [IEPL专线]", type: "SS", ping: 38, ip: "104.28.198.42", geo: "Cloudflare HK" }, + { id: "hk02", group: "PROXY", flag: "🇭🇰", name: "香港 BGP 02 [直连优化]", type: "Vmess", ping: 45, ip: "104.28.198.50", geo: "HKIX" }, + { id: "hk03", group: "PROXY", flag: "🇭🇰", name: "香港 03 [Netflix/Disney+]", type: "Trojan", ping: 52, ip: "103.21.244.12", geo: "HK Datacenter" }, + { id: "jp01", group: "PROXY", flag: "🇯🇵", name: "日本 东京 01 [低延迟专线]", type: "SS", ping: 68, ip: "172.67.142.11", geo: "Tokyo Equinix" }, + { id: "jp02", group: "PROXY", flag: "🇯🇵", name: "日本 大阪 02 [原生IP]", type: "VLESS", ping: 75, ip: "172.67.142.88", geo: "Osaka Sakura" }, + { id: "sg01", group: "PROXY", flag: "🇸🇬", name: "新加坡 01 [BGP多线]", type: "SS", ping: 62, ip: "104.21.33.19", geo: "Singapore Singtel" }, + { id: "sg02", group: "PROXY", flag: "🇸🇬", name: "新加坡 02 [流媒体解锁]", type: "Hysteria2", ping: 59, ip: "104.21.33.20", geo: "Singapore AWS" }, + { id: "us01", group: "PROXY", flag: "🇺🇸", name: "美国 洛杉矶 01 [GIA优质线路]", type: "SS", ping: 142, ip: "198.41.214.10", geo: "Los Angeles Cera" }, + { id: "us02", group: "PROXY", flag: "🇺🇸", name: "美国 硅谷 02 [ChatGPT专用]", type: "Trojan", ping: 156, ip: "198.41.214.22", geo: "San Jose Equinix" }, + { id: "tw01", group: "PROXY", flag: "🇹🇼", name: "台湾 台北 01 [动画疯解锁]", type: "SS", ping: 48, ip: "104.28.156.12", geo: "Taipei Chunghwa" }, + { id: "de01", group: "🇩🇪", flag: "🇩🇪", name: "德国 法兰克福 01 [欧洲节点]", type: "Vmess", ping: 210, ip: "188.114.97.10", geo: "Frankfurt" }, + { id: "kr01", group: "PROXY", flag: "🇰🇷", name: "韩国 首尔 01 [低延迟]", type: "Trojan", ping: 64, ip: "104.28.12.90", geo: "Seoul KINX" } + ], + + // PRD-003 App Definitions (Package Name, Label, isSystem, isDomestic) + apps: [ + { id: "tg", name: "Telegram", pkg: "org.telegram.messenger", icon: "✈️", isSystem: false, isDomestic: false }, + { id: "yt", name: "YouTube", pkg: "com.google.android.youtube", icon: "▶️", isSystem: false, isDomestic: false }, + { id: "tw", name: "X (Twitter)", pkg: "com.twitter.android", icon: "🐦", isSystem: false, isDomestic: false }, + { id: "chrome", name: "Google Chrome", pkg: "com.android.chrome", icon: "🌐", isSystem: true, isDomestic: false }, + { id: "netflix", name: "Netflix", pkg: "com.netflix.mediaclient", icon: "🎬", isSystem: false, isDomestic: false }, + { id: "discord", name: "Discord", pkg: "com.discord", icon: "💬", isSystem: false, isDomestic: false }, + { id: "github", name: "GitHub", pkg: "com.github.android", icon: "🐙", isSystem: false, isDomestic: false }, + { id: "spotify", name: "Spotify", pkg: "com.spotify.music", icon: "🎵", isSystem: false, isDomestic: false }, + { id: "wx", name: "微信", pkg: "com.tencent.mm", icon: "💬", isSystem: false, isDomestic: true }, + { id: "alipay", name: "支付宝", pkg: "com.eg.android.AlipayGphone", icon: "💳", isSystem: false, isDomestic: true }, + { id: "bili", name: "哔哩哔哩", pkg: "tv.danmaku.bili", icon: "📺", isSystem: false, isDomestic: true }, + { id: "taobao", name: "手机淘宝", pkg: "com.taobao.taobao", icon: "🛍️", isSystem: false, isDomestic: true }, + { id: "settings", name: "系统设置", pkg: "com.android.settings", icon: "⚙️", isSystem: true, isDomestic: false }, + { id: "play", name: "Google Play 商店", pkg: "com.android.vending", icon: "🏪", isSystem: true, isDomestic: false }, + { id: "systemui", name: "系统界面 (SystemUI)", pkg: "com.android.systemui", icon: "📱", isSystem: true, isDomestic: false } + ], + + // PRD-003 Global Access Control Packages + globalPackages: ["tg", "yt", "tw", "chrome", "netflix", "discord", "github", "spotify"], + + // PRD-003 App Sets (Customizable & Editable App Collections) + appSets: [ + { + id: "system", + name: "系统应用集合", + icon: "⚙️", + isBuiltin: true, + description: "Android 系统底层及系统服务组件 (自动按系统标识归类)", + packages: ["chrome", "settings", "play", "systemui"] + }, + { + id: "domestic", + name: "国内常用软件", + icon: "🇨🇳", + isBuiltin: true, + description: "基于 domestic_packages.txt 目录,可扩充自定义包名", + packages: ["wx", "alipay", "bili", "taobao"] + }, + { + id: "social", + name: "海外社交媒体", + icon: "💬", + isBuiltin: false, + description: "Telegram, X (Twitter), Discord 等国际即时通讯与社群", + packages: ["tg", "tw", "discord"] + }, + { + id: "streaming", + name: "影音流媒体", + icon: "🎬", + isBuiltin: false, + description: "YouTube, Netflix, Spotify 视听娱乐服务", + packages: ["yt", "netflix", "spotify"] + }, + { + id: "developer", + name: "开发者与办公", + icon: "💻", + isBuiltin: false, + description: "GitHub, Chrome 等工作协同与代码托管工具", + packages: ["github", "chrome"] + } + ], + editingAppSetId: null, + newAppSetTempPackages: [], + editAppSetTempPackages: [], + appSetPickerKeyword: "", + + // PRD-004 Apps Strategies + strategies: [ + { id: "s1", name: "海外应用加速", mode: "AcceptSelected", modeDesc: "仅允许选中的应用 (白名单)", packages: ["tg", "yt", "tw", "chrome", "netflix", "discord", "github"] }, + { id: "s2", name: "国内直连绕过", mode: "DenySelected", modeDesc: "仅禁止选中的应用 (黑名单直连)", packages: ["wx", "alipay", "bili", "taobao"] }, + { id: "s3", name: "办公工作区", mode: "AcceptAll", modeDesc: "全部应用允许进入隧道 (全局)", packages: [] } + ], + + // Currently Active Strategy (UUID or null for global access control fallback) + activeStrategyUuid: "s1", + + // Currently Editing Target: null (global access control) or strategy ID ("s1", "s2", etc.) + currentEditingTarget: "s1", + + // Filter States + hideSystemApps: false, + appSearchKeyword: "", + editingStrategyId: null, + + // Providers List + providers: [ + { id: "p1", name: "海外流媒体分流规则集", type: "Rule Provider", vehicle: "HTTP", count: 1420, updated: "10分钟前", format: "yaml", status: "有效" }, + { id: "p2", name: "广告与追踪过滤规则集", type: "Rule Provider", vehicle: "HTTP", count: 38500, updated: "2小时前", format: "text", status: "有效" }, + { id: "p3", name: "备用专线节点提供者", type: "Proxy Provider", vehicle: "HTTP", count: 18, updated: "昨天 19:30", format: "yaml", status: "有效" } + ], + + // Active Connections + connections: [ + { host: "api.openai.com:443", process: "ChatGPT", group: "🤖 AI 智能", node: "香港 BGP 01", speed: "128 KB/s" }, + { host: "rr3---sn-4g5ednks.googlevideo.com:443", process: "YouTube", group: "🎬 流媒体", node: "香港 BGP 02", speed: "3.2 MB/s" }, + { host: "api.telegram.org:443", process: "Telegram", group: "🚀 节点选择", node: "新加坡 01", speed: "14 KB/s" }, + { host: "gateway.discord.gg:443", process: "Discord", group: "🚀 节点选择", node: "日本 东京 01", speed: "42 KB/s" } + ] + }; + + // DOM Elements + const heroCard = document.getElementById("heroCard"); + const powerBtn = document.getElementById("powerBtn"); + const connectBadge = document.getElementById("connectBadge"); + const connectStatusText = document.getElementById("connectStatusText"); + const powerHintText = document.getElementById("powerHintText"); + const powerSubhintText = document.getElementById("powerSubhintText"); + const uptimeDisplay = document.getElementById("uptimeDisplay"); + const islandVpnIcon = document.getElementById("islandVpnIcon"); + const outboundCard = document.getElementById("outboundCard"); + const outboundFlag = document.getElementById("outboundFlag"); + const outboundName = document.getElementById("outboundName"); + const outboundPing = document.getElementById("outboundPing"); + const outboundIp = document.getElementById("outboundIp"); + const outboundGeo = document.getElementById("outboundGeo"); + const downRate = document.getElementById("downRate"); + const upRate = document.getElementById("upRate"); + const totalDown = document.getElementById("totalDown"); + const totalUp = document.getElementById("totalUp"); + const toastContainer = document.getElementById("toastContainer"); + + // Formatters + function formatBytes(bytes) { + if (bytes < 1024) return bytes.toFixed(0) + " B/s"; + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB/s"; + return (bytes / (1024 * 1024)).toFixed(2) + " MB/s"; + } + + function formatTotal(bytes) { + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB"; + if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + " MB"; + return (bytes / (1024 * 1024 * 1024)).toFixed(2) + " GB"; + } + + function formatTime(sec) { + const h = Math.floor(sec / 3600).toString().padStart(2, "0"); + const m = Math.floor((sec % 3600) / 60).toString().padStart(2, "0"); + const s = (sec % 60).toString().padStart(2, "0"); + return `${h}:${m}:${s}`; + } + + // Toast + function showToast(msg) { + const toast = document.createElement("div"); + toast.className = "toast"; + toast.innerHTML = `⚡${msg}`; + toastContainer.appendChild(toast); + setTimeout(() => { + toast.style.opacity = "0"; + toast.style.transform = "translateY(-10px)"; + toast.style.transition = "all 0.25s"; + setTimeout(() => toast.remove(), 250); + }, 2600); + } + + // Sheet Controls + function openSheet(id) { + const sheet = document.getElementById(id); + const backdrop = document.getElementById("sheetBackdrop"); + if (sheet && backdrop) { + backdrop.classList.add("show"); + sheet.classList.add("show"); + } + } + + function closeSheet() { + document.querySelectorAll(".bottom-sheet").forEach(s => s.classList.remove("show")); + const backdrop = document.getElementById("sheetBackdrop"); + if (backdrop) backdrop.classList.remove("show"); + } + + // Power Toggle + powerBtn.addEventListener("click", () => { + if (state.running) { + state.running = false; + powerBtn.classList.remove("running"); + heroCard.classList.remove("running"); + connectBadge.classList.remove("running"); + connectStatusText.textContent = "已断开"; + powerHintText.textContent = "点击连接网络"; + powerSubhintText.textContent = "核心已停止工作"; + islandVpnIcon.classList.remove("active"); + showToast("VPN 隧道已断开"); + addLog("[Core] Clash.Meta tunnel stopped", "warn"); + } else { + powerBtn.classList.add("connecting"); + powerHintText.textContent = "正在建立安全隧道..."; + setTimeout(() => { + powerBtn.classList.remove("connecting"); + powerBtn.classList.add("running"); + heroCard.classList.add("running"); + connectBadge.classList.add("running"); + connectStatusText.textContent = "运行中 (VPN)"; + powerHintText.textContent = "安全网络已保护"; + powerSubhintText.textContent = `模式:${state.mode === "Rule" ? "规则分流" : state.mode === "Global" ? "全局代理" : "直接连接"}`; + islandVpnIcon.classList.add("active"); + state.running = true; + showToast("已连接到 " + state.currentNode.name); + addLog("[Core] Clash.Meta tunnel established successfully", "info"); + }, 650); + } + }); + + // Mode Selector + document.querySelectorAll(".mode-tab").forEach(tab => { + tab.addEventListener("click", () => { + document.querySelectorAll(".mode-tab").forEach(t => t.classList.remove("active")); + tab.classList.add("active"); + state.mode = tab.dataset.mode; + showToast(`分流模式已切换为:${tab.textContent}`); + if (state.running) { + powerSubhintText.textContent = `模式:${tab.textContent}`; + } + addLog(`[Mode] Routing mode switched to [${tab.textContent}]`); + }); + }); + + // Node Switcher + outboundCard.addEventListener("click", openQuickNodeSwitcher); + + function openQuickNodeSwitcher() { + const container = document.getElementById("quickNodeList"); + container.innerHTML = state.nodes.map(n => ` +
+
+
+ ${n.flag} ${n.name} +
+
+ ${n.ping} ms +
+
+
+ ${n.type} · ${n.geo} (${n.ip}) +
+
+ `).join(""); + + openSheet("nodeSwitcherSheet"); + } + + function selectNode(nodeId) { + const node = state.nodes.find(n => n.id === nodeId); + if (node) { + state.currentNode = node; + outboundFlag.textContent = node.flag; + outboundName.textContent = node.name; + outboundPing.textContent = node.ping + " ms"; + outboundIp.textContent = node.ip; + outboundGeo.textContent = node.geo; + closeSheet(); + renderProxies(); + showToast(`已切换至: ${node.name}`); + addLog(`[Proxy] Switched outbound node to [${node.name}] (${node.ip})`); + } + } + + // Main Tab Switching + function switchTab(tabId, subTab) { + document.querySelectorAll(".page-view").forEach(p => p.classList.remove("active")); + document.querySelectorAll(".nav-item").forEach(n => n.classList.remove("active")); + + const targetPage = document.getElementById("page-" + tabId); + if (targetPage) targetPage.classList.add("active"); + + const navIndices = { dashboard: 0, proxies: 1, profiles: 2, apps: 3, settings: 4 }; + const items = document.querySelectorAll(".nav-item"); + if (items[navIndices[tabId]]) { + items[navIndices[tabId]].classList.add("active"); + } + + if (tabId === "proxies") { + if (subTab === "providers") toggleProxySubTab("providers"); + else toggleProxySubTab("proxies"); + } + if (tabId === "profiles") { + if (subTab === "files") toggleProfileSubTab("files"); + else toggleProfileSubTab("profiles"); + } + if (tabId === "apps") { + if (subTab === "accesslist") toggleStrategySubTab("accesslist"); + else toggleStrategySubTab("strategies"); + } + } + + // SubTabs + function toggleProxySubTab(subTab) { + const tabA = document.getElementById("subTabProxies"); + const tabB = document.getElementById("subTabProviders"); + const secA = document.getElementById("proxySectionA"); + const secB = document.getElementById("proxySectionB"); + + if (subTab === "providers") { + tabA.classList.remove("active"); + tabB.classList.add("active"); + secA.style.display = "none"; + secB.style.display = "block"; + renderProviders(); + } else { + tabA.classList.add("active"); + tabB.classList.remove("active"); + secA.style.display = "block"; + secB.style.display = "none"; + renderProxies(); + } + } + + function toggleProfileSubTab(subTab) { + const tabA = document.getElementById("subTabProfiles"); + const tabB = document.getElementById("subTabFiles"); + const secA = document.getElementById("profileSectionA"); + const secB = document.getElementById("profileSectionB"); + + if (subTab === "files") { + tabA.classList.remove("active"); + tabB.classList.add("active"); + secA.style.display = "none"; + secB.style.display = "block"; + } else { + tabA.classList.add("active"); + tabB.classList.remove("active"); + secA.style.display = "block"; + secB.style.display = "none"; + } + } + + function toggleStrategySubTab(subTab) { + const tabA = document.getElementById("subTabStrategyList"); + const tabS = document.getElementById("subTabAppSets"); + const tabB = document.getElementById("subTabAccessList"); + const secA = document.getElementById("strategySectionA"); + const secS = document.getElementById("strategySectionSets"); + const secB = document.getElementById("strategySectionB"); + + [tabA, tabS, tabB].forEach(t => t && t.classList.remove("active")); + [secA, secS, secB].forEach(s => s && (s.style.display = "none")); + + if (subTab === "appsets") { + if (tabS) tabS.classList.add("active"); + if (secS) secS.style.display = "block"; + renderAppSetsManagement(); + } else if (subTab === "accesslist") { + if (tabB) tabB.classList.add("active"); + if (secB) secB.style.display = "block"; + renderApps(); + } else { + if (tabA) tabA.classList.add("active"); + if (secA) secA.style.display = "block"; + renderStrategies(); + } + } + + // Proxies Render + function renderProxies() { + const container = document.getElementById("nodeContainer"); + const search = (document.getElementById("proxySearchInput").value || "").toLowerCase(); + + const filtered = state.nodes.filter(n => { + return n.name.toLowerCase().includes(search) || n.type.toLowerCase().includes(search) || n.geo.toLowerCase().includes(search); + }); + + container.className = "proxy-grid" + (state.isGrid ? "" : " list-view"); + container.innerHTML = filtered.map(n => { + const isSel = (n.name === state.currentNode.name); + const pingClass = n.ping < 80 ? "fast" : n.ping < 160 ? "medium" : "slow"; + return ` +
+
${n.flag} ${n.name}
+ +
+ `; + }).join(""); + } + + document.getElementById("proxySearchInput").addEventListener("input", renderProxies); + + document.getElementById("btnToggleLayout").addEventListener("click", () => { + state.isGrid = !state.isGrid; + renderProxies(); + showToast(state.isGrid ? "已切换至网格卡片视图" : "已切换至紧凑列表视图"); + }); + + document.getElementById("btnUrlTest").addEventListener("click", () => { + showToast("正在并发测速全部 12 个节点..."); + addLog("[Test] Initiated parallel URL-test across 12 endpoints"); + + document.querySelectorAll(".node-ping").forEach(el => { + el.innerHTML = `⏳ 测速中`; + }); + + setTimeout(() => { + state.nodes.forEach(n => { + n.ping = Math.floor(28 + Math.random() * 190); + }); + state.nodes.sort((a, b) => a.ping - b.ping); + renderProxies(); + showToast("测速完成,节点已按延迟自动升序排列!"); + }, 850); + }); + + // Providers Render + function renderProviders() { + const container = document.getElementById("providersListContainer"); + if (!container) return; + container.innerHTML = state.providers.map((p, idx) => ` +
+
+
+
${p.name}
+
+ 类型:${p.type} · 载体:${p.vehicle} (${p.format}) +
+
+ + ${p.status} + +
+ +
+
+ 规则/节点数: ${p.count} 条 · 更新于 ${p.updated} +
+
+ + +
+
+
+ `).join(""); + } + + function updateSingleProvider(id, isForce = false) { + const p = state.providers.find(item => item.id === id); + if (p) { + showToast(`正在更新提供者 [${p.name}]...`); + addLog(`[Provider] Updating provider ${p.name} (${p.type})`); + setTimeout(() => { + p.updated = "刚刚"; + renderProviders(); + showToast(`[${p.name}] 更新成功,已拉取 ${p.count} 条规则`); + }, 600); + } + } + + function updateAllProviders() { + showToast("正在全量更新所有 Providers..."); + addLog("[Provider] Bulk updating all Rule & Proxy Providers", "warn"); + setTimeout(() => { + state.providers.forEach(p => p.updated = "刚刚"); + renderProviders(); + showToast("全量提供者更新完毕,核心规则树已重构"); + }, 900); + } + + // ========================================================================= + // APPS STRATEGY & ACCESS CONTROL (PRD-003 & PRD-004 TIIEHenry Fork Core) + // ========================================================================= + + function selectActiveStrategy(uuid) { + state.activeStrategyUuid = uuid; + renderStrategies(); + + const sDesc = document.getElementById("settingsStrategyDesc"); + if (uuid === null) { + showToast("已切换为【全局访问控制名单】,内核已热重载"); + addLog("[Strategy] Deactivated all named strategies, falling back to global accessControlPackages", "info"); + if (sDesc) sDesc.textContent = "当前使用全局访问控制名单"; + } else { + const s = state.strategies.find(x => x.id === uuid); + showToast(`已激活分流策略【${s.name}】(内核 TUN 已热重载)`); + addLog(`[Strategy] Active strategy set to [${s.name}] (${s.mode}), reloading TUN filter with ${s.packages.length} apps`, "info"); + if (sDesc) sDesc.textContent = `当前由分流策略 [${s.name}] 接管`; + } + } + + function renderStrategies() { + // 1. Global Card + const globalCard = document.getElementById("globalAccessCard"); + const globalRadio = document.getElementById("globalAccessRadio"); + const globalDesc = document.getElementById("globalAccessDesc"); + + const isGlobalActive = (state.activeStrategyUuid === null); + if (globalCard) { + globalCard.classList.toggle("active-strat", isGlobalActive); + } + if (globalRadio) { + globalRadio.textContent = isGlobalActive ? "●" : ""; + } + if (globalDesc) { + globalDesc.textContent = `模式:仅允许选中的应用进入隧道 (白名单) · 已选 ${state.globalPackages.length} 个应用`; + } + + // Update Dashboard Bento card + const dashStatus = document.getElementById("dashStrategyStatus"); + const dashDesc = document.getElementById("dashStrategyDesc"); + if (dashDesc) { + if (state.activeStrategyUuid === null) { + if (dashStatus) dashStatus.textContent = "全局名单"; + dashDesc.innerHTML = `当前:全局基础名单
${state.globalPackages.length} 款已选 · 白名单`; + } else { + const activeS = state.strategies.find(s => s.id === state.activeStrategyUuid); + if (activeS) { + if (dashStatus) dashStatus.textContent = "已生效"; + const modeLabel = activeS.mode === "AcceptSelected" ? "白名单代理" : activeS.mode === "DenySelected" ? "黑名单直连" : "全局放行"; + dashDesc.innerHTML = `当前:${activeS.name}
${activeS.packages.length} 款已选 · ${modeLabel}`; + } + } + } + + // 2. Custom Named Strategies + const container = document.getElementById("strategyConfigsContainer"); + if (!container) return; + + container.innerHTML = state.strategies.map((strat) => { + const isActive = (state.activeStrategyUuid === strat.id); + const modeBadgeClass = strat.mode === "AcceptSelected" ? "var(--accent-blue)" : strat.mode === "DenySelected" ? "var(--accent-orange)" : "var(--accent-green)"; + const modeBadgeBg = strat.mode === "AcceptSelected" ? "var(--accent-blue-subtle)" : strat.mode === "DenySelected" ? "var(--accent-orange-subtle)" : "var(--accent-green-subtle)"; + + // Find matched app sets + const matchedSetNames = state.appSets.filter(set => { + return set.packages.length > 0 && set.packages.every(p => strat.packages.includes(p)); + }).map(s => s.icon + " " + s.name); + + const setHint = matchedSetNames.length > 0 ? ` · 覆盖: ${matchedSetNames.slice(0, 2).join(", ")}` : ""; + + return ` +
+
+
+
+
${strat.name}
+ + ${strat.mode === "AcceptSelected" ? "白名单" : strat.mode === "DenySelected" ? "黑名单直连" : "全局放行"} + + ${isActive ? '● 生效中' : ''} +
+
${strat.modeDesc}
+
+
${isActive ? "●" : ""}
+
+ +
+ + 已勾选 ${strat.packages.length} 款应用${setHint} + +
+ + +
+
+
+ `; + }).join(""); + } + + // Dedicated App Sets Management Screen (PRD-003) + function renderAppSetsManagement() { + const container = document.getElementById("appSetsManagementContainer"); + if (!container) return; + + container.innerHTML = state.appSets.map(set => { + const memberApps = set.packages.map(pkgId => state.apps.find(a => a.id === pkgId)).filter(Boolean); + const previewPills = memberApps.slice(0, 6).map(a => ` + ${a.icon} ${a.name} + `).join(""); + const moreCount = memberApps.length > 6 ? `+${memberApps.length - 6}` : ""; + + return ` +
+
+
+
${set.icon}
+
+
+ ${set.name} + + ${set.isBuiltin ? '内置基础集合' : '自定义集合'} + +
+
${set.description}
+
+
+
+ +
+ ${previewPills || '暂无成员应用,请点击编辑添加'} + ${moreCount} +
+ + +
+ `; + }).join(""); + } + + // Open/Close and Manage App Set Modals + function openNewAppSetModal() { + state.newAppSetTempPackages = []; + document.getElementById("newAppSetName").value = ""; + document.getElementById("newAppSetIcon").value = "📦"; + document.getElementById("newAppSetDesc").value = ""; + document.getElementById("newAppSetSearchInput").value = ""; + state.appSetPickerKeyword = ""; + renderNewAppSetPickList(""); + openSheet("newAppSetSheet"); + } + + function onFilterNewAppSetPicker(kw) { + state.appSetPickerKeyword = kw.trim().toLowerCase(); + renderNewAppSetPickList(state.appSetPickerKeyword); + } + + function renderNewAppSetPickList(kw) { + const container = document.getElementById("newAppSetPickList"); + if (!container) return; + + const filtered = state.apps.filter(a => { + if (!kw) return true; + return a.name.toLowerCase().includes(kw) || a.pkg.toLowerCase().includes(kw); + }); + + const countEl = document.getElementById("newAppSetPickCount"); + if (countEl) countEl.textContent = state.newAppSetTempPackages.length; + + if (filtered.length === 0) { + container.innerHTML = `
无匹配应用
`; + return; + } + + container.innerHTML = filtered.map(app => { + const isSelected = state.newAppSetTempPackages.includes(app.id); + return ` +
+
+ ${app.icon} +
+
${app.name}
+
${app.pkg}
+
+
+
+ + + +
+
+ `; + }).join(""); + } + + function toggleNewAppSetPick(appId) { + const idx = state.newAppSetTempPackages.indexOf(appId); + if (idx !== -1) { + state.newAppSetTempPackages.splice(idx, 1); + } else { + state.newAppSetTempPackages.push(appId); + } + renderNewAppSetPickList(state.appSetPickerKeyword); + } + + function submitNewAppSet() { + const name = document.getElementById("newAppSetName").value.trim(); + const icon = document.getElementById("newAppSetIcon").value.trim() || "📦"; + const desc = document.getElementById("newAppSetDesc").value.trim() || "自定义应用集合"; + + if (!name) { + showToast("请输入应用集合名称"); + return; + } + + const newSet = { + id: "set_" + Date.now().toString(36), + name: name, + icon: icon, + isBuiltin: false, + description: desc, + packages: [...state.newAppSetTempPackages] + }; + + state.appSets.push(newSet); + closeSheet("newAppSetSheet"); + showToast(`已成功创建应用集合【${name}】(含 ${newSet.packages.length} 款应用)`); + addLog(`[AppSet] Created custom app set [${name}] with ${newSet.packages.length} packages`, "info"); + + renderAppSetsManagement(); + renderApps(); + renderStrategies(); + } + + function openEditAppSetModal(setId) { + const set = state.appSets.find(s => s.id === setId); + if (!set) return; + + state.editingAppSetId = setId; + state.editAppSetTempPackages = [...set.packages]; + state.appSetPickerKeyword = ""; + + const titleEl = document.getElementById("editAppSetSheetTitle"); + if (titleEl) titleEl.textContent = `编辑应用集 · ${set.name}`; + + const nameInput = document.getElementById("editAppSetName"); + const iconInput = document.getElementById("editAppSetIcon"); + const descInput = document.getElementById("editAppSetDesc"); + const searchInput = document.getElementById("editAppSetSearchInput"); + const delBtn = document.getElementById("btnDeleteAppSet"); + + if (nameInput) nameInput.value = set.name; + if (iconInput) iconInput.value = set.icon; + if (descInput) descInput.value = set.description; + if (searchInput) searchInput.value = ""; + + // Built-in sets cannot be deleted + if (delBtn) { + delBtn.style.display = set.isBuiltin ? "none" : "block"; + } + + renderEditAppSetPickList(""); + openSheet("editAppSetSheet"); + } + + function onFilterEditAppSetPicker(kw) { + state.appSetPickerKeyword = kw.trim().toLowerCase(); + renderEditAppSetPickList(state.appSetPickerKeyword); + } + + function renderEditAppSetPickList(kw) { + const container = document.getElementById("editAppSetPickList"); + if (!container) return; + + const filtered = state.apps.filter(a => { + if (!kw) return true; + return a.name.toLowerCase().includes(kw) || a.pkg.toLowerCase().includes(kw); + }); + + const countEl = document.getElementById("editAppSetPickCount"); + if (countEl) countEl.textContent = state.editAppSetTempPackages.length; + + if (filtered.length === 0) { + container.innerHTML = `
无匹配应用
`; + return; + } + + container.innerHTML = filtered.map(app => { + const isSelected = state.editAppSetTempPackages.includes(app.id); + return ` +
+
+ ${app.icon} +
+
${app.name}
+
${app.pkg}
+
+
+
+ + + +
+
+ `; + }).join(""); + } + + function toggleEditAppSetPick(appId) { + const idx = state.editAppSetTempPackages.indexOf(appId); + if (idx !== -1) { + state.editAppSetTempPackages.splice(idx, 1); + } else { + state.editAppSetTempPackages.push(appId); + } + renderEditAppSetPickList(state.appSetPickerKeyword); + } + + function saveAppSetEdits() { + const set = state.appSets.find(s => s.id === state.editingAppSetId); + if (!set) return; + + const name = document.getElementById("editAppSetName").value.trim(); + const icon = document.getElementById("editAppSetIcon").value.trim(); + const desc = document.getElementById("editAppSetDesc").value.trim(); + + if (!name) { + showToast("集合名称不可为空"); + return; + } + + set.name = name; + if (icon) set.icon = icon; + set.description = desc; + set.packages = [...state.editAppSetTempPackages]; + + closeSheet("editAppSetSheet"); + showToast(`已保存应用集【${set.name}】修改 (成员已更新为 ${set.packages.length} 款)`); + addLog(`[AppSet] Updated set [${set.name}], total members: ${set.packages.length}`, "info"); + + renderAppSetsManagement(); + renderApps(); + renderStrategies(); + } + + function deleteCurrentAppSet() { + if (!state.editingAppSetId) return; + deleteAppSetDirect(state.editingAppSetId); + closeSheet("editAppSetSheet"); + } + + function deleteAppSetDirect(setId) { + const set = state.appSets.find(s => s.id === setId); + if (!set || set.isBuiltin) { + showToast("内置基础集合不支持删除"); + return; + } + + if (confirm(`确定要删除自定义应用集【${set.name}】吗?此操作不会删除已安装应用。`)) { + state.appSets = state.appSets.filter(s => s.id !== setId); + showToast(`已删除应用集【${set.name}】`); + addLog(`[AppSet] Deleted custom app set [${set.name}]`, "warn"); + renderAppSetsManagement(); + renderApps(); + renderStrategies(); + } + } + + function editStrategyApps(targetId) { + state.currentEditingTarget = targetId; + toggleStrategySubTab("accesslist"); + + const titleEl = document.getElementById("currentEditingStrategyTitle"); + const descEl = document.getElementById("currentEditingStrategyModeDesc"); + const activeBadge = document.getElementById("currentEditingStrategyActiveBadge"); + + if (targetId === null) { + titleEl.textContent = "全局访问控制名单"; + descEl.textContent = "当前模式:仅允许选中的应用进入隧道 (白名单)"; + activeBadge.style.display = (state.activeStrategyUuid === null) ? "inline-block" : "none"; + } else { + const s = state.strategies.find(x => x.id === targetId); + if (s) { + titleEl.textContent = `${s.name} · 名单配置`; + descEl.textContent = `当前模式:${s.modeDesc}`; + activeBadge.style.display = (state.activeStrategyUuid === s.id) ? "inline-block" : "none"; + } + } + + state.appSearchKeyword = ""; + const searchInput = document.getElementById("appSearchInput"); + if (searchInput) searchInput.value = ""; + + renderApps(); + } + + function getCurrentTargetSet() { + if (state.currentEditingTarget === null) { + return state.globalPackages; + } + const s = state.strategies.find(x => x.id === state.currentEditingTarget); + return s ? s.packages : state.globalPackages; + } + + // PRD-003 Dynamic App Sets & App List Rendering + function renderApps() { + const currentSet = getCurrentTargetSet(); + + // 1. Render Dynamic App Sets Quick-Bar (PRD-003 & App Sets) + const setsContainer = document.getElementById("appSetsQuickBarContainer"); + if (setsContainer) { + setsContainer.innerHTML = state.appSets.map(set => { + const totalMembers = set.packages.length; + const selectedMembers = set.packages.filter(p => currentSet.includes(p)).length; + + let stateClass = ""; + if (totalMembers > 0 && selectedMembers === totalMembers) { + stateClass = "checked"; + } else if (selectedMembers > 0) { + stateClass = "indeterminate"; + } + + return ` +
+
+
${set.icon}
+
+
+ ${set.name} + ${set.isBuiltin ? '' : '自定义'} +
+
成员共 ${totalMembers} 款 · 当前已勾选 ${selectedMembers} 款
+
+
+
+ +
+ + + +
+
+
+ `; + }).join(""); + } + + // 2. Count Totals + const countEl = document.getElementById("selectedAppCount"); + const totalEl = document.getElementById("totalAppCount"); + if (countEl) countEl.textContent = currentSet.length; + if (totalEl) totalEl.textContent = state.apps.length; + + // 3. Filter Apps List + const container = document.getElementById("appListContainer"); + if (!container) return; + + const filtered = state.apps.filter(a => { + if (state.hideSystemApps && a.isSystem) return false; + if (state.appSearchKeyword) { + const kw = state.appSearchKeyword; + return a.name.toLowerCase().includes(kw) || a.pkg.toLowerCase().includes(kw); + } + return true; + }); + + if (filtered.length === 0) { + container.innerHTML = `
无匹配应用
`; + return; + } + + container.innerHTML = filtered.map(app => { + const isChecked = currentSet.includes(app.id); + const matchedSets = state.appSets.filter(s => s.packages.includes(app.id)); + const setBadges = matchedSets.map(s => ` + ${s.icon} ${s.name} + `).join(""); + + return ` +
+
+
${app.icon}
+
+
+ ${app.name} + ${app.isSystem ? '系统' : ''} + ${app.isDomestic ? '国内' : ''} + ${setBadges} +
+
${app.pkg}
+
+
+
+ + + +
+
+ `; + }).join(""); + } + + // PRD-003 applyAppSetToggle on dynamic sets + function toggleAppSet(setId) { + const currentSet = getCurrentTargetSet(); + const targetSet = state.appSets.find(s => s.id === setId); + if (!targetSet || targetSet.packages.length === 0) { + showToast("该应用集合内暂无成员应用"); + return; + } + + const memberIds = targetSet.packages; + const allSelected = memberIds.every(id => currentSet.includes(id)); + + if (allSelected) { + // Checking a fully selected set clears only those members + memberIds.forEach(id => { + const idx = currentSet.indexOf(id); + if (idx !== -1) currentSet.splice(idx, 1); + }); + showToast(`已取消【${targetSet.name}】的勾选`); + addLog(`[AccessControl] Deselected ${memberIds.length} apps from set [${targetSet.name}]`, "info"); + } else { + // Checking a partial or empty selection selects every current member + memberIds.forEach(id => { + if (!currentSet.includes(id)) currentSet.push(id); + }); + showToast(`已全选【${targetSet.name}】(${memberIds.length}款应用)`); + addLog(`[AccessControl] Selected all ${memberIds.length} apps in set [${targetSet.name}]`, "info"); + } + + renderApps(); + renderStrategies(); + } + + function toggleApp(id) { + const currentSet = getCurrentTargetSet(); + const idx = currentSet.indexOf(id); + const app = state.apps.find(a => a.id === id); + + if (idx !== -1) { + currentSet.splice(idx, 1); + showToast(`${app ? app.name : id} 已从分流名单移除`); + } else { + currentSet.push(id); + showToast(`${app ? app.name : id} 已加入分流名单`); + } + + renderApps(); + renderStrategies(); + } + + function selectAllApps(select) { + const currentSet = getCurrentTargetSet(); + if (select) { + state.apps.forEach(a => { + if (!currentSet.includes(a.id)) currentSet.push(a.id); + }); + showToast("已全选当前所有应用名单"); + addLog("[AccessControl] Selected all installed applications", "info"); + } else { + currentSet.length = 0; + showToast("已清空所有已选应用"); + addLog("[AccessControl] Cleared all application selections", "info"); + } + renderApps(); + renderStrategies(); + } + + function invertAppSelection() { + const currentSet = getCurrentTargetSet(); + const allIds = state.apps.map(a => a.id); + + allIds.forEach(id => { + const idx = currentSet.indexOf(id); + if (idx !== -1) { + currentSet.splice(idx, 1); + } else { + currentSet.push(id); + } + }); + + showToast("已反向选择应用名单"); + addLog("[AccessControl] Inverted app selection", "info"); + renderApps(); + renderStrategies(); + } + + function onAppSearch(kw) { + state.appSearchKeyword = kw.trim().toLowerCase(); + renderApps(); + } + + function toggleHideSystem() { + const sw = document.getElementById("hideSystemSwitch"); + state.hideSystemApps = sw ? sw.checked : false; + renderApps(); + showToast(state.hideSystemApps ? "已隐藏系统自带应用行" : "已显示系统自带应用行"); + } + + function openNewStrategyModal() { + openSheet("newStrategySheet"); + } + + function submitNewStrategy() { + const name = document.getElementById("newStratName").value.trim() || "新建分流策略"; + const mode = document.getElementById("newStratMode").value; + const active = document.getElementById("newStratActive").checked; + const modeDesc = mode === "AcceptSelected" ? "仅允许选中的应用 (白名单)" : mode === "DenySelected" ? "仅禁止选中的应用 (黑名单直连)" : "全部应用允许进入隧道 (全局)"; + + const newId = "s_" + Date.now(); + state.strategies.push({ + id: newId, + name: name, + mode: mode, + modeDesc: modeDesc, + packages: [] + }); + + if (active) { + state.activeStrategyUuid = newId; + } + + closeSheet(); + renderStrategies(); + showToast(`分流策略【${name}】创建成功,现可配置应用名单`); + addLog(`[Strategy] Created new strategy: ${name} (${mode})`, "info"); + + // Auto open access list for the new strategy + editStrategyApps(newId); + } + + function openEditStrategyModal(stratId) { + state.editingStrategyId = stratId; + const s = state.strategies.find(x => x.id === stratId); + if (!s) return; + + document.getElementById("editStratName").value = s.name; + document.getElementById("editStratMode").value = s.mode; + openSheet("strategyEditSheet"); + } + + function saveStrategyEdits() { + const s = state.strategies.find(x => x.id === state.editingStrategyId); + if (!s) return; + + const newName = document.getElementById("editStratName").value.trim() || s.name; + const newMode = document.getElementById("editStratMode").value; + + s.name = newName; + s.mode = newMode; + s.modeDesc = newMode === "AcceptSelected" ? "仅允许选中的应用 (白名单)" : newMode === "DenySelected" ? "仅禁止选中的应用 (黑名单直连)" : "全部应用允许进入隧道 (全局)"; + + closeSheet(); + renderStrategies(); + showToast(`分流策略【${s.name}】修改已生效`); + addLog(`[Strategy] Updated strategy ${s.id}: name=${s.name}, mode=${s.mode}`); + } + + function deleteCurrentStrategy() { + const s = state.strategies.find(x => x.id === state.editingStrategyId); + if (!s) return; + + state.strategies = state.strategies.filter(x => x.id !== s.id); + if (state.activeStrategyUuid === s.id) { + state.activeStrategyUuid = null; + showToast(`已删除策略【${s.name}】,已自动回退为全局名单`); + } else { + showToast(`策略【${s.name}】已成功删除`); + } + + closeSheet(); + renderStrategies(); + addLog(`[Strategy] Deleted strategy ${s.id}`, "warn"); + } + + // Profiles Operations + function updateSingleProfile(e, profId) { + if (e) e.stopPropagation(); + const btn = e ? e.currentTarget : null; + if (btn) { + btn.style.transform = "rotate(360deg)"; + btn.style.transition = "transform 0.6s ease"; + } + showToast("正在拉取最新订阅规则与节点配置..."); + addLog("[Profile] Fetching remote subscription update", "info"); + setTimeout(() => { + if (btn) btn.style.transform = ""; + showToast("订阅更新成功!节点列表已刷新"); + }, 700); + } + + function activateProfile(profId) { + document.querySelectorAll(".profile-card").forEach(c => c.classList.remove("active-profile")); + const p = document.getElementById(profId); + if (p) p.classList.add("active-profile"); + showToast("已切换生效配置档案"); + addLog(`[Profile] Active configuration switched to [${profId}]`); + } + + document.getElementById("btnUpdateAllProfiles").addEventListener("click", () => { + showToast("正在批量并发同步所有订阅配置..."); + addLog("[Profile] Bulk updating all active subscriptions"); + setTimeout(() => { + showToast("全部订阅已成功更新至最新版本!"); + }, 900); + }); + + function openProfileProps(title, url, interval, isSub) { + document.getElementById("propsSheetTitle").textContent = `配置属性 · ${title}`; + document.getElementById("propUrlInput").value = url; + document.getElementById("propIntervalInput").value = interval; + openSheet("profilePropsSheet"); + } + + // WebDAV Controls (TIIEHenry Fork Core) + function openWebdavModal() { + openSheet("webdavSheet"); + } + + function testWebdav() { + showToast("正在连接 WebDAV 服务器..."); + addLog("[WebDAV] Testing PROPFIND https://dav.jianguoyun.com/dav/clash-sync/"); + setTimeout(() => { + showToast("✅ WebDAV 连接测试成功,认证有效!"); + addLog("[WebDAV] Connection test passed (HTTP 207 Multi-Status)", "info"); + }, 700); + } + + function syncWebdavNow() { + showToast("正在进行 WebDAV 双向云同步..."); + addLog("[WebDAV] Initiating two-way sync with remote timestamp comparison"); + setTimeout(() => { + showToast("✅ 双向同步完成:3 个配置与分流策略已保持一致"); + addLog("[WebDAV] Sync complete. Local and remote files synchronized", "info"); + closeSheet(); + }, 950); + } + + function pullWebdav() { + showToast("正在从 WebDAV 云端拉取配置..."); + addLog("[WebDAV] Pulling remote configuration and rule sets"); + setTimeout(() => { + showToast("云端配置拉取完成,已覆盖本地缓存"); + closeSheet(); + }, 800); + } + + function pushWebdav() { + showToast("正在向 WebDAV 云端推送备份..."); + addLog("[WebDAV] Pushing local settings, strategies, and age cipher"); + setTimeout(() => { + showToast("本地配置已成功推送到坚果云 WebDAV"); + closeSheet(); + }, 800); + } + + // Network Stack + function openNetworkStackModal() { + openSheet("networkStackSheet"); + } + + function setNetworkStack(stack) { + state.networkStack = stack; + const el = document.getElementById("currentStackLabel"); + if (el) el.textContent = `${stack} (${stack === 'gVisor' ? '推荐' : stack === 'System' ? '系统原生' : '混合栈'})`; + closeSheet(); + showToast(`网络协议栈已切换为: ${stack}`); + addLog(`[Network] TUN virtual stack reconfigured to [${stack}]`, "warn"); + } + + // Overrides + function openOverrideModal() { + document.getElementById("overrideMixedPort").value = state.mixedPort; + document.getElementById("overrideSocksPort").value = state.socksPort; + document.getElementById("overrideRestPort").value = state.restPort; + document.getElementById("overrideAllowLan").checked = state.allowLan; + document.getElementById("overrideSniffer").checked = state.sniffer; + document.getElementById("overrideFakeIp").checked = state.fakeIp; + openSheet("overrideSheet"); + } + + function saveOverrides() { + state.mixedPort = parseInt(document.getElementById("overrideMixedPort").value) || 7890; + state.socksPort = parseInt(document.getElementById("overrideSocksPort").value) || 7891; + state.restPort = parseInt(document.getElementById("overrideRestPort").value) || 9090; + state.allowLan = document.getElementById("overrideAllowLan").checked; + state.sniffer = document.getElementById("overrideSniffer").checked; + state.fakeIp = document.getElementById("overrideFakeIp").checked; + closeSheet(); + showToast("配置覆写已生效,端口与嗅探器已更新"); + addLog(`[Override] Mixed:${state.mixedPort}, SOCKS:${state.socksPort}, Sniffer:${state.sniffer}`); + } + + // Meta Features + function openMetaFeaturesModal() { + openSheet("metaFeaturesSheet"); + } + + function setGeoSource(source) { + state.geoipSource = source; + document.getElementById("geoBtnMeta").style.background = (source === 'MetaCubeX') ? 'var(--accent-blue)' : ''; + document.getElementById("geoBtnLoyal").style.background = (source === 'Loyalsoldier') ? 'var(--accent-blue)' : ''; + showToast(`GeoIP 数据库源已设置为: ${source}`); + addLog(`[Geo] Database provider set to [${source}]`); + } + + // New Profile Modal + function openNewProfileModal() { + openSheet("newProfileSheet"); + } + + function submitNewProfile() { + const name = document.getElementById("newProfName").value || "新建订阅配置"; + closeSheet(); + showToast(`配置 [${name}] 已成功创建!`); + addLog(`[Profile] Created new configuration profile: ${name}`); + } + + // About Dialog + function showAboutDialog() { + openSheet("aboutSheet"); + } + + // Logs + function addLog(text, level = "info") { + const box = document.getElementById("logBox"); + if (!box) return; + const time = new Date().toTimeString().split(" ")[0]; + const div = document.createElement("div"); + div.className = `log-line log-${level}`; + div.textContent = `[${time}] ${text}`; + box.appendChild(div); + box.scrollTop = box.scrollHeight; + } + + function openLogSheet() { + openSheet("logSheet"); + } + + function clearLogs() { + const box = document.getElementById("logBox"); + if (box) box.innerHTML = ""; + showToast("日志已清空"); + } + + document.getElementById("btnFastLogs").addEventListener("click", openLogSheet); + document.getElementById("btnFastReload").addEventListener("click", () => { + showToast("正在重新加载 Mihomo 配置文件..."); + addLog("[Core] Reloading configuration with live state persistence", "warn"); + setTimeout(() => showToast("内核重载完成,无缝平滑切流"), 500); + }); + + // Initial Logs + addLog("[Core] Clash.Meta v1.18.9 initiated with Android TUN stack"); + addLog("[TUN] gVisor virtual network device bound to tun0 at 198.18.0.1/16"); + addLog("[Strategy] Active strategy: [海外应用加速] (AcceptSelected: 7 apps)"); + addLog("[DNS] Sniffing enabled for TLS SNI and HTTP Host header"); + addLog("[Rule] Matching api.openai.com -> Route to [🤖 AI 智能]"); + + // Active Connections + document.getElementById("btnActiveConn").addEventListener("click", () => { + const list = document.getElementById("connListContainer"); + + let activeStrat = null; + let activePackages = state.globalPackages; + let activeMode = "AcceptSelected"; + let activeName = "全局基础名单"; + + if (state.activeStrategyUuid) { + activeStrat = state.strategies.find(s => s.id === state.activeStrategyUuid); + if (activeStrat) { + activePackages = activeStrat.packages; + activeMode = activeStrat.mode; + activeName = activeStrat.name; + } + } + + const simulatedRaw = [ + { host: "api.openai.com:443", appId: "tg", process: "Telegram", speed: "142 KB/s" }, + { host: "rr3---sn-4g5ednks.googlevideo.com:443", appId: "yt", process: "YouTube", speed: "3.4 MB/s" }, + { host: "api.twitter.com:443", appId: "tw", process: "X (Twitter)", speed: "84 KB/s" }, + { host: "szextshort.weixin.qq.com:443", appId: "wx", process: "微信", speed: "12 KB/s" }, + { host: "mobilegw.alipay.com:443", appId: "alipay", process: "支付宝", speed: "8 KB/s" }, + { host: "api.bilibili.com:443", appId: "bili", process: "哔哩哔哩", speed: "2.1 MB/s" }, + { host: "api.github.com:443", appId: "github", process: "GitHub", speed: "45 KB/s" } + ]; + + const simulated = simulatedRaw.map(c => { + let isProxied = false; + let routeReason = ""; + + if (state.mode === "Global") { + isProxied = true; + routeReason = "全局代理模式 (全部走隧道)"; + } else if (state.mode === "Direct") { + isProxied = false; + routeReason = "全局直连模式 (全部绕过)"; + } else { + const inPackages = activePackages.includes(c.appId); + if (activeMode === "AcceptSelected") { + isProxied = inPackages; + routeReason = inPackages ? `白名单命中代理 [${activeName}]` : `未在白名单中 (直连绕过)`; + } else if (activeMode === "DenySelected") { + isProxied = !inPackages; + routeReason = inPackages ? `黑名单命中 (直连绕过) [${activeName}]` : `走代理隧道`; + } else { + isProxied = true; + routeReason = `全局放行隧道 [${activeName}]`; + } + } + + return { + host: c.host, + process: c.process, + speed: c.speed, + isProxied: isProxied, + node: isProxied ? state.currentNode.name : "DIRECT (直连)", + reason: routeReason + }; + }); + + document.getElementById("connCount").textContent = simulated.length; + list.innerHTML = simulated.map(c => ` +
+
+
+ + ${c.isProxied ? '代理' : '直连'} + + ${c.host} +
+ ${c.speed} +
+
+ 应用:${c.process} · 出站:${c.node} + ${c.reason} +
+
+ `).join(""); + + openSheet("connSheet"); + }); + + function clearAllConnections() { + state.connections = []; + document.getElementById("connCount").textContent = 0; + document.getElementById("connListContainer").innerHTML = `
暂无活跃网络连接
`; + showToast("所有活跃连接已重置"); + addLog("[Core] Closed all active sockets", "warn"); + } + + // Canvas Traffic Waveform + const canvas = document.getElementById("trafficCanvas"); + const ctx = canvas.getContext("2d"); + + function resizeCanvas() { + const rect = canvas.getBoundingClientRect(); + canvas.width = rect.width * window.devicePixelRatio; + canvas.height = rect.height * window.devicePixelRatio; + ctx.scale(window.devicePixelRatio, window.devicePixelRatio); + } + window.addEventListener("resize", resizeCanvas); + setTimeout(resizeCanvas, 100); + + function drawWave() { + const w = canvas.getBoundingClientRect().width; + const h = canvas.getBoundingClientRect().height; + ctx.clearRect(0, 0, w, h); + + if (!state.running) { + ctx.strokeStyle = "rgba(142, 142, 147, 0.2)"; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(0, h - 10); + ctx.lineTo(w, h - 10); + ctx.stroke(); + return; + } + + const points = state.samples; + const step = w / (points.length - 1); + + // Download Wave (Blue) + ctx.beginPath(); + ctx.moveTo(0, h); + points.forEach((p, idx) => { + const x = idx * step; + const normY = Math.min(1, p.down / 4000); + const y = h - normY * (h - 15) - 4; + if (idx === 0) ctx.lineTo(x, y); + else ctx.lineTo(x, y); + }); + ctx.lineTo(w, h); + ctx.closePath(); + + const gradDown = ctx.createLinearGradient(0, 0, 0, h); + gradDown.addColorStop(0, "rgba(10, 132, 255, 0.28)"); + gradDown.addColorStop(1, "rgba(10, 132, 255, 0.0)"); + ctx.fillStyle = gradDown; + ctx.fill(); + + ctx.beginPath(); + points.forEach((p, idx) => { + const x = idx * step; + const normY = Math.min(1, p.down / 4000); + const y = h - normY * (h - 15) - 4; + if (idx === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + }); + ctx.strokeStyle = "#0a84ff"; + ctx.lineWidth = 2; + ctx.stroke(); + + // Upload Wave (Green) + ctx.beginPath(); + points.forEach((p, idx) => { + const x = idx * step; + const normY = Math.min(1, p.up / 1200); + const y = h - normY * (h - 30) - 2; + if (idx === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + }); + ctx.strokeStyle = "#30d158"; + ctx.lineWidth = 1.5; + ctx.stroke(); + } + + // Tick Loop + setInterval(() => { + if (state.running) { + state.uptimeSeconds++; + uptimeDisplay.textContent = formatTime(state.uptimeSeconds); + + const downJitter = (Math.random() - 0.48) * 400 * 1024; + state.trafficDown = Math.max(120 * 1024, Math.min(6 * 1024 * 1024, state.trafficDown + downJitter)); + const upJitter = (Math.random() - 0.48) * 80 * 1024; + state.trafficUp = Math.max(20 * 1024, Math.min(1.2 * 1024 * 1024, state.trafficUp + upJitter)); + + state.totalDown += state.trafficDown; + state.totalUp += state.trafficUp; + + downRate.textContent = formatBytes(state.trafficDown); + upRate.textContent = formatBytes(state.trafficUp); + totalDown.textContent = formatTotal(state.totalDown); + totalUp.textContent = formatTotal(state.totalUp); + + state.samples.shift(); + state.samples.push({ + up: state.trafficUp / 1024, + down: state.trafficDown / 1024 + }); + } else { + downRate.textContent = "0.0 KB/s"; + upRate.textContent = "0.0 KB/s"; + } + + drawWave(); + }, 1000); + + // Clock + setInterval(() => { + const now = new Date(); + const h = now.getHours().toString().padStart(2, "0"); + const m = now.getMinutes().toString().padStart(2, "0"); + document.getElementById("clockDisplay").textContent = `${h}:${m}`; + }, 1000); + + // Demo Bar Actions + document.getElementById("btnThemeToggle").addEventListener("click", () => { + state.theme = state.theme === "dark" ? "light" : "dark"; + document.documentElement.setAttribute("data-theme", state.theme); + document.getElementById("themeIcon").textContent = state.theme === "dark" ? "☀️" : "🌙"; + showToast(`已切换至 ${state.theme === "dark" ? "深色暗夜" : "浅色明亮"} 模式`); + }); + + document.getElementById("btnPhoneMode").addEventListener("click", () => { + state.fullscreenPhone = !state.fullscreenPhone; + const phone = document.getElementById("devicePhone"); + phone.classList.toggle("fullscreen-mode", state.fullscreenPhone); + document.getElementById("phoneModeText").textContent = state.fullscreenPhone ? "真机外壳" : "全屏平铺"; + setTimeout(resizeCanvas, 360); + }); + + document.getElementById("btnRandomSpike").addEventListener("click", () => { + if (!state.running) { + showToast("请先点击主开关启动 VPN 连接"); + return; + } + state.trafficDown = 18.5 * 1024 * 1024; + state.trafficUp = 3.2 * 1024 * 1024; + showToast("🔥 模拟突发流量飙升: 18.5 MB/s"); + addLog("[Traffic] Inbound traffic burst detected: 18.5 MB/s via [香港 BGP 01]", "warn"); + }); + + // Initial render + renderProxies(); + renderProviders(); + renderStrategies(); + renderApps(); + drawWave(); + + // Deep Linking Support (URL Query / Hash) + function applyDeepLink() { + const params = new URLSearchParams(window.location.search); + if (params.get('theme') === 'light') { + state.theme = 'light'; + document.documentElement.setAttribute('data-theme', 'light'); + const icon = document.getElementById('themeIcon'); + if (icon) icon.textContent = '🌙'; + } + const hash = window.location.hash.replace('#', ''); + const tab = params.get('tab') || (hash ? hash.split('/')[0] : null); + const sub = params.get('sub') || (hash && hash.includes('/') ? hash.split('/')[1] : null); + if (tab) { + switchTab(tab); + if (sub) toggleStrategySubTab(sub); + const modal = params.get('modal'); + if (modal === 'editAppSet') { + const setId = params.get('id') || 'domestic'; + openEditAppSetModal(setId); + } else if (modal === 'newAppSet') { + openNewAppSetModal(); + } else if (modal === 'connections') { + document.getElementById('btnActiveConn').click(); + } + } + } + applyDeepLink(); +""" diff --git a/ui-web-demo/serve.py b/ui-web-demo/serve.py new file mode 100755 index 0000000000..564b20dd53 --- /dev/null +++ b/ui-web-demo/serve.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 +import http.server +import socketserver +import os +import sys + +PORT = 8080 + +class Handler(http.server.SimpleHTTPRequestHandler): + def __init__(self, *args, **kwargs): + super().__init__(*args, directory=os.path.dirname(os.path.abspath(__file__)), **kwargs) + +if __name__ == "__main__": + port = int(sys.argv[1]) if len(sys.argv) > 1 else PORT + socketserver.TCPServer.allow_reuse_address = True + try: + with socketserver.TCPServer(("", port), Handler) as httpd: + print(f"ClashMetaX UI Web Demo running at: http://localhost:{port}") + httpd.serve_forever() + except OSError as e: + print(f"Port {port} is busy, try another port. Error: {e}") From b4448347b4d75bb76c07ece283b5aeeec97516d6 Mon Sep 17 00:00:00 2001 From: clarence Date: Sun, 25 Oct 2026 10:05:11 +0800 Subject: [PATCH 09/10] Add an in-app language list that defaults to the system locale. Settings, App, Interface lists every locale in locales_config.xml, with Follow system first. An empty or unknown value keeps the system language, including an Android 13 per-app locale. The choice is stored in the ui preference and applied by wrapping each process. --- .../com/github/kr328/clash/BaseActivity.kt | 17 +++ .../com/github/kr328/clash/MainApplication.kt | 26 +++- common/build.gradle.kts | 2 + common/docs/INDEX.md | 5 +- common/docs/constants.md | 4 +- .../github/kr328/clash/common/AppLanguages.kt | 126 ++++++++++++++++++ .../kr328/clash/common/constants/Intents.kt | 4 +- .../kr328/clash/common/AppLanguagesTest.kt | 54 ++++++++ .../kr328/clash/design/AppSettingsDesign.kt | 16 +++ .../clash/design/ios/AppSettingsScreen.kt | 39 +++++- .../kr328/clash/design/store/UiStore.kt | 13 +- design/src/main/res/values-ja-rJP/strings.xml | 2 + design/src/main/res/values-ko-rKR/strings.xml | 2 + design/src/main/res/values-ru/strings.xml | 2 + design/src/main/res/values-vi/strings.xml | 2 + design/src/main/res/values-zh-rHK/strings.xml | 2 + design/src/main/res/values-zh-rTW/strings.xml | 2 + design/src/main/res/values-zh/strings.xml | 2 + design/src/main/res/values/strings.xml | 2 + dev/progress/status.md | 8 +- docs/modules/common/INDEX.md | 5 +- docs/product/requirements/INDEX.md | 3 +- docs/product/requirements/PRD-007.md | 14 ++ docs/product/traceability.md | 3 +- docs/systems/ui/INDEX.md | 6 +- 25 files changed, 336 insertions(+), 25 deletions(-) create mode 100644 common/src/main/java/com/github/kr328/clash/common/AppLanguages.kt create mode 100644 common/src/test/java/com/github/kr328/clash/common/AppLanguagesTest.kt create mode 100644 docs/product/requirements/PRD-007.md diff --git a/app/src/main/java/com/github/kr328/clash/BaseActivity.kt b/app/src/main/java/com/github/kr328/clash/BaseActivity.kt index b181713e13..7973c4e42d 100644 --- a/app/src/main/java/com/github/kr328/clash/BaseActivity.kt +++ b/app/src/main/java/com/github/kr328/clash/BaseActivity.kt @@ -1,6 +1,8 @@ package com.github.kr328.clash import android.app.ActivityManager +import android.content.Context +import android.content.pm.PackageManager import android.content.res.Configuration import android.os.Build import android.os.Bundle @@ -8,6 +10,7 @@ import android.view.View import androidx.activity.result.contract.ActivityResultContract import androidx.appcompat.app.AppCompatActivity import androidx.core.content.getSystemService +import com.github.kr328.clash.common.AppLanguages import com.github.kr328.clash.common.compat.isAllowForceDarkCompat import com.github.kr328.clash.common.compat.isLightNavigationBarCompat import com.github.kr328.clash.common.compat.isLightStatusBarsCompat @@ -86,8 +89,13 @@ abstract class BaseActivity> : AppCompatActivity(), } } + override fun attachBaseContext(newBase: Context) { + super.attachBaseContext(AppLanguages.wrap(newBase)) + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + applyLocalizedTitle() applyDayNight() // Apply excludeFromRecents setting to all app tasks. @@ -145,6 +153,15 @@ abstract class BaseActivity> : AppCompatActivity(), } } + private fun applyLocalizedTitle() { + val labelRes = try { + packageManager.getActivityInfo(componentName, 0).labelRes + } catch (_: PackageManager.NameNotFoundException) { + 0 + } + if (labelRes != 0) setTitle(getString(labelRes)) + } + open fun shouldDisplayHomeAsUpEnabled(): Boolean { return true } diff --git a/app/src/main/java/com/github/kr328/clash/MainApplication.kt b/app/src/main/java/com/github/kr328/clash/MainApplication.kt index 40712c8716..77773406f2 100644 --- a/app/src/main/java/com/github/kr328/clash/MainApplication.kt +++ b/app/src/main/java/com/github/kr328/clash/MainApplication.kt @@ -1,9 +1,16 @@ package com.github.kr328.clash import android.app.Application +import android.content.BroadcastReceiver import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import com.github.kr328.clash.common.AppLanguages import com.github.kr328.clash.common.Global +import com.github.kr328.clash.common.compat.registerReceiverCompat import com.github.kr328.clash.common.compat.currentProcessName +import com.github.kr328.clash.common.constants.Intents +import com.github.kr328.clash.common.constants.Permissions import com.github.kr328.clash.common.log.Log import com.github.kr328.clash.remote.Remote import com.github.kr328.clash.service.util.sendServiceRecreated @@ -14,13 +21,20 @@ import com.github.kr328.clash.util.installBundledGeoFile class MainApplication : Application() { override fun attachBaseContext(base: Context?) { - super.attachBaseContext(base) + if (base != null) AppLanguages.rememberBaseline(base) + super.attachBaseContext(base?.let(AppLanguages::wrap)) Global.init(this) } override fun onCreate() { super.onCreate() + registerReceiverCompat( + appLanguageReceiver, + IntentFilter(Intents.ACTION_APP_LANGUAGE_CHANGED), + Permissions.RECEIVE_SELF_BROADCASTS, + exported = false, + ) val processName = currentProcessName extractGeoFiles() @@ -55,4 +69,14 @@ class MainApplication : Application() { fun finalize() { Global.destroy() } + + private val appLanguageReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent?) { + if (intent?.action != Intents.ACTION_APP_LANGUAGE_CHANGED) return + if (!intent.hasExtra(Intents.EXTRA_APP_LANGUAGE)) return + val tag = intent.getStringExtra(Intents.EXTRA_APP_LANGUAGE) + AppLanguages.write(context, tag.orEmpty()) + AppLanguages.apply(context, tag) + } + } } diff --git a/common/build.gradle.kts b/common/build.gradle.kts index bff1f8bd1c..798b75782d 100644 --- a/common/build.gradle.kts +++ b/common/build.gradle.kts @@ -8,4 +8,6 @@ dependencies { implementation(libs.kotlin.coroutine) implementation(libs.androidx.core) + + testImplementation(libs.junit) } diff --git a/common/docs/INDEX.md b/common/docs/INDEX.md index 284407dbdf..e38e224cf3 100644 --- a/common/docs/INDEX.md +++ b/common/docs/INDEX.md @@ -3,8 +3,8 @@ title: "common 模块设计" type: index status: accepted phase: N/A -updated: 2026-10-15 -summary: "common 的包、依赖,以及 Store、常量以外的 Global、compat 和 util" +updated: 2026-10-25 +summary: "common 的包、依赖,以及 Store、常量以外的 Global、语言、compat 和 util" --- # common @@ -20,6 +20,7 @@ summary: "common 的包、依赖,以及 Store、常量以外的 Global、compa | `compat` | 按 `SDK_INT` 分支的 Android API 包装 | | `util` | 包名、Intent、分片 Parcel、定时 Channel | | `Global.kt` | 进程内的 `Application` 和 IO 协程作用域 | +| `AppLanguages.kt` | 应用语言偏好与进程内套用。行为见 [界面](../../docs/systems/ui/INDEX.md) | | `log` | 固定 tag 的 `android.util.Log` 包装 | | `id` | 进程内递增整数。仓库里由 `ProfileWorker` 用作通知 id | diff --git a/common/docs/constants.md b/common/docs/constants.md index 662c002937..07e272839f 100644 --- a/common/docs/constants.md +++ b/common/docs/constants.md @@ -3,7 +3,7 @@ title: "common 常量" type: architecture status: accepted phase: N/A -updated: 2026-10-15 +updated: 2026-10-25 summary: "Intents、Components、Permissions、Authorities、Metadata 实际拼出的字符串" --- @@ -37,6 +37,7 @@ summary: "Intents、Components、Permissions、Authorities、Metadata 实际拼 | `ACTION_PROFILE_SCHEDULE_UPDATES` | `$packageName.intent.action.SCHEDULE_UPDATES` | | `ACTION_PROFILE_LOADED` | `$packageName.intent.action.PROFILE_LOADED` | | `ACTION_OVERRIDE_CHANGED` | `$packageName.intent.action.OVERRIDE_CHANGED` | +| `ACTION_APP_LANGUAGE_CHANGED` | `$packageName.intent.action.APP_LANGUAGE_CHANGED` | Extra 键是 `const`,不加包名前缀: @@ -44,6 +45,7 @@ Extra 键是 `const`,不加包名前缀: |:-----|:---| | `EXTRA_NAME` | `name` | | `EXTRA_STOP_REASON` | `stop_reason` | +| `EXTRA_APP_LANGUAGE` | `app_language` | | `EXTRA_UUID` | `uuid` | | `EXTRA_FAIL_REASON` | `fail_reason` | diff --git a/common/src/main/java/com/github/kr328/clash/common/AppLanguages.kt b/common/src/main/java/com/github/kr328/clash/common/AppLanguages.kt new file mode 100644 index 0000000000..48d6d1d750 --- /dev/null +++ b/common/src/main/java/com/github/kr328/clash/common/AppLanguages.kt @@ -0,0 +1,126 @@ +package com.github.kr328.clash.common + +import android.content.Context +import android.content.Intent +import android.content.res.Configuration +import android.os.Build +import android.os.LocaleList +import androidx.core.os.LocaleListCompat +import com.github.kr328.clash.common.constants.Intents +import com.github.kr328.clash.common.constants.Permissions +import java.util.Locale + +/** + * Tags follow `app/src/main/res/xml/locales_config.xml`, in the same order. + * An empty tag follows the system, including an Android 13 per-app locale. + */ +object AppLanguages { + const val PREFERENCE_NAME = "ui" + const val KEY = "app_language" + const val FOLLOW_SYSTEM = "" + + data class Option(val tag: String, val nativeName: String) + + val supported: List