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..93cef1c471 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,7 @@ package com.github.kr328.clash import android.app.ActivityManager +import android.content.Intent import android.content.res.Configuration import android.os.Build import android.os.Bundle @@ -12,14 +13,19 @@ import com.github.kr328.clash.common.compat.isAllowForceDarkCompat import com.github.kr328.clash.common.compat.isLightNavigationBarCompat import com.github.kr328.clash.common.compat.isLightStatusBarsCompat import com.github.kr328.clash.common.compat.isSystemBarsTranslucentCompat +import com.github.kr328.clash.common.util.intent import com.github.kr328.clash.core.bridge.ClashException +import com.github.kr328.clash.core.bridge.Bridge import com.github.kr328.clash.design.Design +import com.github.kr328.clash.design.databinding.DesignAboutBinding import com.github.kr328.clash.design.model.DarkMode import com.github.kr328.clash.design.store.UiStore import com.github.kr328.clash.design.ui.DayNight import com.github.kr328.clash.design.util.resolveThemedBoolean import com.github.kr328.clash.design.util.resolveThemedColor import com.github.kr328.clash.design.util.showExceptionToast +import com.github.kr328.clash.design.view.TvActivityShell +import com.github.kr328.clash.design.view.TvNavigationBar import com.github.kr328.clash.remote.Broadcasts import com.github.kr328.clash.remote.Remote import com.github.kr328.clash.util.ActivityResultLifecycle @@ -39,14 +45,16 @@ abstract class BaseActivity> : AppCompatActivity(), protected val uiStore by lazy { UiStore(this) } protected val events = Channel(Channel.UNLIMITED) protected var activityStarted: Boolean = false + private var tvShell: TvActivityShell? = null protected val clashRunning: Boolean get() = Remote.broadcasts.clashRunning protected var design: D? = null set(value) { field = value if (value != null) { - setContentView(value.root) + setContentView(wrapTvContent(value.root)) } else { + tvShell = null setContentView(View(this)) } } @@ -86,6 +94,103 @@ abstract class BaseActivity> : AppCompatActivity(), } } + protected fun openTvTab(intent: Intent) { + if (isTelevision) { + intent.addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT or Intent.FLAG_ACTIVITY_NO_ANIMATION) + } + startActivity(intent) + if (isTelevision) overridePendingTransition(0, 0) + } + + private val isTelevision: Boolean + get() = resources.configuration.uiMode and Configuration.UI_MODE_TYPE_MASK == + Configuration.UI_MODE_TYPE_TELEVISION + + private fun wrapTvContent(content: View): View { + val activeTab = currentTvTab() ?: return content.also { tvShell = null } + if (!isTelevision || this is MainActivity) return content.also { tvShell = null } + + return TvActivityShell(this).also { shell -> + tvShell = shell + shell.setContent(content) + shell.navigationBar.apply { + setActiveTab(activeTab) + proxyEnabled = clashRunning + onTabSelected = ::openTvTab + } + + if (isTopLevelTvTab()) { + content.findViewById(R.id.activity_bar_close_view)?.visibility = View.GONE + } + shell.focusFirstContent() + } + } + + private fun currentTvTab(): TvNavigationBar.Tab? = when (this) { + is MainActivity -> TvNavigationBar.Tab.Home + is ProxyActivity -> TvNavigationBar.Tab.Proxy + is ProfilesActivity, + is NewProfileActivity, + is PropertiesActivity, + is FilesActivity, + is ProvidersActivity -> TvNavigationBar.Tab.Profiles + is LogsActivity, + is LogcatActivity -> TvNavigationBar.Tab.Logs + is SettingsActivity, + is AppSettingsActivity, + is NetworkSettingsActivity, + is OverrideSettingsActivity, + is MetaFeatureSettingsActivity, + is AccessControlActivity -> TvNavigationBar.Tab.Settings + is HelpActivity -> TvNavigationBar.Tab.Help + else -> null + } + + private fun isTopLevelTvTab(): Boolean = when (this) { + is ProxyActivity, + is ProfilesActivity, + is LogsActivity, + is SettingsActivity, + is HelpActivity -> true + else -> false + } + + private fun openTvTab(tab: TvNavigationBar.Tab) { + if (tab == currentTvTab() && isTopLevelTvTab()) { + tvShell?.focusFirstContent() + return + } + + when (tab) { + TvNavigationBar.Tab.Home -> openTvTab( + MainActivity::class.intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP), + ) + TvNavigationBar.Tab.Proxy -> if (clashRunning) openTvTab(ProxyActivity::class.intent) + TvNavigationBar.Tab.Profiles -> openTvTab(ProfilesActivity::class.intent) + TvNavigationBar.Tab.Logs -> openTvTab( + if (LogcatService.running) LogcatActivity::class.intent else LogsActivity::class.intent, + ) + TvNavigationBar.Tab.Settings -> openTvTab(SettingsActivity::class.intent) + TvNavigationBar.Tab.Help -> openTvTab(HelpActivity::class.intent) + TvNavigationBar.Tab.About -> launch { showTvAbout() } + } + } + + private suspend fun showTvAbout() { + val versionName = withContext(Dispatchers.IO) { + packageManager.getPackageInfo(packageName, 0).versionName + "\n" + + Bridge.nativeCoreVersion().replace("_", "-") + } + withContext(Dispatchers.Main) { + val about = DesignAboutBinding.inflate(layoutInflater).apply { + this.versionName = versionName + } + androidx.appcompat.app.AlertDialog.Builder(this@BaseActivity) + .setView(about.root) + .show() + } + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) applyDayNight() @@ -175,10 +280,12 @@ abstract class BaseActivity> : AppCompatActivity(), } override fun onStarted() { + tvShell?.navigationBar?.proxyEnabled = true events.trySend(Event.ClashStart) } override fun onStopped(cause: String?) { + tvShell?.navigationBar?.proxyEnabled = false events.trySend(Event.ClashStop) if (cause != null && activityStarted) { 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 ea8254cb40..8cbda88407 100644 --- a/app/src/main/java/com/github/kr328/clash/MainActivity.kt +++ b/app/src/main/java/com/github/kr328/clash/MainActivity.kt @@ -15,6 +15,7 @@ import androidx.core.graphics.drawable.IconCompat import com.github.kr328.clash.common.constants.Intents import com.github.kr328.clash.common.util.intent import com.github.kr328.clash.common.util.ticker +import com.github.kr328.clash.core.model.ProxySort import com.github.kr328.clash.design.MainDesign import com.github.kr328.clash.design.ui.ToastDuration import com.github.kr328.clash.util.startClashService @@ -52,6 +53,10 @@ class MainActivity : BaseActivity() { } design.requests.onReceive { when (it) { + MainDesign.Request.OpenHome -> { + design.fetch() + design.focusPrimaryAction() + } MainDesign.Request.ToggleStatus -> { if (clashRunning) stopClashService() @@ -59,22 +64,22 @@ class MainActivity : BaseActivity() { design.startClash() } MainDesign.Request.OpenProxy -> - startActivity(ProxyActivity::class.intent) + openTvTab(ProxyActivity::class.intent) MainDesign.Request.OpenProfiles -> - startActivity(ProfilesActivity::class.intent) + openTvTab(ProfilesActivity::class.intent) MainDesign.Request.OpenProviders -> - startActivity(ProvidersActivity::class.intent) + openTvTab(ProvidersActivity::class.intent) MainDesign.Request.OpenLogs -> { if (LogcatService.running) { - startActivity(LogcatActivity::class.intent) + openTvTab(LogcatActivity::class.intent) } else { - startActivity(LogsActivity::class.intent) + openTvTab(LogsActivity::class.intent) } } MainDesign.Request.OpenSettings -> - startActivity(SettingsActivity::class.intent) + openTvTab(SettingsActivity::class.intent) MainDesign.Request.OpenHelp -> - startActivity(HelpActivity::class.intent) + openTvTab(HelpActivity::class.intent) MainDesign.Request.OpenAbout -> design.showAbout(queryAppVersionName()) } @@ -97,9 +102,38 @@ class MainActivity : BaseActivity() { val providers = withClash { queryProviders() } + val currentProxy = if (clashRunning) { + withClash { + val rootGroup = queryProxyGroupNames(true).firstOrNull() + val groupNames = queryProxyGroupNames(false).toHashSet() + + rootGroup?.let { group -> + val visited = mutableSetOf(group) + var node = queryProxyGroup(group, ProxySort.Default).now + val route = mutableListOf() + + while (node.isNotBlank()) { + route += node + if (node !in groupNames || !visited.add(node)) break + val next = queryProxyGroup(node, ProxySort.Default).now + if (next.isBlank() || next == node) break + node = next + } + + group to route.distinct().joinToString(" → ") + } + } + } else { + null + } setMode(state.mode) setHasProviders(providers.isNotEmpty()) + setProviderCount(providers.size) + setCurrentProxy(currentProxy?.first, currentProxy?.second) + val traffic = if (clashRunning) queryTrafficStats() else TrafficSnapshot() + setForwarded(traffic.total) + setTrafficStats(traffic.now, traffic.total, traffic.connections, traffic.memory) withProfile { setProfileName(queryActive()?.name) @@ -107,8 +141,19 @@ class MainActivity : BaseActivity() { } private suspend fun MainDesign.fetchTraffic() { - withClash { - setForwarded(queryTrafficTotal()) + val traffic = queryTrafficStats() + setForwarded(traffic.total) + setTrafficStats(traffic.now, traffic.total, traffic.connections, traffic.memory) + } + + private suspend fun queryTrafficStats(): TrafficSnapshot { + return withClash { + TrafficSnapshot( + now = queryTrafficNow(), + total = queryTrafficTotal(), + connections = queryActiveConnections(), + memory = queryMemory(), + ) } } @@ -118,7 +163,7 @@ class MainActivity : BaseActivity() { if (active == null || !active.imported) { showToast(DesignR.string.no_profile_selected, ToastDuration.Long) { setAction(DesignR.string.profiles) { - startActivity(ProfilesActivity::class.intent) + openTvTab(ProfilesActivity::class.intent) } } @@ -165,6 +210,13 @@ class MainActivity : BaseActivity() { setupShortcuts() } + private data class TrafficSnapshot( + val now: Long = 0L, + val total: Long = 0L, + val connections: Int = 0, + val memory: Long = 0L, + ) + private fun setupShortcuts() { // Skip dynamic shortcut setup when the app icon is hidden. if (uiStore.hideAppIcon) return diff --git a/core/src/main/cpp/main.c b/core/src/main/cpp/main.c index 61e431c8a1..9e2b0ce6d2 100644 --- a/core/src/main/cpp/main.c +++ b/core/src/main/cpp/main.c @@ -78,6 +78,21 @@ Java_com_github_kr328_clash_core_bridge_Bridge_nativeQueryTrafficTotal(JNIEnv *e return (jlong) (down_scale_traffic(upload) << 32u | down_scale_traffic(download)); } +JNIEXPORT jint JNICALL +Java_com_github_kr328_clash_core_bridge_Bridge_nativeQueryActiveConnections(JNIEnv *env, + jobject thiz) { + TRACE_METHOD(); + + return (jint) queryActiveConnections(); +} + +JNIEXPORT jlong JNICALL +Java_com_github_kr328_clash_core_bridge_Bridge_nativeQueryMemory(JNIEnv *env, jobject thiz) { + TRACE_METHOD(); + + return (jlong) queryMemory(); +} + JNIEXPORT void JNICALL Java_com_github_kr328_clash_core_bridge_Bridge_nativeNotifyDnsChanged(JNIEnv *env, jobject thiz, jstring dns_list) { diff --git a/core/src/main/golang/native/tunnel.go b/core/src/main/golang/native/tunnel.go index 3e48863c00..b366735092 100644 --- a/core/src/main/golang/native/tunnel.go +++ b/core/src/main/golang/native/tunnel.go @@ -37,6 +37,16 @@ func queryTotal(upload, download *C.uint64_t) { *download = C.uint64_t(down) } +//export queryActiveConnections +func queryActiveConnections() C.int { + return C.int(tunnel.ActiveConnections()) +} + +//export queryMemory +func queryMemory() C.uint64_t { + return C.uint64_t(tunnel.Memory()) +} + //export queryGroupNames func queryGroupNames(excludeNotSelectable C.int) *C.char { return marshalJson(tunnel.QueryProxyGroupNames(excludeNotSelectable != 0)) diff --git a/core/src/main/golang/native/tunnel/statistic.go b/core/src/main/golang/native/tunnel/statistic.go index 7a00270d54..e07e40d624 100644 --- a/core/src/main/golang/native/tunnel/statistic.go +++ b/core/src/main/golang/native/tunnel/statistic.go @@ -15,3 +15,16 @@ func Now() (up int64, down int64) { func Total() (up int64, down int64) { return statistic.DefaultManager.Total() } + +func ActiveConnections() int { + count := 0 + statistic.DefaultManager.Range(func(statistic.Tracker) bool { + count++ + return true + }) + return count +} + +func Memory() uint64 { + return statistic.DefaultManager.Memory() +} 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..5e076b7ee3 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 @@ -50,6 +50,14 @@ object Clash { return Bridge.nativeQueryTrafficTotal() } + fun queryActiveConnections(): Int { + return Bridge.nativeQueryActiveConnections() + } + + fun queryMemory(): Long { + return Bridge.nativeQueryMemory() + } + fun notifyDnsChanged(dns: List) { Bridge.nativeNotifyDnsChanged(dns.toSet().joinToString(separator = ",")) } @@ -257,4 +265,4 @@ object Clash { private fun parseAgeKeyPair(value: String): AgeKeyPair { return Json.Default.decodeFromString(AgeKeyPair.serializer(), value) } -} \ No newline at end of file +} 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..b8d1c4d2c9 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 @@ -16,6 +16,8 @@ object Bridge { external fun nativeQueryTunnelState(): String external fun nativeQueryTrafficNow(): Long external fun nativeQueryTrafficTotal(): Long + external fun nativeQueryActiveConnections(): Int + external fun nativeQueryMemory(): Long external fun nativeNotifyDnsChanged(dnsList: String) external fun nativeNotifyTimeZoneChanged(name: String, offset: Int) external fun nativeNotifyInstalledAppChanged(uidList: String) @@ -75,4 +77,4 @@ object Bridge { nativeInit(home, versionName, sdkVersion) } -} \ 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 f418e488d9..91d7beac33 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 @@ -1,20 +1,26 @@ package com.github.kr328.clash.design import android.content.Context +import android.content.res.Configuration +import android.text.format.Formatter import android.view.View import androidx.appcompat.app.AlertDialog import com.github.kr328.clash.core.model.TunnelState import com.github.kr328.clash.core.util.trafficTotal +import com.github.kr328.clash.core.util.trafficDownload +import com.github.kr328.clash.core.util.trafficUpload import com.github.kr328.clash.design.databinding.DesignAboutBinding import com.github.kr328.clash.design.databinding.DesignMainBinding import com.github.kr328.clash.design.util.layoutInflater import com.github.kr328.clash.design.util.resolveThemedColor import com.github.kr328.clash.design.util.root +import com.github.kr328.clash.design.view.TvNavigationBar import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext class MainDesign(context: Context) : Design(context) { enum class Request { + OpenHome, ToggleStatus, OpenProxy, OpenProfiles, @@ -40,6 +46,7 @@ class MainDesign(context: Context) : Design(context) { suspend fun setClashRunning(running: Boolean) { withContext(Dispatchers.Main) { binding.clashRunning = running + binding.root.findViewById(R.id.tv_navigation_bar)?.proxyEnabled = running } } @@ -49,6 +56,17 @@ class MainDesign(context: Context) : Design(context) { } } + suspend fun setTrafficStats(now: Long, total: Long, connections: Int, memory: Long) { + withContext(Dispatchers.Main) { + binding.uploadSpeed = "${now.trafficUpload()}/s" + binding.downloadSpeed = "${now.trafficDownload()}/s" + binding.uploaded = total.trafficUpload() + binding.downloaded = total.trafficDownload() + binding.activeConnections = connections.toString() + binding.memoryUsage = Formatter.formatShortFileSize(context, memory) + } + } + suspend fun setMode(mode: TunnelState.Mode) { withContext(Dispatchers.Main) { binding.mode = when (mode) { @@ -66,6 +84,26 @@ class MainDesign(context: Context) : Design(context) { } } + suspend fun setProviderCount(count: Int) { + withContext(Dispatchers.Main) { + binding.providerSummary = context.getString(R.string.tv_provider_summary, count) + } + } + + suspend fun setCurrentProxy(group: String?, node: String?) { + withContext(Dispatchers.Main) { + binding.currentGroup = group ?: context.getString(R.string.tv_group_fallback) + binding.currentNode = node?.takeIf { it.isNotBlank() } + ?: context.getString(R.string.tv_node_fallback) + } + } + + suspend fun focusPrimaryAction() { + withContext(Dispatchers.Main) { + binding.root.findViewById(R.id.tv_status)?.requestFocus() + } + } + suspend fun showAbout(versionName: String) { withContext(Dispatchers.Main) { val binding = DesignAboutBinding.inflate(context.layoutInflater).apply { @@ -83,9 +121,41 @@ class MainDesign(context: Context) : Design(context) { binding.colorClashStarted = context.resolveThemedColor(com.google.android.material.R.attr.colorPrimary) binding.colorClashStopped = context.resolveThemedColor(R.attr.colorClashStopped) + + if (context.resources.configuration.uiMode and Configuration.UI_MODE_TYPE_MASK == + Configuration.UI_MODE_TYPE_TELEVISION + ) { + binding.root.findViewById(R.id.tv_navigation_bar)?.apply { + setActiveTab(TvNavigationBar.Tab.Home) + proxyEnabled = binding.clashRunning + setNextFocusDown(TvNavigationBar.Tab.Home, R.id.tv_status) + setNextFocusDown(TvNavigationBar.Tab.Proxy, R.id.tv_proxies) + setNextFocusDown(TvNavigationBar.Tab.Profiles, R.id.tv_profiles) + setNextFocusDown(TvNavigationBar.Tab.Logs, R.id.tv_mode_card) + setNextFocusDown(TvNavigationBar.Tab.Settings, R.id.tv_mode_card) + setNextFocusDown(TvNavigationBar.Tab.Help, R.id.tv_mode_card) + setNextFocusDown(TvNavigationBar.Tab.About, R.id.tv_mode_card) + onTabSelected = { tab -> + request( + when (tab) { + TvNavigationBar.Tab.Home -> Request.OpenHome + TvNavigationBar.Tab.Proxy -> Request.OpenProxy + TvNavigationBar.Tab.Profiles -> Request.OpenProfiles + TvNavigationBar.Tab.Logs -> Request.OpenLogs + TvNavigationBar.Tab.Settings -> Request.OpenSettings + TvNavigationBar.Tab.Help -> Request.OpenHelp + TvNavigationBar.Tab.About -> Request.OpenAbout + }, + ) + } + } + binding.root.post { + binding.root.findViewById(R.id.tv_status)?.requestFocus() + } + } } fun request(request: Request) { requests.trySend(request) } -} \ No newline at end of file +} 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 index 0b75496dc9..13206b4ffc 100644 --- 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 @@ -1,6 +1,8 @@ package com.github.kr328.clash.design.view import android.content.Context +import android.content.res.Configuration +import android.graphics.Rect import android.graphics.drawable.Drawable import android.util.AttributeSet import androidx.annotation.AttrRes @@ -14,6 +16,8 @@ class LargeActionCard @JvmOverloads constructor( attributeSet: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0 ) : MaterialCardView(context, attributeSet, defStyleAttr) { + private val television = resources.configuration.uiMode and Configuration.UI_MODE_TYPE_MASK == + Configuration.UI_MODE_TYPE_TELEVISION private val binding = ComponentLargeActionLabelBinding .inflate(context.layoutInflater, this, true) @@ -62,4 +66,19 @@ class LargeActionCard @JvmOverloads constructor( 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 + + override fun onFocusChanged(gainFocus: Boolean, direction: Int, previouslyFocusedRect: Rect?) { + super.onFocusChanged(gainFocus, direction, previouslyFocusedRect) + if (!television) return + + val scale = if (gainFocus) 1.012f else 1f + strokeColor = context.resolveThemedColor(com.google.android.material.R.attr.colorPrimary) + strokeWidth = if (gainFocus) context.getPixels(R.dimen.tv_focus_stroke) else 0 + animate() + .scaleX(scale) + .scaleY(scale) + .translationZ(if (gainFocus) context.getPixels(R.dimen.tv_focus_elevation).toFloat() else 0f) + .setDuration(120L) + .start() + } +} 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 index 4dd43432e0..ba127641d3 100644 --- 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 @@ -1,6 +1,8 @@ package com.github.kr328.clash.design.view import android.content.Context +import android.content.res.Configuration +import android.graphics.Rect import android.graphics.drawable.Drawable import android.util.AttributeSet import android.view.View @@ -19,6 +21,8 @@ class LargeActionLabel @JvmOverloads constructor( @AttrRes defStyleAttr: Int = 0, @StyleRes defStyleRes: Int = 0 ) : FrameLayout(context, attributeSet, defStyleAttr, defStyleRes) { + private val television = resources.configuration.uiMode and Configuration.UI_MODE_TYPE_MASK == + Configuration.UI_MODE_TYPE_TELEVISION private val binding = ComponentLargeActionLabelBinding .inflate(context.layoutInflater, this, true) @@ -72,4 +76,18 @@ class LargeActionLabel @JvmOverloads constructor( } } } -} \ No newline at end of file + + override fun onFocusChanged(gainFocus: Boolean, direction: Int, previouslyFocusedRect: Rect?) { + super.onFocusChanged(gainFocus, direction, previouslyFocusedRect) + if (!television) return + + val scale = if (gainFocus) 1.01f else 1f + alpha = if (gainFocus) 1f else 0.88f + animate() + .scaleX(scale) + .scaleY(scale) + .translationZ(if (gainFocus) context.resources.getDimension(R.dimen.tv_focus_elevation) else 0f) + .setDuration(120L) + .start() + } +} diff --git a/design/src/main/java/com/github/kr328/clash/design/view/TvActivityShell.kt b/design/src/main/java/com/github/kr328/clash/design/view/TvActivityShell.kt new file mode 100644 index 0000000000..470210b5a4 --- /dev/null +++ b/design/src/main/java/com/github/kr328/clash/design/view/TvActivityShell.kt @@ -0,0 +1,50 @@ +package com.github.kr328.clash.design.view + +import android.content.Context +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.LinearLayout +import com.github.kr328.clash.design.databinding.ComponentTvActivityShellBinding +import com.github.kr328.clash.design.util.layoutInflater + +class TvActivityShell(context: Context) : LinearLayout(context) { + private val binding = ComponentTvActivityShellBinding + .inflate(context.layoutInflater, this, true) + + val navigationBar: TvNavigationBar + get() = binding.navigationBar + + init { + orientation = VERTICAL + clipChildren = false + } + + fun setContent(view: View) { + binding.contentContainer.removeAllViews() + binding.contentContainer.addView( + view, + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT, + ), + ) + } + + fun focusFirstContent() { + binding.contentContainer.post { + binding.contentContainer.firstFocusableDescendant()?.requestFocus() + } + } + + private fun View.firstFocusableDescendant(): View? { + if (visibility != View.VISIBLE || !isEnabled) return null + if (isFocusable) return this + if (this !is ViewGroup) return null + + for (index in 0 until childCount) { + getChildAt(index).firstFocusableDescendant()?.let { return it } + } + return null + } +} diff --git a/design/src/main/java/com/github/kr328/clash/design/view/TvNavigationBar.kt b/design/src/main/java/com/github/kr328/clash/design/view/TvNavigationBar.kt new file mode 100644 index 0000000000..fe40af882f --- /dev/null +++ b/design/src/main/java/com/github/kr328/clash/design/view/TvNavigationBar.kt @@ -0,0 +1,66 @@ +package com.github.kr328.clash.design.view + +import android.content.Context +import android.util.AttributeSet +import android.widget.LinearLayout +import com.github.kr328.clash.design.databinding.ComponentTvNavigationBarBinding +import com.github.kr328.clash.design.util.layoutInflater + +class TvNavigationBar @JvmOverloads constructor( + context: Context, + attributeSet: AttributeSet? = null, +) : LinearLayout(context, attributeSet) { + enum class Tab { + Home, + Proxy, + Profiles, + Logs, + Settings, + Help, + About, + } + + private val binding = ComponentTvNavigationBarBinding + .inflate(context.layoutInflater, this, true) + + private val items by lazy { + mapOf( + Tab.Home to binding.tvNavHome, + Tab.Proxy to binding.tvNavProxy, + Tab.Profiles to binding.tvNavProfiles, + Tab.Logs to binding.tvNavLogs, + Tab.Settings to binding.tvNavSettings, + Tab.Help to binding.tvNavHelp, + Tab.About to binding.tvNavAbout, + ) + } + + var onTabSelected: ((Tab) -> Unit)? = null + + var proxyEnabled: Boolean + get() = binding.tvNavProxy.isEnabled + set(value) { + binding.tvNavProxy.isEnabled = value + binding.tvNavProxy.alpha = if (value) 1f else 0.45f + } + + init { + orientation = HORIZONTAL + clipChildren = false + items.forEach { (tab, view) -> + view.setOnClickListener { onTabSelected?.invoke(tab) } + } + } + + fun setActiveTab(tab: Tab) { + items.forEach { (itemTab, view) -> view.isSelected = itemTab == tab } + } + + fun setNextFocusDown(tab: Tab, viewId: Int) { + items[tab]?.nextFocusDownId = viewId + } + + fun focus(tab: Tab) { + items[tab]?.requestFocus() + } +} diff --git a/design/src/main/java/com/github/kr328/clash/design/view/TvNavigationItem.kt b/design/src/main/java/com/github/kr328/clash/design/view/TvNavigationItem.kt new file mode 100644 index 0000000000..20ae2997b7 --- /dev/null +++ b/design/src/main/java/com/github/kr328/clash/design/view/TvNavigationItem.kt @@ -0,0 +1,78 @@ +package com.github.kr328.clash.design.view + +import android.content.Context +import android.content.res.ColorStateList +import android.graphics.Rect +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import androidx.annotation.AttrRes +import androidx.core.content.ContextCompat +import com.github.kr328.clash.design.R +import com.github.kr328.clash.design.databinding.ComponentTvNavigationItemBinding +import com.github.kr328.clash.design.util.layoutInflater +import com.google.android.material.card.MaterialCardView + +class TvNavigationItem @JvmOverloads constructor( + context: Context, + attributeSet: AttributeSet? = null, + @AttrRes defStyleAttr: Int = 0, +) : MaterialCardView(context, attributeSet, defStyleAttr) { + private val binding = ComponentTvNavigationItemBinding + .inflate(context.layoutInflater, this, true) + + var text: CharSequence? + get() = binding.textView.text + set(value) { + binding.textView.text = value + } + + var icon: Drawable? + get() = binding.iconView.drawable + set(value) { + binding.iconView.setImageDrawable(value) + } + + init { + isFocusable = true + isClickable = true + radius = resources.getDimension(R.dimen.tv_dashboard_radius) + strokeWidth = resources.getDimensionPixelSize(R.dimen.tv_dashboard_stroke) + cardElevation = 0f + setCardBackgroundColor(ColorStateList.valueOf(ContextCompat.getColor(context, R.color.tv_transparent))) + setStrokeColor(ContextCompat.getColorStateList(context, R.color.tv_nav_stroke)) + setRippleColor(ColorStateList.valueOf(ContextCompat.getColor(context, R.color.tv_accent_ripple))) + + context.theme.obtainStyledAttributes( + attributeSet, + R.styleable.LargeActionLabel, + defStyleAttr, + 0, + ).apply { + try { + icon = getDrawable(R.styleable.LargeActionLabel_icon) + text = getString(R.styleable.LargeActionLabel_text) + } finally { + recycle() + } + } + } + + override fun drawableStateChanged() { + super.drawableStateChanged() + setCardBackgroundColor( + if (isFocused || isSelected) ContextCompat.getColor(context, R.color.tv_accent_surface) + else ContextCompat.getColor(context, R.color.tv_transparent), + ) + alpha = if (isEnabled) 1f else 0.42f + } + + override fun onFocusChanged(gainFocus: Boolean, direction: Int, previouslyFocusedRect: Rect?) { + super.onFocusChanged(gainFocus, direction, previouslyFocusedRect) + animate() + .scaleX(if (gainFocus) 1.012f else 1f) + .scaleY(if (gainFocus) 1.012f else 1f) + .translationZ(if (gainFocus) resources.getDimension(R.dimen.tv_focus_elevation) else 0f) + .setDuration(120L) + .start() + } +} diff --git a/design/src/main/res/animator/tv_dashboard_focus.xml b/design/src/main/res/animator/tv_dashboard_focus.xml new file mode 100644 index 0000000000..e77b576c2b --- /dev/null +++ b/design/src/main/res/animator/tv_dashboard_focus.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + diff --git a/design/src/main/res/color/tv_dashboard_card_background.xml b/design/src/main/res/color/tv_dashboard_card_background.xml new file mode 100644 index 0000000000..9688ddeecc --- /dev/null +++ b/design/src/main/res/color/tv_dashboard_card_background.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/design/src/main/res/color/tv_dashboard_card_stroke.xml b/design/src/main/res/color/tv_dashboard_card_stroke.xml new file mode 100644 index 0000000000..e05adc176f --- /dev/null +++ b/design/src/main/res/color/tv_dashboard_card_stroke.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/design/src/main/res/color/tv_nav_stroke.xml b/design/src/main/res/color/tv_nav_stroke.xml new file mode 100644 index 0000000000..38005831ec --- /dev/null +++ b/design/src/main/res/color/tv_nav_stroke.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/design/src/main/res/drawable/ic_clash_verge_macos.xml b/design/src/main/res/drawable/ic_clash_verge_macos.xml new file mode 100644 index 0000000000..343a572c40 --- /dev/null +++ b/design/src/main/res/drawable/ic_clash_verge_macos.xml @@ -0,0 +1,10 @@ + + + + diff --git a/design/src/main/res/drawable/ic_tv_home.xml b/design/src/main/res/drawable/ic_tv_home.xml new file mode 100644 index 0000000000..dc8d27adbc --- /dev/null +++ b/design/src/main/res/drawable/ic_tv_home.xml @@ -0,0 +1,10 @@ + + + + diff --git a/design/src/main/res/drawable/tv_icon_tile_background.xml b/design/src/main/res/drawable/tv_icon_tile_background.xml new file mode 100644 index 0000000000..07f532fe97 --- /dev/null +++ b/design/src/main/res/drawable/tv_icon_tile_background.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/design/src/main/res/drawable/tv_mode_chip_background.xml b/design/src/main/res/drawable/tv_mode_chip_background.xml new file mode 100644 index 0000000000..2872fdaf8f --- /dev/null +++ b/design/src/main/res/drawable/tv_mode_chip_background.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/design/src/main/res/drawable/tv_sidebar_background.xml b/design/src/main/res/drawable/tv_sidebar_background.xml new file mode 100644 index 0000000000..ea467f69af --- /dev/null +++ b/design/src/main/res/drawable/tv_sidebar_background.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/design/src/main/res/drawable/tv_status_pill_running.xml b/design/src/main/res/drawable/tv_status_pill_running.xml new file mode 100644 index 0000000000..a3be8e1d24 --- /dev/null +++ b/design/src/main/res/drawable/tv_status_pill_running.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/design/src/main/res/drawable/tv_status_pill_stopped.xml b/design/src/main/res/drawable/tv_status_pill_stopped.xml new file mode 100644 index 0000000000..6c7d561ea1 --- /dev/null +++ b/design/src/main/res/drawable/tv_status_pill_stopped.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/design/src/main/res/drawable/tv_topbar_background.xml b/design/src/main/res/drawable/tv_topbar_background.xml new file mode 100644 index 0000000000..635c978ae2 --- /dev/null +++ b/design/src/main/res/drawable/tv_topbar_background.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/design/src/main/res/layout-television/design_about.xml b/design/src/main/res/layout-television/design_about.xml new file mode 100644 index 0000000000..ec0d94059a --- /dev/null +++ b/design/src/main/res/layout-television/design_about.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + diff --git a/design/src/main/res/layout-television/design_main.xml b/design/src/main/res/layout-television/design_main.xml new file mode 100644 index 0000000000..6c2154d520 --- /dev/null +++ b/design/src/main/res/layout-television/design_main.xml @@ -0,0 +1,617 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/design/src/main/res/layout/component_tv_activity_shell.xml b/design/src/main/res/layout/component_tv_activity_shell.xml new file mode 100644 index 0000000000..ba20492597 --- /dev/null +++ b/design/src/main/res/layout/component_tv_activity_shell.xml @@ -0,0 +1,25 @@ + + + + + + + + + + diff --git a/design/src/main/res/layout/component_tv_navigation_bar.xml b/design/src/main/res/layout/component_tv_navigation_bar.xml new file mode 100644 index 0000000000..08d474d1b8 --- /dev/null +++ b/design/src/main/res/layout/component_tv_navigation_bar.xml @@ -0,0 +1,112 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/design/src/main/res/layout/component_tv_navigation_item.xml b/design/src/main/res/layout/component_tv_navigation_item.xml new file mode 100644 index 0000000000..b01d741cee --- /dev/null +++ b/design/src/main/res/layout/component_tv_navigation_item.xml @@ -0,0 +1,29 @@ + + + + + + + + + + diff --git a/design/src/main/res/values-television/dimens.xml b/design/src/main/res/values-television/dimens.xml new file mode 100644 index 0000000000..b2bd15a6a3 --- /dev/null +++ b/design/src/main/res/values-television/dimens.xml @@ -0,0 +1,19 @@ + + + 18dp + 36dp + 22dp + 80dp + 24dp + 96dp + 7dp + 5dp + 56dp + 64dp + 104dp + 3dp + 10dp + 12dp + 10dp + 2dp + diff --git a/design/src/main/res/values-zh/strings.xml b/design/src/main/res/values-zh/strings.xml index be62f50f54..7b8bf38552 100644 --- a/design/src/main/res/values-zh/strings.xml +++ b/design/src/main/res/values-zh/strings.xml @@ -1,5 +1,30 @@ + 首页 + 一览当前网络状态 + 当前订阅 + 当前节点 + 代理组 · %1$s + VPN 隧道已连接 + 网络控制 + 代理模式 + 流量概览 + 流量统计 + 上传速度 + 下载速度 + 上传量 + 下载量 + 活动连接 + 内存占用 + 已激活配置 + 暂无代理组 + 启动服务后加载节点 + %1$d 个外部资源 + 按确认键停止 VPN 隧道 + 按确认键启动 VPN 隧道 + 流量将按照当前 Mihomo 模式进行分流 + 方向键移动 · 确认键选择 + Android TV · 遥控器优化 为应用配置访问权限 关于 访问控制模式 diff --git a/design/src/main/res/values/colors.xml b/design/src/main/res/values/colors.xml index 27d3e5d148..d19ada7143 100644 --- a/design/src/main/res/values/colors.xml +++ b/design/src/main/res/values/colors.xml @@ -10,4 +10,21 @@ #FFD3D3D3 #FF808080 #FFB00020 + #171923 + #20222E + #262936 + #303D5C + #3F8CFF + #3B82F6 + #243B64 + #333B82F6 + #34D399 + #173F3A + #F59E0B + #F87171 + #F7F8FC + #AEB4C5 + #D8DBE3 + #363947 + #00000000 diff --git a/design/src/main/res/values/dimens.xml b/design/src/main/res/values/dimens.xml index 002d927159..0d19c603f4 100644 --- a/design/src/main/res/values/dimens.xml +++ b/design/src/main/res/values/dimens.xml @@ -48,6 +48,11 @@ 8dp 5dp 85dp + 0dp + 0dp + 0dp + 8dp + 0dp 25dp @@ -84,4 +89,4 @@ 12dp 12dp - \ No newline at end of file + diff --git a/design/src/main/res/values/strings.xml b/design/src/main/res/values/strings.xml index 2fe54fd226..2ee350b0cd 100644 --- a/design/src/main/res/values/strings.xml +++ b/design/src/main/res/values/strings.xml @@ -3,6 +3,31 @@ Clash Meta Alpha Clash Meta for Android Clash Meta for Android Alpha + Android TV · Remote optimized + Home + Your network at a glance + Current subscription + Current node + Proxy group · %1$s + VPN tunnel connected + Network control + Proxy mode + Traffic overview + Traffic statistics + Upload speed + Download speed + Uploaded + Downloaded + Active connections + Memory usage + Active profile + No proxy group + Start the tunnel to load nodes + %1$d providers + Select to stop the VPN tunnel + Select to start the VPN tunnel + Traffic is routed using the current Mihomo mode + Use the D-pad to move · OK to select Stopped Tap to start diff --git a/service/src/main/java/com/github/kr328/clash/service/ClashManager.kt b/service/src/main/java/com/github/kr328/clash/service/ClashManager.kt index aa92b4afe4..2c7e04a0be 100644 --- a/service/src/main/java/com/github/kr328/clash/service/ClashManager.kt +++ b/service/src/main/java/com/github/kr328/clash/service/ClashManager.kt @@ -26,6 +26,18 @@ class ClashManager(private val context: Context) : IClashManager, return Clash.queryTrafficTotal() } + override fun queryTrafficNow(): Long { + return Clash.queryTrafficNow() + } + + override fun queryActiveConnections(): Int { + return Clash.queryActiveConnections() + } + + override fun queryMemory(): Long { + return Clash.queryMemory() + } + override fun queryProxyGroupNames(excludeNotSelectable: Boolean): List { return Clash.queryGroupNames(excludeNotSelectable) } @@ -108,4 +120,4 @@ class ClashManager(private val context: Context) : IClashManager, } } } -} \ No newline at end of file +} diff --git a/service/src/main/java/com/github/kr328/clash/service/remote/IClashManager.kt b/service/src/main/java/com/github/kr328/clash/service/remote/IClashManager.kt index f60c2edb17..135fad21bf 100644 --- a/service/src/main/java/com/github/kr328/clash/service/remote/IClashManager.kt +++ b/service/src/main/java/com/github/kr328/clash/service/remote/IClashManager.kt @@ -8,6 +8,9 @@ import com.github.kr328.kaidl.BinderInterface interface IClashManager { fun queryTunnelState(): TunnelState fun queryTrafficTotal(): Long + fun queryTrafficNow(): Long + fun queryActiveConnections(): Int + fun queryMemory(): Long fun queryProxyGroupNames(excludeNotSelectable: Boolean): List fun queryProxyGroup(name: String, proxySort: ProxySort): ProxyGroup fun queryConfiguration(): UiConfiguration @@ -23,4 +26,4 @@ interface IClashManager { fun clearOverride(slot: Clash.OverrideSlot) fun setLogObserver(observer: ILogObserver?) -} \ No newline at end of file +}