diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/CardsLayoutFragment.kt b/app/src/main/java/se/ajpanton/notificationsmaster/CardsLayoutFragment.kt new file mode 100644 index 0000000..a574bf0 --- /dev/null +++ b/app/src/main/java/se/ajpanton/notificationsmaster/CardsLayoutFragment.kt @@ -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, + ) +} diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/MainActivity.kt b/app/src/main/java/se/ajpanton/notificationsmaster/MainActivity.kt index 6eed0c6..15b9185 100644 --- a/app/src/main/java/se/ajpanton/notificationsmaster/MainActivity.kt +++ b/app/src/main/java/se/ajpanton/notificationsmaster/MainActivity.kt @@ -168,6 +168,7 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte page == Page.RULES -> RulesFragment() page == Page.APPS -> AppsFragment() page == Page.NOTIFICATION_VISIBILITY -> NotificationVisibilityFragment() + page == Page.CARDS_LAYOUT -> CardsLayoutFragment() page == Page.LOG_DISPLAY -> LogDisplayFragment() page == Page.FILTER_LOGGING -> FilterLoggingFragment() page == Page.FILTER_APPS -> FilterAppsFragment.newInstance() @@ -373,6 +374,7 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte RULES(R.id.nav_rules, R.string.page_rules), APPS(R.id.nav_apps, R.string.page_apps), 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), FILTER_LOGGING(R.id.nav_filter_logging, R.string.page_filter_logging), FILTER_APPS(R.id.nav_filter_apps, R.string.page_filter_apps), diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/module/ProcessVisibilityPolicyCache.kt b/app/src/main/java/se/ajpanton/notificationsmaster/module/ProcessVisibilityPolicyCache.kt index a7daed7..a828ae5 100644 --- a/app/src/main/java/se/ajpanton/notificationsmaster/module/ProcessVisibilityPolicyCache.kt +++ b/app/src/main/java/se/ajpanton/notificationsmaster/module/ProcessVisibilityPolicyCache.kt @@ -33,6 +33,8 @@ internal object ProcessVisibilityPolicyCache { fun hasExceptions(packageName: String) = policy.hasExceptions(packageName) val unlockedIconLimit get() = policy.unlockedIconLimit + val lockscreenCards get() = policy.lockscreenCards + val aodCards get() = policy.aodCards private fun tryInstall() { if (installed) return diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/visibility/CardsGridLayout.kt b/app/src/main/java/se/ajpanton/notificationsmaster/visibility/CardsGridLayout.kt new file mode 100644 index 0000000..b6feb24 --- /dev/null +++ b/app/src/main/java/se/ajpanton/notificationsmaster/visibility/CardsGridLayout.kt @@ -0,0 +1,34 @@ +package se.ajpanton.notificationsmaster.visibility + +data class CardsGridRows( + val rowSizes: List, + 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) + } +} diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/visibility/VisibilityPolicy.kt b/app/src/main/java/se/ajpanton/notificationsmaster/visibility/VisibilityPolicy.kt index 71c19fa..cabc630 100644 --- a/app/src/main/java/se/ajpanton/notificationsmaster/visibility/VisibilityPolicy.kt +++ b/app/src/main/java/se/ajpanton/notificationsmaster/visibility/VisibilityPolicy.kt @@ -4,6 +4,25 @@ import com.google.re2j.Pattern 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( val pattern: String, val blockedSurfaces: Set, @@ -26,6 +45,8 @@ data class VisibilityPolicy( val enabled: Boolean = true, val apps: List = emptyList(), val unlockedIconLimit: Int? = null, + val lockscreenCards: CardsGridSettings = CardsGridSettings.LOCKSCREEN_DEFAULT, + val aodCards: CardsGridSettings = CardsGridSettings.AOD_DEFAULT, ) { init { require(generation >= 0) @@ -51,6 +72,8 @@ data class VisibilityNotification( class CompiledVisibilityPolicy(policy: VisibilityPolicy) { val generation = policy.generation val unlockedIconLimit = policy.unlockedIconLimit + val lockscreenCards = policy.lockscreenCards + val aodCards = policy.aodCards private val enabled = policy.enabled private val apps = policy.apps.associate { app -> app.packageName to CompiledAppPolicy( diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/visibility/VisibilityPolicyJson.kt b/app/src/main/java/se/ajpanton/notificationsmaster/visibility/VisibilityPolicyJson.kt index 6fd5c67..52f5700 100644 --- a/app/src/main/java/se/ajpanton/notificationsmaster/visibility/VisibilityPolicyJson.kt +++ b/app/src/main/java/se/ajpanton/notificationsmaster/visibility/VisibilityPolicyJson.kt @@ -11,6 +11,8 @@ internal object VisibilityPolicyJson { .put("generation", policy.generation) .put("enabled", policy.enabled) .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()) } }) .toString().encodeToByteArray() @@ -22,9 +24,28 @@ internal object VisibilityPolicyJson { enabled = root.getBoolean("enabled"), apps = root.getJSONArray("apps").map { (it as JSONObject).toAppPolicy() }, 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() .put("packageName", packageName) .put("blockedSurfaces", JSONArray(blockedSurfaces.map { it.name })) diff --git a/app/src/main/res/layout/fragment_cards_layout.xml b/app/src/main/res/layout/fragment_cards_layout.xml new file mode 100644 index 0000000..9da2338 --- /dev/null +++ b/app/src/main/res/layout/fragment_cards_layout.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/include_cards_grid_settings_aod.xml b/app/src/main/res/layout/include_cards_grid_settings_aod.xml new file mode 100644 index 0000000..bed870e --- /dev/null +++ b/app/src/main/res/layout/include_cards_grid_settings_aod.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/include_cards_grid_settings_lockscreen.xml b/app/src/main/res/layout/include_cards_grid_settings_lockscreen.xml new file mode 100644 index 0000000..bed870e --- /dev/null +++ b/app/src/main/res/layout/include_cards_grid_settings_lockscreen.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/menu/drawer_menu.xml b/app/src/main/res/menu/drawer_menu.xml index b176773..38adedf 100644 --- a/app/src/main/res/menu/drawer_menu.xml +++ b/app/src/main/res/menu/drawer_menu.xml @@ -24,6 +24,9 @@ + Rules Apps Notification visibility + Cards layout Filter apps Log display Filter logging @@ -23,6 +24,7 @@     Rules     Apps     Visibility +     Cards layout     Filter logging     Filter apps diff --git a/app/src/test/java/se/ajpanton/notificationsmaster/visibility/CardsGridLayoutTest.kt b/app/src/test/java/se/ajpanton/notificationsmaster/visibility/CardsGridLayoutTest.kt new file mode 100644 index 0000000..dcfeaf3 --- /dev/null +++ b/app/src/test/java/se/ajpanton/notificationsmaster/visibility/CardsGridLayoutTest.kt @@ -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(), result.rowSizes) + assertTrue(result.showsOverflow) + assertFalse(CardsGridLayout.arrange(0, settings).showsOverflow) + } +} diff --git a/app/src/test/java/se/ajpanton/notificationsmaster/visibility/VisibilityPolicyJsonTest.kt b/app/src/test/java/se/ajpanton/notificationsmaster/visibility/VisibilityPolicyJsonTest.kt index a3ff2db..e1b6ebb 100644 --- a/app/src/test/java/se/ajpanton/notificationsmaster/visibility/VisibilityPolicyJsonTest.kt +++ b/app/src/test/java/se/ajpanton/notificationsmaster/visibility/VisibilityPolicyJsonTest.kt @@ -9,6 +9,8 @@ class VisibilityPolicyJsonTest { generation = 17, enabled = true, unlockedIconLimit = 7, + lockscreenCards = CardsGridSettings(2, 7, false, false, 72), + aodCards = CardsGridSettings(3, 6, true, true, 48), apps = listOf(AppVisibilityPolicy( packageName = "example.app", blockedSurfaces = setOf(NotificationSurface.AOD), @@ -27,6 +29,16 @@ class VisibilityPolicyJsonTest { 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) fun `unknown schemas are rejected`() { VisibilityPolicyJson.decode("{\"version\":99}".encodeToByteArray())