From 2f1f91571e15d3f4413103cc2660c5a110430267 Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Sun, 21 Jun 2026 15:01:18 -0400 Subject: [PATCH 1/6] feat(theme): migrate to Material 3 Move the theme stack from Theme.MaterialComponents.* to Theme.Material3.*: - base Theme.SagerNet (values + values-v26), Theme.SagerNet.Dialog, Theme.Start - widget parents: CardView -> Material3.CardView.Filled, Button, TextInputLayout OutlinedBox, PopupMenu(.Overflow), BottomSheet.Modal, BottomSheetDialog overlay, ShapeAppearance Small/Medium. Custom style *names* are unchanged (they're identifiers). - Material 1.12.0 accepts the existing M2 attrs (colorAccent/colorPrimaryDark/etc.), and the ~25 per-theme color variants + Dracula keep working. Fix a latent crash surfaced by M3: M3 wraps a View's Context in a ContextThemeWrapper, so / in StatsBar, ServiceButton and Dialogs threw ClassCastException on launch. Added Context.unwrap() (walks the ContextWrapper.baseContext chain) and used it at those sites. Verified on-device: app launches; main list, Settings, profile Edit + input dialog, QR generation (MaterialAlertDialog), and the remove-confirm alert all render with no inflation or cast crashes; lintVitalOssRelease passes. --- .../io/nekohasekai/sagernet/ktx/Dialogs.kt | 2 +- .../java/io/nekohasekai/sagernet/ktx/Utils.kt | 15 +++++++++ .../sagernet/widget/ServiceButton.kt | 5 +-- .../nekohasekai/sagernet/widget/StatsBar.kt | 4 +-- app/src/main/res/values-v26/themes.xml | 4 +-- app/src/main/res/values/themes.xml | 31 ++++++++++--------- 6 files changed, 40 insertions(+), 21 deletions(-) diff --git a/app/src/main/java/io/nekohasekai/sagernet/ktx/Dialogs.kt b/app/src/main/java/io/nekohasekai/sagernet/ktx/Dialogs.kt index 3dbc532d5..24e5a40a1 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ktx/Dialogs.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ktx/Dialogs.kt @@ -18,7 +18,7 @@ fun Fragment.alert(text: String) = requireContext().alert(text) fun AlertDialog.tryToShow() { try { - val activity = context as Activity + val activity = context.unwrap() if (!activity.isFinishing) { show() } diff --git a/app/src/main/java/io/nekohasekai/sagernet/ktx/Utils.kt b/app/src/main/java/io/nekohasekai/sagernet/ktx/Utils.kt index 8c43f98b8..2906e4e56 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ktx/Utils.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ktx/Utils.kt @@ -63,6 +63,21 @@ import kotlin.reflect.KProperty0 fun String?.blankAsNull(): String? = if (isNullOrBlank()) null else this +/** + * Resolve the hosting object of type [T] (e.g. the Activity / LifecycleOwner) from a View's + * Context. Material 3 themes wrap the view context in a ContextThemeWrapper, so a direct + * `context as Activity` cast throws ClassCastException; walk the ContextWrapper.baseContext + * chain instead. + */ +inline fun Context.unwrap(): T { + var ctx: Context? = this + while (ctx != null) { + if (ctx is T) return ctx + ctx = (ctx as? android.content.ContextWrapper)?.baseContext + } + error("Could not unwrap ${T::class.java.simpleName} from context") +} + inline fun Iterable.forEachTry(action: (T) -> Unit) { var result: Exception? = null for (element in this) try { diff --git a/app/src/main/java/io/nekohasekai/sagernet/widget/ServiceButton.kt b/app/src/main/java/io/nekohasekai/sagernet/widget/ServiceButton.kt index 6a578a823..509841b07 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/widget/ServiceButton.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/widget/ServiceButton.kt @@ -20,6 +20,7 @@ import com.google.android.material.progressindicator.BaseProgressIndicator import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.bg.BaseService import io.nekohasekai.sagernet.ktx.getColorAttr +import io.nekohasekai.sagernet.ktx.unwrap import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -66,11 +67,11 @@ class ServiceButton @JvmOverloads constructor( private val iconConnecting by lazy { AnimatedState(R.drawable.ic_service_connecting) { hideProgress() - delayedAnimation = (context as LifecycleOwner).lifecycleScope.launch { + delayedAnimation = context.unwrap().lifecycleScope.launch { delay(context.resources.getInteger(android.R.integer.config_mediumAnimTime) + 1000L) // Gate the UI mutation on STARTED so a delayed progress reveal doesn't run // while the activity is stopped (the old launchWhenStarted suspended here). - (context as LifecycleOwner).withStarted { + context.unwrap().withStarted { isIndeterminate = true show() } diff --git a/app/src/main/java/io/nekohasekai/sagernet/widget/StatsBar.kt b/app/src/main/java/io/nekohasekai/sagernet/widget/StatsBar.kt index a6cdde97a..c3db54ab2 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/widget/StatsBar.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/widget/StatsBar.kt @@ -122,7 +122,7 @@ class StatsBar @JvmOverloads constructor( } fun changeState(state: BaseService.State) { - val activity = context as MainActivity + val activity = context.unwrap() fun postWhenStarted(what: () -> Unit) = activity.lifecycleScope.launch(Dispatchers.Main) { delay(100L) activity.withStarted { what() } @@ -172,7 +172,7 @@ class StatsBar @JvmOverloads constructor( } fun testConnection() { - val activity = context as MainActivity + val activity = context.unwrap() isEnabled = false // "Testing…" in the testing color. statusText.setTextColor(context.getColorAttr(R.attr.statusTestingColor)) diff --git a/app/src/main/res/values-v26/themes.xml b/app/src/main/res/values-v26/themes.xml index 58a704e87..9041833d1 100644 --- a/app/src/main/res/values-v26/themes.xml +++ b/app/src/main/res/values-v26/themes.xml @@ -1,6 +1,6 @@ - - - - @@ -217,11 +220,11 @@ @anim/popup_exit - - @@ -233,10 +236,10 @@ 0dp 0dp - - @@ -244,11 +247,11 @@ rounded @dimen/button_corner_radius - - @@ -701,7 +704,7 @@ @color/color_dracula_cyan - - + + + + + - - - - + + - @@ -645,6 +691,13 @@ @color/color_ng_black_accent @color/color_ng_black_accent + + @color/color_ng_black_container + @color/color_ng_black_container + @color/color_ng_black_container + @color/color_ng_black_surface_variant + @color/color_ng_black_accent + ?android:textColorSecondary ?android:textColorPrimary ?android:textColorSecondary @@ -697,6 +750,13 @@ @color/color_ng_black_primary @color/color_ng_black_accent + + @color/color_ng_black_container + @color/color_ng_black_container + @color/color_ng_black_container + @color/color_ng_black_surface_variant + @color/color_ng_black_accent + ?android:textColorSecondary ?android:textColorPrimary ?android:textColorSecondary