feat: Выбор между классической и новой иконкой приложения

This commit is contained in:
xtclovver 2026-05-07 23:02:54 +03:00
parent e16a78fd5e
commit e7d76f9b4b
20 changed files with 339 additions and 9 deletions

View file

@ -144,6 +144,21 @@
</intent-filter>
</activity-alias>
<activity-alias
android:name=".LauncherClassic"
android:targetActivity=".MainActivity"
android:enabled="false"
android:exported="true"
android:icon="@mipmap/ic_launcher_classic"
android:roundIcon="@mipmap/ic_launcher_classic_round"
android:label="@string/app_name">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity-alias>
<activity
android:name=".SettingsActivity"
android:exported="false" />

View file

@ -5,6 +5,7 @@ internal enum class LauncherIconVariant(
val prefValue: String,
) {
ORIGINAL("com.notcvnt.rknhardering.LauncherOriginal", "original"),
CLASSIC("com.notcvnt.rknhardering.LauncherClassic", "classic"),
PROTANOPIA("com.notcvnt.rknhardering.LauncherProtanopia", "protanopia"),
DEUTERANOPIA("com.notcvnt.rknhardering.LauncherDeuteranopia", "deuteranopia"),
TRITANOPIA("com.notcvnt.rknhardering.LauncherTritanopia", "tritanopia"),
@ -23,5 +24,14 @@ internal enum class LauncherIconVariant(
ColorVisionMode.ACHROMATOPSIA -> MONOCHROME
}
}
fun resolve(
iconStyleClassic: Boolean,
mode: ColorVisionMode,
redGreenSub: LauncherIconVariant?,
): LauncherIconVariant {
if (iconStyleClassic) return CLASSIC
return fromCvd(mode, redGreenSub)
}
}
}

View file

