Skip to content

Commit 2f1f915

Browse files
committed
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<T>() (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.
1 parent 04956ce commit 2f1f915

6 files changed

Lines changed: 40 additions & 21 deletions

File tree

app/src/main/java/io/nekohasekai/sagernet/ktx/Dialogs.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ fun Fragment.alert(text: String) = requireContext().alert(text)
1818

1919
fun AlertDialog.tryToShow() {
2020
try {
21-
val activity = context as Activity
21+
val activity = context.unwrap<Activity>()
2222
if (!activity.isFinishing) {
2323
show()
2424
}

app/src/main/java/io/nekohasekai/sagernet/ktx/Utils.kt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,21 @@ import kotlin.reflect.KProperty0
6363

6464
fun String?.blankAsNull(): String? = if (isNullOrBlank()) null else this
6565

66+
/**
67+
* Resolve the hosting object of type [T] (e.g. the Activity / LifecycleOwner) from a View's
68+
* Context. Material 3 themes wrap the view context in a ContextThemeWrapper, so a direct
69+
* `context as Activity` cast throws ClassCastException; walk the ContextWrapper.baseContext
70+
* chain instead.
71+
*/
72+
inline fun <reified T> Context.unwrap(): T {
73+
var ctx: Context? = this
74+
while (ctx != null) {
75+
if (ctx is T) return ctx
76+
ctx = (ctx as? android.content.ContextWrapper)?.baseContext
77+
}
78+
error("Could not unwrap ${T::class.java.simpleName} from context")
79+
}
80+
6681
inline fun <T> Iterable<T>.forEachTry(action: (T) -> Unit) {
6782
var result: Exception? = null
6883
for (element in this) try {

app/src/main/java/io/nekohasekai/sagernet/widget/ServiceButton.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import com.google.android.material.progressindicator.BaseProgressIndicator
2020
import io.nekohasekai.sagernet.R
2121
import io.nekohasekai.sagernet.bg.BaseService
2222
import io.nekohasekai.sagernet.ktx.getColorAttr
23+
import io.nekohasekai.sagernet.ktx.unwrap
2324
import kotlinx.coroutines.Job
2425
import kotlinx.coroutines.delay
2526
import kotlinx.coroutines.launch
@@ -66,11 +67,11 @@ class ServiceButton @JvmOverloads constructor(
6667
private val iconConnecting by lazy {
6768
AnimatedState(R.drawable.ic_service_connecting) {
6869
hideProgress()
69-
delayedAnimation = (context as LifecycleOwner).lifecycleScope.launch {
70+
delayedAnimation = context.unwrap<LifecycleOwner>().lifecycleScope.launch {
7071
delay(context.resources.getInteger(android.R.integer.config_mediumAnimTime) + 1000L)
7172
// Gate the UI mutation on STARTED so a delayed progress reveal doesn't run
7273
// while the activity is stopped (the old launchWhenStarted suspended here).
73-
(context as LifecycleOwner).withStarted {
74+
context.unwrap<LifecycleOwner>().withStarted {
7475
isIndeterminate = true
7576
show()
7677
}

app/src/main/java/io/nekohasekai/sagernet/widget/StatsBar.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ class StatsBar @JvmOverloads constructor(
122122
}
123123

124124
fun changeState(state: BaseService.State) {
125-
val activity = context as MainActivity
125+
val activity = context.unwrap<MainActivity>()
126126
fun postWhenStarted(what: () -> Unit) = activity.lifecycleScope.launch(Dispatchers.Main) {
127127
delay(100L)
128128
activity.withStarted { what() }
@@ -172,7 +172,7 @@ class StatsBar @JvmOverloads constructor(
172172
}
173173

174174
fun testConnection() {
175-
val activity = context as MainActivity
175+
val activity = context.unwrap<MainActivity>()
176176
isEnabled = false
177177
// "Testing…" in the testing color.
178178
statusText.setTextColor(context.getColorAttr(R.attr.statusTestingColor))

app/src/main/res/values-v26/themes.xml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<resources xmlns:tools="http://schemas.android.com/tools">
22
<!-- Base application theme for API 26+ -->
3-
<style name="Theme.SagerNet" parent="Theme.MaterialComponents.DayNight.NoActionBar">
3+
<style name="Theme.SagerNet" parent="Theme.Material3.DayNight.NoActionBar">
44
<item name="actionBarStyle">@style/Widget.MaterialComponents.ActionBar.Solid</item>
55
<!-- Default nav-drawer selected-item fill; satisfies the
66
?itemShapeFillColor self-reference in @color/navigation_item. -->
@@ -55,7 +55,7 @@
5555
<item name="chipStyle">@style/Widget.MaterialComponents.Chip.Choice.SagerNet</item>
5656

5757
<item name="itemShapeAppearance">
58-
@style/ShapeAppearance.MaterialComponents.MediumComponent
58+
@style/ShapeAppearance.Material3.MediumComponent
5959
</item>
6060

6161
<item name="alertDialogTheme">

app/src/main/res/values/themes.xml

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
<resources>
2-
<!-- Base application theme. -->
3-
<style name="Theme.SagerNet" parent="Theme.MaterialComponents.DayNight.NoActionBar">
2+
<!-- Base application theme. Migrated to Material 3. M3 derives the accent/variant
3+
roles from colorPrimary, but this app sets explicit per-theme palettes, so the
4+
key M3 color roles are populated below and per-theme overrides follow in each
5+
Theme.SagerNet.* variant. -->
6+
<style name="Theme.SagerNet" parent="Theme.Material3.DayNight.NoActionBar">
47
<item name="actionBarStyle">@style/Widget.MaterialComponents.ActionBar.Solid</item>
58
<!-- Protocol/type label color; defaults to accent, Dracula overrides green. -->
69
<item name="protocolColor">?attr/accentOrTextSecondary</item>
@@ -56,7 +59,7 @@
5659
<item name="chipStyle">@style/Widget.MaterialComponents.Chip.Choice.SagerNet</item>
5760

5861
<item name="itemShapeAppearance">
59-
@style/ShapeAppearance.MaterialComponents.MediumComponent
62+
@style/ShapeAppearance.Material3.MediumComponent
6063
</item>
6164

6265
<item name="alertDialogTheme">
@@ -76,7 +79,7 @@
7679

7780
</style>
7881

79-
<style name="Theme.SagerNet.Dialog" parent="Theme.MaterialComponents.DayNight.Dialog.Alert">
82+
<style name="Theme.SagerNet.Dialog" parent="Theme.Material3.DayNight.Dialog.Alert">
8083
<item name="actionBarStyle">@style/Widget.MaterialComponents.ActionBar.Solid</item>
8184
<item name="protocolColor">?attr/accentOrTextSecondary</item>
8285
<item name="profileNameColor">?android:attr/textColorPrimary</item>
@@ -125,7 +128,7 @@
125128
<item name="chipStyle">@style/Widget.MaterialComponents.Chip.Choice.SagerNet</item>
126129

127130
<item name="itemShapeAppearance">
128-
@style/ShapeAppearance.MaterialComponents.MediumComponent
131+
@style/ShapeAppearance.Material3.MediumComponent
129132
</item>
130133

131134
<item name="alertDialogTheme">
@@ -184,7 +187,7 @@
184187
<item name="rippleColor">@color/chip_ripple_color</item>
185188
</style>
186189

187-
<style name="Widget.SagerNet.CardView" parent="Widget.MaterialComponents.CardView">
190+
<style name="Widget.SagerNet.CardView" parent="Widget.Material3.CardView.Filled">
188191
<item name="cardElevation">0dp</item>
189192
<item name="cardCornerRadius">@dimen/card_corner_radius</item>
190193
<item name="strokeColor">@color/card_stroke</item>
@@ -208,7 +211,7 @@
208211
<item name="cornerFamily">rounded</item>
209212
<item name="cornerSize">@dimen/input_corner_radius</item>
210213
</style>
211-
<style name="Widget.SagerNet.TextInputLayout" parent="Widget.MaterialComponents.TextInputLayout.OutlinedBox">
214+
<style name="Widget.SagerNet.TextInputLayout" parent="Widget.Material3.TextInputLayout.OutlinedBox">
212215
<item name="shapeAppearanceOverlay">@style/ShapeAppearanceOverlay.SagerNet.Input</item>
213216
</style>
214217

@@ -217,11 +220,11 @@
217220
<item name="android:windowExitAnimation">@anim/popup_exit</item>
218221
</style>
219222

220-
<style name="Widget.SagerNet.PopupMenu" parent="Widget.MaterialComponents.PopupMenu">
223+
<style name="Widget.SagerNet.PopupMenu" parent="Widget.Material3.PopupMenu">
221224
<item name="android:popupBackground">@drawable/bg_popup_menu</item>
222225
<item name="android:popupAnimationStyle">@style/PopupMenuAnimation</item>
223226
</style>
224-
<style name="Widget.SagerNet.PopupMenu.Overflow" parent="Widget.MaterialComponents.PopupMenu.Overflow">
227+
<style name="Widget.SagerNet.PopupMenu.Overflow" parent="Widget.Material3.PopupMenu.Overflow">
225228
<item name="android:popupBackground">@drawable/bg_popup_menu</item>
226229
<item name="android:popupAnimationStyle">@style/PopupMenuAnimation</item>
227230
</style>
@@ -233,22 +236,22 @@
233236
<item name="cornerSizeBottomLeft">0dp</item>
234237
<item name="cornerSizeBottomRight">0dp</item>
235238
</style>
236-
<style name="Widget.SagerNet.BottomSheet" parent="Widget.MaterialComponents.BottomSheet.Modal">
239+
<style name="Widget.SagerNet.BottomSheet" parent="Widget.Material3.BottomSheet.Modal">
237240
<item name="shapeAppearanceOverlay">@style/ShapeAppearanceOverlay.SagerNet.BottomSheet</item>
238241
</style>
239-
<style name="ThemeOverlay.SagerNet.BottomSheetDialog" parent="ThemeOverlay.MaterialComponents.BottomSheetDialog">
242+
<style name="ThemeOverlay.SagerNet.BottomSheetDialog" parent="ThemeOverlay.Material3.BottomSheetDialog">
240243
<item name="bottomSheetStyle">@style/Widget.SagerNet.BottomSheet</item>
241244
</style>
242245

243246
<style name="ShapeAppearanceOverlay.SagerNet.Button" parent="">
244247
<item name="cornerFamily">rounded</item>
245248
<item name="cornerSize">@dimen/button_corner_radius</item>
246249
</style>
247-
<style name="Widget.SagerNet.Button" parent="Widget.MaterialComponents.Button">
250+
<style name="Widget.SagerNet.Button" parent="Widget.Material3.Button">
248251
<item name="shapeAppearanceOverlay">@style/ShapeAppearanceOverlay.SagerNet.Button</item>
249252
</style>
250253

251-
<style name="ShapeAppearance.SagerNet.NavItem" parent="ShapeAppearance.MaterialComponents.SmallComponent">
254+
<style name="ShapeAppearance.SagerNet.NavItem" parent="ShapeAppearance.Material3.SmallComponent">
252255
<item name="cornerFamily">rounded</item>
253256
<item name="cornerSize">@dimen/nav_item_corner_radius</item>
254257
</style>
@@ -701,7 +704,7 @@
701704
<item name="routeProxyColor">@color/color_dracula_cyan</item>
702705
</style>
703706

704-
<style name="Theme.Start" parent="Theme.MaterialComponents.DayNight">
707+
<style name="Theme.Start" parent="Theme.Material3.DayNight">
705708
<item name="android:navigationBarColor">@android:color/transparent</item>
706709
<item name="colorPrimary">@android:color/transparent</item>
707710
<item name="colorPrimaryDark">@android:color/transparent</item>

0 commit comments

Comments
 (0)