Add OneUI cards layout settings

This commit is contained in:
ajp_anton
2026-08-29 22:26:25 +00:00
parent c14b2c5e61
commit ae1bf8e62b
13 changed files with 346 additions and 0 deletions
@@ -0,0 +1,130 @@
package se.ajpanton.notificationsmaster
import android.os.Bundle
import android.view.View
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.SeekBar
import android.widget.Spinner
import android.widget.TextView
import androidx.fragment.app.Fragment
import com.google.android.material.switchmaterial.SwitchMaterial
import se.ajpanton.notificationsmaster.databinding.FragmentCardsLayoutBinding
import se.ajpanton.notificationsmaster.visibility.CardsGridSettings
import se.ajpanton.notificationsmaster.visibility.VisibilityPolicy
import se.ajpanton.notificationsmaster.visibility.VisibilityPolicyStore
class CardsLayoutFragment : Fragment(R.layout.fragment_cards_layout) {
private var binding: FragmentCardsLayoutBinding? = null
private lateinit var store: VisibilityPolicyStore
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding = FragmentCardsLayoutBinding.bind(view)
store = VisibilityPolicyStore(requireContext())
val policy = store.load()
bind(
controls(lockscreen = true),
policy.lockscreenCards,
VisibilityPolicy::lockscreenCards,
) { current, settings -> current.copy(lockscreenCards = settings) }
bind(
controls(lockscreen = false),
policy.aodCards,
VisibilityPolicy::aodCards,
) { current, settings -> current.copy(aodCards = settings) }
}
override fun onResume() {
super.onResume()
activity?.title = getString(R.string.page_cards_layout)
}
override fun onDestroyView() {
binding = null
super.onDestroyView()
}
private fun bind(
controls: Controls,
initial: CardsGridSettings,
currentSettings: (VisibilityPolicy) -> CardsGridSettings,
update: (VisibilityPolicy, CardsGridSettings) -> VisibilityPolicy,
) {
controls.rows.bindNumbers(0..5, initial.maxRows) { value ->
save(currentSettings, update) { it.copy(maxRows = value) }
}
controls.icons.bindNumbers(1..15, initial.maxIconsPerRow) { value ->
save(currentSettings, update) { it.copy(maxIconsPerRow = value) }
}
controls.even.isChecked = initial.evenDistribution
controls.even.setOnCheckedChangeListener { _, checked ->
save(currentSettings, update) { it.copy(evenDistribution = checked) }
}
controls.limit.isChecked = initial.limitToScreenWidth
controls.limit.setOnCheckedChangeListener { _, checked ->
save(currentSettings, update) { it.copy(limitToScreenWidth = checked) }
}
controls.height.max = 90
controls.height.progress = initial.iconHeightPercent - 10
controls.heightValue.text = "${initial.iconHeightPercent}%"
controls.height.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) {
controls.heightValue.text = "${progress + 10}%"
}
override fun onStartTrackingTouch(seekBar: SeekBar) = Unit
override fun onStopTrackingTouch(seekBar: SeekBar) {
save(currentSettings, update) { it.copy(iconHeightPercent = seekBar.progress + 10) }
}
})
}
private fun save(
currentSettings: (VisibilityPolicy) -> CardsGridSettings,
update: (VisibilityPolicy, CardsGridSettings) -> VisibilityPolicy,
change: (CardsGridSettings) -> CardsGridSettings,
) {
store.update { current ->
update(current, change(currentSettings(current)))
}
}
private fun Spinner.bindNumbers(range: IntRange, selected: Int, changed: (Int) -> Unit) {
var current = selected
adapter = ArrayAdapter(
requireContext(),
android.R.layout.simple_spinner_dropdown_item,
range.map(Int::toString),
)
setSelection(selected - range.first, false)
onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onNothingSelected(parent: AdapterView<*>?) = Unit
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
val value = range.first + position
if (value != current) {
current = value
changed(value)
}
}
}
}
private fun controls(lockscreen: Boolean): Controls {
val b = binding!!
return if (lockscreen) b.lockscreenSettings.run {
Controls(maxRows, maxIcons, evenDistribution, limitWidth, iconHeight, iconHeightValue)
} else b.aodSettings.run {
Controls(maxRows, maxIcons, evenDistribution, limitWidth, iconHeight, iconHeightValue)
}
}
private data class Controls(
val rows: Spinner,
val icons: Spinner,
val even: SwitchMaterial,
val limit: SwitchMaterial,
val height: SeekBar,
val heightValue: TextView,
)
}
@@ -168,6 +168,7 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
page == Page.RULES -> RulesFragment() page == Page.RULES -> RulesFragment()
page == Page.APPS -> AppsFragment() page == Page.APPS -> AppsFragment()
page == Page.NOTIFICATION_VISIBILITY -> NotificationVisibilityFragment() page == Page.NOTIFICATION_VISIBILITY -> NotificationVisibilityFragment()
page == Page.CARDS_LAYOUT -> CardsLayoutFragment()
page == Page.LOG_DISPLAY -> LogDisplayFragment() page == Page.LOG_DISPLAY -> LogDisplayFragment()
page == Page.FILTER_LOGGING -> FilterLoggingFragment() page == Page.FILTER_LOGGING -> FilterLoggingFragment()
page == Page.FILTER_APPS -> FilterAppsFragment.newInstance() page == Page.FILTER_APPS -> FilterAppsFragment.newInstance()
@@ -373,6 +374,7 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
RULES(R.id.nav_rules, R.string.page_rules), RULES(R.id.nav_rules, R.string.page_rules),
APPS(R.id.nav_apps, R.string.page_apps), APPS(R.id.nav_apps, R.string.page_apps),
NOTIFICATION_VISIBILITY(R.id.nav_notification_visibility, R.string.page_notification_visibility), NOTIFICATION_VISIBILITY(R.id.nav_notification_visibility, R.string.page_notification_visibility),
CARDS_LAYOUT(R.id.nav_cards_layout, R.string.page_cards_layout),
LOG_DISPLAY(R.id.nav_log_display, R.string.page_log_display), LOG_DISPLAY(R.id.nav_log_display, R.string.page_log_display),
FILTER_LOGGING(R.id.nav_filter_logging, R.string.page_filter_logging), FILTER_LOGGING(R.id.nav_filter_logging, R.string.page_filter_logging),
FILTER_APPS(R.id.nav_filter_apps, R.string.page_filter_apps), FILTER_APPS(R.id.nav_filter_apps, R.string.page_filter_apps),
@@ -33,6 +33,8 @@ internal object ProcessVisibilityPolicyCache {
fun hasExceptions(packageName: String) = policy.hasExceptions(packageName) fun hasExceptions(packageName: String) = policy.hasExceptions(packageName)
val unlockedIconLimit get() = policy.unlockedIconLimit val unlockedIconLimit get() = policy.unlockedIconLimit
val lockscreenCards get() = policy.lockscreenCards
val aodCards get() = policy.aodCards
private fun tryInstall() { private fun tryInstall() {
if (installed) return if (installed) return
@@ -0,0 +1,34 @@
package se.ajpanton.notificationsmaster.visibility
data class CardsGridRows(
val rowSizes: List<Int>,
val showsOverflow: Boolean,
) {
val visibleSlots get() = rowSizes.sum()
}
object CardsGridLayout {
fun arrange(itemCount: Int, settings: CardsGridSettings, widthCapacity: Int? = null): CardsGridRows {
require(itemCount >= 0)
val perRow = if (settings.limitToScreenWidth && widthCapacity != null) {
minOf(settings.maxIconsPerRow, widthCapacity.coerceAtLeast(0))
} else {
settings.maxIconsPerRow
}
val capacity = perRow * settings.maxRows
if (itemCount == 0 || capacity == 0) return CardsGridRows(emptyList(), itemCount > 0)
val overflow = itemCount > capacity
val slots = minOf(itemCount, capacity)
val rows = minOf(settings.maxRows, (slots + perRow - 1) / perRow)
if (!settings.evenDistribution || rows == 1) {
var remaining = slots
return CardsGridRows(List(rows) {
minOf(perRow, remaining).also { remaining -= it }
}, overflow)
}
val base = slots / rows
val remainder = slots % rows
return CardsGridRows(List(rows) { base + if (it < remainder) 1 else 0 }, overflow)
}
}
@@ -4,6 +4,25 @@ import com.google.re2j.Pattern
enum class NotificationSurface { AOD, LOCKSCREEN_COLLAPSED, UNLOCKED_STATUSBAR } enum class NotificationSurface { AOD, LOCKSCREEN_COLLAPSED, UNLOCKED_STATUSBAR }
data class CardsGridSettings(
val maxRows: Int = 1,
val maxIconsPerRow: Int = 5,
val evenDistribution: Boolean = true,
val limitToScreenWidth: Boolean = true,
val iconHeightPercent: Int,
) {
init {
require(maxRows in 0..5)
require(maxIconsPerRow in 1..15)
require(iconHeightPercent in 10..100)
}
companion object {
val LOCKSCREEN_DEFAULT = CardsGridSettings(iconHeightPercent = 59)
val AOD_DEFAULT = CardsGridSettings(iconHeightPercent = 65)
}
}
data class VisibilityExceptionRule( data class VisibilityExceptionRule(
val pattern: String, val pattern: String,
val blockedSurfaces: Set<NotificationSurface>, val blockedSurfaces: Set<NotificationSurface>,
@@ -26,6 +45,8 @@ data class VisibilityPolicy(
val enabled: Boolean = true, val enabled: Boolean = true,
val apps: List<AppVisibilityPolicy> = emptyList(), val apps: List<AppVisibilityPolicy> = emptyList(),
val unlockedIconLimit: Int? = null, val unlockedIconLimit: Int? = null,
val lockscreenCards: CardsGridSettings = CardsGridSettings.LOCKSCREEN_DEFAULT,
val aodCards: CardsGridSettings = CardsGridSettings.AOD_DEFAULT,
) { ) {
init { init {
require(generation >= 0) require(generation >= 0)
@@ -51,6 +72,8 @@ data class VisibilityNotification(
class CompiledVisibilityPolicy(policy: VisibilityPolicy) { class CompiledVisibilityPolicy(policy: VisibilityPolicy) {
val generation = policy.generation val generation = policy.generation
val unlockedIconLimit = policy.unlockedIconLimit val unlockedIconLimit = policy.unlockedIconLimit
val lockscreenCards = policy.lockscreenCards
val aodCards = policy.aodCards
private val enabled = policy.enabled private val enabled = policy.enabled
private val apps = policy.apps.associate { app -> private val apps = policy.apps.associate { app ->
app.packageName to CompiledAppPolicy( app.packageName to CompiledAppPolicy(
@@ -11,6 +11,8 @@ internal object VisibilityPolicyJson {
.put("generation", policy.generation) .put("generation", policy.generation)
.put("enabled", policy.enabled) .put("enabled", policy.enabled)
.put("unlockedIconLimit", policy.unlockedIconLimit ?: JSONObject.NULL) .put("unlockedIconLimit", policy.unlockedIconLimit ?: JSONObject.NULL)
.put("lockscreenCards", policy.lockscreenCards.toJson())
.put("aodCards", policy.aodCards.toJson())
.put("apps", JSONArray().apply { policy.apps.forEach { put(it.toJson()) } }) .put("apps", JSONArray().apply { policy.apps.forEach { put(it.toJson()) } })
.toString().encodeToByteArray() .toString().encodeToByteArray()
@@ -22,9 +24,28 @@ internal object VisibilityPolicyJson {
enabled = root.getBoolean("enabled"), enabled = root.getBoolean("enabled"),
apps = root.getJSONArray("apps").map { (it as JSONObject).toAppPolicy() }, apps = root.getJSONArray("apps").map { (it as JSONObject).toAppPolicy() },
unlockedIconLimit = if (root.isNull("unlockedIconLimit")) null else root.getInt("unlockedIconLimit"), unlockedIconLimit = if (root.isNull("unlockedIconLimit")) null else root.getInt("unlockedIconLimit"),
lockscreenCards = root.optJSONObject("lockscreenCards")?.toCardsGridSettings()
?: CardsGridSettings.LOCKSCREEN_DEFAULT,
aodCards = root.optJSONObject("aodCards")?.toCardsGridSettings()
?: CardsGridSettings.AOD_DEFAULT,
) )
} }
private fun CardsGridSettings.toJson() = JSONObject()
.put("maxRows", maxRows)
.put("maxIconsPerRow", maxIconsPerRow)
.put("evenDistribution", evenDistribution)
.put("limitToScreenWidth", limitToScreenWidth)
.put("iconHeightPercent", iconHeightPercent)
private fun JSONObject.toCardsGridSettings() = CardsGridSettings(
maxRows = getInt("maxRows"),
maxIconsPerRow = getInt("maxIconsPerRow"),
evenDistribution = getBoolean("evenDistribution"),
limitToScreenWidth = getBoolean("limitToScreenWidth"),
iconHeightPercent = getInt("iconHeightPercent"),
)
private fun AppVisibilityPolicy.toJson() = JSONObject() private fun AppVisibilityPolicy.toJson() = JSONObject()
.put("packageName", packageName) .put("packageName", packageName)
.put("blockedSurfaces", JSONArray(blockedSurfaces.map { it.name })) .put("blockedSurfaces", JSONArray(blockedSurfaces.map { it.name }))
@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="@dimen/page_padding">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="These settings apply only to OneUI Cards mode. Icons mode remains rendered by Android or StatusBarTweak."
android:textAppearance="?attr/textAppearanceBody2" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="Lockscreen"
android:textAppearance="?attr/textAppearanceHeadline6" />
<include
android:id="@+id/lockscreen_settings"
layout="@layout/include_cards_grid_settings_lockscreen" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="28dp"
android:text="Always-on display"
android:textAppearance="?attr/textAppearanceHeadline6" />
<include
android:id="@+id/aod_settings"
layout="@layout/include_cards_grid_settings_aod" />
</LinearLayout>
</ScrollView>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" android:text="Max rows" />
<Spinner android:id="@+id/max_rows" android:layout_width="wrap_content" android:layout_height="wrap_content" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:text="Max icons per row" />
<Spinner android:id="@+id/max_icons" android:layout_width="wrap_content" android:layout_height="wrap_content" />
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/even_distribution" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:text="Even distribution" />
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/limit_width" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="Limit to screen width" />
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:gravity="center_vertical" android:orientation="horizontal">
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:text="Icon height (% of status bar)" />
<TextView android:id="@+id/icon_height_value" android:layout_width="wrap_content" android:layout_height="wrap_content" />
</LinearLayout>
<SeekBar android:id="@+id/icon_height" android:layout_width="match_parent" android:layout_height="wrap_content" />
</LinearLayout>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" android:text="Max rows" />
<Spinner android:id="@+id/max_rows" android:layout_width="wrap_content" android:layout_height="wrap_content" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:text="Max icons per row" />
<Spinner android:id="@+id/max_icons" android:layout_width="wrap_content" android:layout_height="wrap_content" />
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/even_distribution" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:text="Even distribution" />
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/limit_width" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="Limit to screen width" />
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:gravity="center_vertical" android:orientation="horizontal">
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:text="Icon height (% of status bar)" />
<TextView android:id="@+id/icon_height_value" android:layout_width="wrap_content" android:layout_height="wrap_content" />
</LinearLayout>
<SeekBar android:id="@+id/icon_height" android:layout_width="match_parent" android:layout_height="wrap_content" />
</LinearLayout>
+3
View File
@@ -24,6 +24,9 @@
<item <item
android:id="@+id/nav_notification_visibility" android:id="@+id/nav_notification_visibility"
android:title="@string/navigation_indented_notification_visibility" /> android:title="@string/navigation_indented_notification_visibility" />
<item
android:id="@+id/nav_cards_layout"
android:title="@string/navigation_indented_cards_layout" />
</group> </group>
<group android:checkableBehavior="single"> <group android:checkableBehavior="single">
<item <item
+2
View File
@@ -14,6 +14,7 @@
<string name="page_rules">Rules</string> <string name="page_rules">Rules</string>
<string name="page_apps">Apps</string> <string name="page_apps">Apps</string>
<string name="page_notification_visibility">Notification visibility</string> <string name="page_notification_visibility">Notification visibility</string>
<string name="page_cards_layout">Cards layout</string>
<string name="page_filter_apps">Filter apps</string> <string name="page_filter_apps">Filter apps</string>
<string name="page_log_display">Log display</string> <string name="page_log_display">Log display</string>
<string name="page_filter_logging">Filter logging</string> <string name="page_filter_logging">Filter logging</string>
@@ -23,6 +24,7 @@
<string name="navigation_indented_rules">&#160;&#160;&#160;&#160;Rules</string> <string name="navigation_indented_rules">&#160;&#160;&#160;&#160;Rules</string>
<string name="navigation_indented_apps">&#160;&#160;&#160;&#160;Apps</string> <string name="navigation_indented_apps">&#160;&#160;&#160;&#160;Apps</string>
<string name="navigation_indented_notification_visibility">&#160;&#160;&#160;&#160;Visibility</string> <string name="navigation_indented_notification_visibility">&#160;&#160;&#160;&#160;Visibility</string>
<string name="navigation_indented_cards_layout">&#160;&#160;&#160;&#160;Cards layout</string>
<string name="navigation_indented_filter_logging">&#160;&#160;&#160;&#160;Filter logging</string> <string name="navigation_indented_filter_logging">&#160;&#160;&#160;&#160;Filter logging</string>
<string name="navigation_indented_filter_apps">&#160;&#160;&#160;&#160;Filter apps</string> <string name="navigation_indented_filter_apps">&#160;&#160;&#160;&#160;Filter apps</string>
</resources> </resources>
@@ -0,0 +1,41 @@
package se.ajpanton.notificationsmaster.visibility
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class CardsGridLayoutTest {
@Test fun `ordinary rows fill in order`() {
val settings = CardsGridSettings(3, 4, false, false, 50)
assertEquals(listOf(4, 4, 2), CardsGridLayout.arrange(10, settings).rowSizes)
}
@Test fun `even distribution balances occupied rows`() {
val settings = CardsGridSettings(3, 4, true, false, 50)
assertEquals(listOf(3, 2), CardsGridLayout.arrange(5, settings).rowSizes)
assertEquals(listOf(4, 3, 3), CardsGridLayout.arrange(10, settings).rowSizes)
}
@Test fun `overflow uses the configured capacity`() {
val result = CardsGridLayout.arrange(13, CardsGridSettings(3, 4, true, false, 50))
assertEquals(listOf(4, 4, 4), result.rowSizes)
assertEquals(12, result.visibleSlots)
assertTrue(result.showsOverflow)
}
@Test fun `screen width can reduce the row capacity`() {
val settings = CardsGridSettings(2, 8, false, true, 50)
val result = CardsGridLayout.arrange(7, settings, widthCapacity = 3)
assertEquals(listOf(3, 3), result.rowSizes)
assertTrue(result.showsOverflow)
}
@Test fun `disabled rows have no slots`() {
val settings = CardsGridSettings(0, 5, true, true, 50)
val result = CardsGridLayout.arrange(2, settings)
assertEquals(emptyList<Int>(), result.rowSizes)
assertTrue(result.showsOverflow)
assertFalse(CardsGridLayout.arrange(0, settings).showsOverflow)
}
}
@@ -9,6 +9,8 @@ class VisibilityPolicyJsonTest {
generation = 17, generation = 17,
enabled = true, enabled = true,
unlockedIconLimit = 7, unlockedIconLimit = 7,
lockscreenCards = CardsGridSettings(2, 7, false, false, 72),
aodCards = CardsGridSettings(3, 6, true, true, 48),
apps = listOf(AppVisibilityPolicy( apps = listOf(AppVisibilityPolicy(
packageName = "example.app", packageName = "example.app",
blockedSurfaces = setOf(NotificationSurface.AOD), blockedSurfaces = setOf(NotificationSurface.AOD),
@@ -27,6 +29,16 @@ class VisibilityPolicyJsonTest {
assertEquals(policy, VisibilityPolicyJson.decode(VisibilityPolicyJson.encode(policy))) assertEquals(policy, VisibilityPolicyJson.decode(VisibilityPolicyJson.encode(policy)))
} }
@Test fun `missing cards settings use defaults`() {
val decoded = VisibilityPolicyJson.decode(
"""{"version":1,"generation":0,"enabled":true,"unlockedIconLimit":null,"apps":[]}"""
.encodeToByteArray(),
)
assertEquals(CardsGridSettings.LOCKSCREEN_DEFAULT, decoded.lockscreenCards)
assertEquals(CardsGridSettings.AOD_DEFAULT, decoded.aodCards)
}
@Test(expected = IllegalArgumentException::class) @Test(expected = IllegalArgumentException::class)
fun `unknown schemas are rejected`() { fun `unknown schemas are rejected`() {
VisibilityPolicyJson.decode("{\"version\":99}".encodeToByteArray()) VisibilityPolicyJson.decode("{\"version\":99}".encodeToByteArray())