@ -146,7 +146,8 @@ internal class SettingsAccessibilityFragment : Fragment(R.layout.fragment_settin
}
private fun applyLauncherIcon(root: View, mode: ColorVisionMode) {
val target = LauncherIconVariant.fromCvd(mode, redGreenSubVariantFromPrefs())
val iconStyleClassic = prefs.getString(SettingsPrefs.PREF_ICON_STYLE, "new") == "classic"
val target = LauncherIconVariant.resolve(iconStyleClassic, mode, redGreenSubVariantFromPrefs())
val message = if (LauncherIconManager.apply(requireContext(), target)) {
R.string.settings_color_vision_icon_changed_warning
} else {

View file

@ -6,20 +6,23 @@ import android.view.View
import androidx.core.content.edit
import androidx.fragment.app.Fragment
import com.google.android.material.chip.ChipGroup
import com.google.android.material.snackbar.Snackbar
internal class SettingsAppearanceFragment : Fragment(R.layout.fragment_settings_appearance) {
private lateinit var prefs: SharedPreferences
private lateinit var chipGroupTheme: ChipGroup
private lateinit var chipGroupIconStyle: ChipGroup
private lateinit var chipGroupLanguage: ChipGroup
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
prefs = AppUiSettings.prefs(requireContext())
chipGroupTheme = view.findViewById(R.id.chipGroupTheme)
chipGroupIconStyle = view.findViewById(R.id.chipGroupIconStyle)
chipGroupLanguage = view.findViewById(R.id.chipGroupLanguage)
loadSettings()
setupListeners()
setupListeners(view)
}
private fun loadSettings() {
@ -32,6 +35,11 @@ internal class SettingsAppearanceFragment : Fragment(R.layout.fragment_settings_
},
)
val iconStyle = prefs.getString(SettingsPrefs.PREF_ICON_STYLE, "new") ?: "new"
chipGroupIconStyle.check(
if (iconStyle == "classic") R.id.chipIconStyleClassic else R.id.chipIconStyleNew,
)
val language = prefs.getString(SettingsPrefs.PREF_LANGUAGE, "").orEmpty()
chipGroupLanguage.check(
when (language) {
@ -44,7 +52,7 @@ internal class SettingsAppearanceFragment : Fragment(R.layout.fragment_settings_
)
}
private fun setupListeners() {
private fun setupListeners(view: View) {
chipGroupTheme.setOnCheckedStateChangeListener { _, checkedIds ->
if (checkedIds.isEmpty()) return@setOnCheckedStateChangeListener
val value = when (checkedIds.first()) {
@ -56,6 +64,14 @@ internal class SettingsAppearanceFragment : Fragment(R.layout.fragment_settings_
SettingsActivity.applyTheme(value)
}
chipGroupIconStyle.setOnCheckedStateChangeListener { _, checkedIds ->
if (checkedIds.isEmpty()) return@setOnCheckedStateChangeListener
val isClassic = checkedIds.first() == R.id.chipIconStyleClassic
val value = if (isClassic) "classic" else "new"
prefs.edit { putString(SettingsPrefs.PREF_ICON_STYLE, value) }
applyIconStyle(view, isClassic)
}
chipGroupLanguage.setOnCheckedStateChangeListener { _, checkedIds ->
if (checkedIds.isEmpty()) return@setOnCheckedStateChangeListener
val value = when (checkedIds.first()) {
@ -69,4 +85,26 @@ internal class SettingsAppearanceFragment : Fragment(R.layout.fragment_settings_
AppUiSettings.applyLanguage(value)
}
}
private fun applyIconStyle(view: View, isClassic: Boolean) {
val rawMode = prefs.getString(SettingsPrefs.PREF_COLOR_VISION_MODE, null)
val mode = ColorVisionMode.fromPref(rawMode)
val redGreenSub = redGreenSubVariantFromPrefs()
val target = LauncherIconVariant.resolve(isClassic, mode, redGreenSub)
val message = if (LauncherIconManager.apply(requireContext(), target)) {
R.string.settings_color_vision_icon_changed_warning
} else {
R.string.settings_color_vision_icon_change_failed
}
Snackbar.make(view, message, Snackbar.LENGTH_LONG).show()
}
private fun redGreenSubVariantFromPrefs(): LauncherIconVariant? {
val unlocked = prefs.getBoolean(SettingsPrefs.PREF_EASTER_EGG_PROTANOPIA_UNLOCKED, false)
if (!unlocked) return null
return when (prefs.getString(SettingsPrefs.PREF_RED_GREEN_ICON_VARIANT, LauncherIconVariant.DEUTERANOPIA.prefValue)) {
LauncherIconVariant.PROTANOPIA.prefValue -> LauncherIconVariant.PROTANOPIA
else -> LauncherIconVariant.DEUTERANOPIA
}
}
}

View file

@ -28,4 +28,5 @@ internal object SettingsPrefs {
const val PREF_EASTER_EGG_PROTANOPIA_UNLOCKED = "pref_easter_egg_protanopia_unlocked"
const val PREF_RED_GREEN_ICON_VARIANT = "pref_red_green_icon_variant"
const val PREF_ICON_MIGRATION_DONE = "pref_icon_migration_done"
const val PREF_ICON_STYLE = "pref_icon_style"
}

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#1A1A26"
android:pathData="M0,0h108v108h-108z" />
</vector>

View file

@ -0,0 +1,66 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#20203A"
android:pathData="M54,54m-28,0a28,28 0,1 1,56 0a28,28 0,1 1,-56 0" />
<path
android:fillColor="#00000000"
android:pathData="M54,54m-28,0a28,28 0,1 1,56 0a28,28 0,1 1,-56 0"
android:strokeColor="#C8C5D0"
android:strokeWidth="3" />
<path
android:fillColor="#00000000"
android:pathData="M54,54m-21,0a21,21 0,1 1,42 0a21,21 0,1 1,-42 0"
android:strokeColor="#B3261E"
android:strokeWidth="1.2"
android:strokeAlpha="0.25" />
<path
android:fillColor="#00000000"
android:pathData="M54,38 A16,16 0 0,1 70,54"
android:strokeColor="#B3261E"
android:strokeWidth="2"
android:strokeLineCap="round" />
<path
android:fillColor="#00000000"
android:pathData="M70,54 A16,16 0 0,1 54,70"
android:strokeColor="#B3261E"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeAlpha="0.4" />
<path
android:fillColor="#00000000"
android:pathData="M54,70 A16,16 0 0,1 38,54"
android:strokeColor="#B3261E"
android:strokeWidth="2"
android:strokeLineCap="round" />
<path
android:fillColor="#00000000"
android:pathData="M38,54 A16,16 0 0,1 54,38"
android:strokeColor="#B3261E"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeAlpha="0.4" />
<path
android:fillColor="#B3261E"
android:pathData="M54,54m-6,0a6,6 0,1 1,12 0a6,6 0,1 1,-12 0" />
<path
android:fillColor="#FFDAD6"
android:pathData="M54,54m-3,0a3,3 0,1 1,6 0a3,3 0,1 1,-6 0" />
<path
android:fillColor="#00000000"
android:pathData="M73.8,73.8 L86,86"
android:strokeColor="#C8C5D0"
android:strokeWidth="5.5"
android:strokeLineCap="round" />
</vector>

View file

@ -65,6 +65,54 @@
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
app:cardBackgroundColor="?attr/colorSurfaceContainer"
app:strokeColor="?attr/colorOutlineVariant"
app:strokeWidth="1dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings_icon_style"
android:textColor="?android:attr/textColorPrimary"
android:textSize="15sp"
android:textStyle="bold" />
<com.google.android.material.chip.ChipGroup
android:id="@+id/chipGroupIconStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
app:selectionRequired="true"
app:singleSelection="true">
<com.google.android.material.chip.Chip
android:id="@+id/chipIconStyleNew"
style="@style/Widget.Material3.Chip.Filter"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings_icon_style_new" />
<com.google.android.material.chip.Chip
android:id="@+id/chipIconStyleClassic"
style="@style/Widget.Material3.Chip.Filter"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings_icon_style_classic" />
</com.google.android.material.chip.ChipGroup>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_classic_background" />
<foreground android:drawable="@drawable/ic_launcher_classic_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_classic_foreground" />
</adaptive-icon>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_classic_background" />
<foreground android:drawable="@drawable/ic_launcher_classic_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_classic_foreground" />
</adaptive-icon>

View file

@ -84,6 +84,9 @@
<string name="settings_dns_bootstrap_hint">8.8.8.8, 8.8.4.4</string>
<string name="settings_privacy_mode">Приватный режим</string>
<string name="settings_privacy_mode_desc">IP отображается как 185.22.*.*</string>
<string name="settings_icon_style">Иконка приложения</string>
<string name="settings_icon_style_classic">Классическая</string>
<string name="settings_icon_style_new">Новая</string>
<string name="settings_theme">Тема</string>
<string name="settings_theme_light">Светлая</string>
<string name="settings_theme_dark">Тёмная</string>
@ -307,7 +310,7 @@
<string name="narrative_value_call_transport_proxy_assisted_udp_stun">UDP STUN через proxy</string>
<!-- VerdictNarrative: reason rows -->
<string name="narrative_reason_xray_api">На устройстве найден локальный Xray API, из которого читаются outbound-адреса.</string>
<string name="narrative_reason_xray_api">На устройстве найден локальный Xray API.</string>
<string name="narrative_reason_split_tunnel">Прямой IP и IP через локальный proxy различаются.</string>
<string name="narrative_reason_vpn_gateway_leak">При активном VPN приложение смогло выйти через non-VPN сеть.</string>
<string name="narrative_reason_vpn_network_binding">Приложение смогло использовать объект VPN Network отдельно от обычной сети.</string>
@ -480,6 +483,9 @@
<string name="checker_bypass_open_proxy_review_suffix"> (требует подтверждения обхода)</string>
<string name="checker_bypass_no_xray">Xray gRPC API: не обнаружен</string>
<string name="checker_bypass_xray_api">Xray gRPC API: %1$s</string>
<string name="checker_bypass_xray_api_stats_only">Xray gRPC StatsService: %1$s</string>
<string name="checker_bypass_xray_handler_unavailable">HandlerService/ListOutbounds недоступен; outbound-адреса не раскрыты.</string>
<string name="checker_bypass_xray_stats_sample">Счётчики StatsService: %1$s</string>
<plurals name="checker_bypass_extra_outbounds">
<item quantity="one">&#160;&#160;&#8230;ещё %1$d аутбаунд</item>
<item quantity="few">&#160;&#160;&#8230;ещё %1$d аутбаунда</item>

View file

@ -83,6 +83,9 @@
<string name="settings_dns_bootstrap_hint">8.8.8.8, 8.8.4.4</string>
<string name="settings_privacy_mode">Privacy mode</string>
<string name="settings_privacy_mode_desc">IP is shown as 185.22.*.*</string>
<string name="settings_icon_style">App icon</string>
<string name="settings_icon_style_classic">Classic</string>
<string name="settings_icon_style_new">New</string>
<string name="settings_theme">Theme</string>
<string name="settings_theme_light">Light</string>
<string name="settings_theme_dark">Dark</string>
@ -304,7 +307,7 @@
<string name="narrative_value_call_transport_proxy_assisted_udp_stun">proxy-assisted UDP STUN</string>
<!-- VerdictNarrative: reason rows -->
<string name="narrative_reason_xray_api">A local Xray API was found on the device and exposes outbound addresses.</string>
<string name="narrative_reason_xray_api">A local Xray API was found on the device.</string>
<string name="narrative_reason_split_tunnel">The direct IP and the IP via the local proxy differ.</string>
<string name="narrative_reason_vpn_gateway_leak">With VPN active, the app was able to access the non-VPN network.</string>
<string name="narrative_reason_vpn_network_binding">The app was able to use the VPN Network object separately from the regular network.</string>
@ -477,6 +480,9 @@
<string name="checker_bypass_open_proxy_review_suffix"> (requires bypass confirmation)</string>
<string name="checker_bypass_no_xray">Xray gRPC API: not found</string>
<string name="checker_bypass_xray_api">Xray gRPC API: %1$s</string>
<string name="checker_bypass_xray_api_stats_only">Xray gRPC StatsService: %1$s</string>
<string name="checker_bypass_xray_handler_unavailable">HandlerService/ListOutbounds is unavailable; outbound addresses were not exposed.</string>
<string name="checker_bypass_xray_stats_sample">StatsService counters: %1$s</string>
<plurals name="checker_bypass_extra_outbounds">
<item quantity="one">&#160;&#160;&#8230;%1$d more outbound</item>
<item quantity="other">&#160;&#160;&#8230;%1$d more outbounds</item>

View file

@ -79,6 +79,20 @@ class LauncherIconManagerTest {
assertEquals(LauncherIconVariant.ORIGINAL, LauncherIconManager.current(context))
}
@Test
fun `apply enables CLASSIC and disables all other variants`() {
assertTrue(LauncherIconManager.apply(context, LauncherIconVariant.CLASSIC))
LauncherIconVariant.entries.forEach { variant ->
val expected = if (variant == LauncherIconVariant.CLASSIC) {
PackageManager.COMPONENT_ENABLED_STATE_ENABLED
} else {
PackageManager.COMPONENT_ENABLED_STATE_DISABLED
}
assertEquals(expected, componentState(variant))
}
}
@Test
fun `apply returns false when package manager rejects alias toggle`() {
LauncherIconManager.setComponentEnabledSettingForTests = { _, _, _ ->

View file

@ -36,4 +36,60 @@ class LauncherIconVariantTest {
LauncherIconVariant.fromCvd(ColorVisionMode.ACHROMATOPSIA, LauncherIconVariant.PROTANOPIA),
)
}
@Test
fun `resolve returns CLASSIC when icon style is classic regardless of CVD`() {
assertEquals(
LauncherIconVariant.CLASSIC,
LauncherIconVariant.resolve(
iconStyleClassic = true,
mode = ColorVisionMode.OFF,
redGreenSub = null,
),
)
assertEquals(
LauncherIconVariant.CLASSIC,
LauncherIconVariant.resolve(
iconStyleClassic = true,
mode = ColorVisionMode.RED_GREEN,
redGreenSub = LauncherIconVariant.DEUTERANOPIA,
),
)
assertEquals(
LauncherIconVariant.CLASSIC,
LauncherIconVariant.resolve(
iconStyleClassic = true,
mode = ColorVisionMode.ACHROMATOPSIA,
redGreenSub = null,
),
)
}
@Test
fun `resolve delegates to fromCvd when icon style is new`() {
assertEquals(
LauncherIconVariant.ORIGINAL,
LauncherIconVariant.resolve(
iconStyleClassic = false,
mode = ColorVisionMode.OFF,
redGreenSub = null,
),
)
assertEquals(
LauncherIconVariant.DEUTERANOPIA,
LauncherIconVariant.resolve(
iconStyleClassic = false,
mode = ColorVisionMode.RED_GREEN,
redGreenSub = null,
),
)
assertEquals(
LauncherIconVariant.MONOCHROME,
LauncherIconVariant.resolve(
iconStyleClassic = false,
mode = ColorVisionMode.ACHROMATOPSIA,
redGreenSub = null,
),
)
}
}

View file

@ -113,6 +113,20 @@ class SettingsAccessibilityFragmentTest {
assertEquals(LauncherIconVariant.MONOCHROME, LauncherIconManager.current(context))
}
@Test
fun `changing CVD mode when classic icon style is set does not change launcher icon`() {
AppUiSettings.prefs(context).edit {
putString(SettingsPrefs.PREF_ICON_STYLE, "classic")
}
LauncherIconManager.apply(context, LauncherIconVariant.CLASSIC)
val scenario = accessibilityFragment()
scenario.root.findViewById<Chip>(R.id.chipColorVisionRedGreen).performClick()
shadowOf(Looper.getMainLooper()).idle()
assertEquals(LauncherIconVariant.CLASSIC, LauncherIconManager.current(context))
}
private fun accessibilityFragment(): AccessibilityScenario {
val activity = Robolectric.buildActivity(SettingsActivity::class.java).setup().get()
activity.supportFragmentManager.beginTransaction()

View file

@ -1 +1,3 @@
Launcher icon now follows the selected color-vision accessibility mode.
Added SNITCH check (β): detects IP and DNS leak signals via a redirect chain.
Redesigned app icon; icon now follows the selected color-vision accessibility mode.
Added classic icon option in Appearance settings.

View file

@ -1 +1,3 @@
نماد برنامه اکنون از حالت دید رنگ انتخاب‌شده پیروی می‌کند.
بررسی SNITCH (β) اضافه شد: نشت IP و DNS را از طریق زنجیره ریدایرکت شناسایی می‌کند.
طراحی مجدد نماد برنامه؛ نماد اکنون از حالت دید رنگ انتخاب‌شده پیروی می‌کند.
گزینه نماد کلاسیک در تنظیمات ظاهر اضافه شد.

View file

@ -1 +1,3 @@
Иконка приложения теперь следует выбранному режиму цветового зрения.
Добавлена SNITCH-проверка (β): обнаруживает утечки IP и DNS через цепочку редиректов.
Редизайн иконки приложения; иконка теперь следует выбранному режиму цветового зрения.
В настройках оформления добавлен выбор классической иконки.

View file

@ -1 +1,3 @@
应用图标现在会跟随所选的色觉辅助模式。
新增 SNITCH 检测(β):通过重定向链检测 IP 和 DNS 泄漏。
重新设计应用图标;图标现在会跟随所选的色觉辅助模式。
在外观设置中新增经典图标选项。

View file

@ -0,0 +1,25 @@
syntax = "proto3";
package xray.app.stats.command;
option csharp_namespace = "Xray.App.Stats.Command";
option go_package = "github.com/xtls/xray-core/app/stats/command";
option java_package = "com.xray.app.stats.command";
option java_multiple_files = true;
message Stat {
string name = 1;
int64 value = 2;
}
message QueryStatsRequest {
string pattern = 1;
bool reset = 2;
}
message QueryStatsResponse {
repeated Stat stat = 1;
}
service StatsService {
rpc QueryStats(QueryStatsRequest) returns (QueryStatsResponse) {}
}