diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/module/AospLockscreenBackend.kt b/app/src/main/java/se/ajpanton/notificationsmaster/module/AospLockscreenBackend.kt new file mode 100644 index 0000000..3a966d5 --- /dev/null +++ b/app/src/main/java/se/ajpanton/notificationsmaster/module/AospLockscreenBackend.kt @@ -0,0 +1,319 @@ +package se.ajpanton.notificationsmaster.module + +import android.os.Build +import android.service.notification.StatusBarNotification +import android.util.Log +import android.view.View +import android.view.ViewGroup +import io.github.libxposed.api.XposedInterface +import se.ajpanton.notificationsmaster.visibility.NotificationSurface +import java.lang.reflect.Field +import java.lang.reflect.Method +import java.util.Collections +import java.util.WeakHashMap + +/** Hides blocked rows only in AOSP's collapsed lockscreen notification stack. */ +internal class AospLockscreenBackend( + private val framework: XposedInterface, +) : VisibilitySurfaceBackend { + private val stacks = Collections.newSetFromMap(WeakHashMap()) + private val shelves = Collections.newSetFromMap(WeakHashMap()) + private val hiddenRows = WeakHashMap() + private val groupOrders = WeakHashMap>() + private lateinit var onKeyguard: Method + private lateinit var entryForRow: (View) -> Any? + private lateinit var sbnForEntry: (Any) -> StatusBarNotification? + private lateinit var attachedChildrenForRow: (View) -> List + private lateinit var reorderChildren: (View, List) -> Unit + private lateinit var setGroupCount: (View, Int) -> Unit + private lateinit var calculateShelfIcons: Method + private lateinit var shelfNotification: Field + private lateinit var shelfIconStates: Field + private lateinit var shelfClampedAmount: Field + private lateinit var shelfAppearAmount: Field + private lateinit var shelfAlpha: Field + private lateinit var shelfHidden: Field + private lateinit var shelfVisibleState: Field + @Volatile private var blockedObserved = false + + override fun install(classLoader: ClassLoader): Boolean { + if (Build.MANUFACTURER.equals("samsung", ignoreCase = true)) return false + return runCatching { + val stackClass = Class.forName(STACK, false, classLoader) + val rowClass = Class.forName(ROW, false, classLoader) + onKeyguard = stackClass.declaredMethods.singleOrNull { + it.name == "onKeyguard" && it.parameterCount == 0 && + it.returnType == Boolean::class.javaPrimitiveType + }?.also { it.isAccessible = true } + ?: error("AOSP lockscreen state method not found") + prepareNotificationAccess(rowClass) + installShelfFilter(classLoader) + + stackClass.declaredMethods.filter { it.name == "onViewAdded" }.forEach { method -> + method.isAccessible = true + framework.hook(method).intercept { chain -> + val result = chain.proceed() + (chain.thisObject as? ViewGroup)?.let { stack -> + track(stack) + (chain.args.firstOrNull() as? View)?.let { applyRow(stack, it) } + } + result + } + } + stackClass.declaredMethods.filter { it.name == "onViewRemoved" }.forEach { method -> + method.isAccessible = true + framework.hook(method).intercept { chain -> + (chain.args.firstOrNull() as? View)?.let(::restoreTree) + chain.proceed() + } + } + stackClass.declaredMethods.filter { it.name == "onMeasure" }.forEach { method -> + method.isAccessible = true + framework.hook(method).intercept { chain -> + (chain.thisObject as? ViewGroup)?.let { + track(it) + applyStack(it) + } + chain.proceed() + } + } + stackClass.declaredMethods.filter { it.name == "setStatusBarState" }.forEach { method -> + method.isAccessible = true + framework.hook(method).intercept { chain -> + val result = chain.proceed() + (chain.thisObject as? ViewGroup)?.let { + track(it) + applyStack(it) + } + result + } + } + }.onSuccess { + Log.i(TAG, "Installed AOSP collapsed-lockscreen notification backend") + }.onFailure { + Log.w(TAG, "AOSP lockscreen shape is unsupported; leaving rows unchanged", it) + }.isSuccess + } + + override fun onPolicyChanged() { + blockedObserved = false + synchronized(stacks) { stacks.toList() }.forEach(::applyStack) + synchronized(shelves) { shelves.toList() }.forEach { shelf -> + runCatching { calculateShelfIcons.invoke(shelf) } + .onFailure { Log.w(TAG, "Could not refresh AOSP lockscreen shelf", it) } + } + } + + private fun prepareNotificationAccess(rowClass: Class<*>) { + val entryMethod = rowClass.methods.firstOrNull { it.name == "getEntry" && it.parameterCount == 0 } + val entryField = findField(rowClass, "mEntry", "entry") + require(entryMethod != null || entryField != null) { "AOSP notification row entry is unavailable" } + entryForRow = accessor(entryMethod, entryField) + + val entryClass = entryMethod?.returnType ?: entryField!!.type + val sbnMethod = entryClass.methods.firstOrNull { it.name == "getSbn" && it.parameterCount == 0 } + val sbnField = findField(entryClass, "mSbn", "sbn", "mStatusBarNotification") + require(sbnMethod != null || sbnField != null) { "AOSP notification entry SBN is unavailable" } + val accessor = accessor(sbnMethod, sbnField) + sbnForEntry = { accessor(it) as? StatusBarNotification } + val attachedChildren = rowClass.methods.firstOrNull { + it.name == "getAttachedChildren" && it.parameterCount == 0 + }?.also { it.isAccessible = true } + attachedChildrenForRow = { row -> + (attachedChildren?.invoke(row) as? List<*>)?.filterIsInstance().orEmpty() + } + reorderChildren = { row, ordered -> + @Suppress("UNCHECKED_CAST") + (attachedChildren?.invoke(row) as? MutableList)?.let { + it.clear() + it.addAll(ordered) + } + } + val childrenContainer = requireField(rowClass, "mChildrenContainer") + val untruncatedCount = requireField(childrenContainer.type, "mUntruncatedChildCount") + setGroupCount = { row, count -> + childrenContainer.get(row)?.let { untruncatedCount.setInt(it, count) } + } + } + + private fun installShelfFilter(classLoader: ClassLoader) { + val iconContainer = Class.forName(ICON_CONTAINER, false, classLoader) + val iconView = Class.forName(STATUS_BAR_ICON_VIEW, false, classLoader) + val iconState = Class.forName(ICON_STATE, false, classLoader) + calculateShelfIcons = iconContainer.declaredMethods.singleOrNull { + it.name == "calculateIconXTranslations" && it.parameterCount == 0 + }?.also { it.isAccessible = true } ?: error("AOSP shelf icon layout method not found") + shelfNotification = requireField(iconView, "mNotification") + shelfIconStates = requireField(iconContainer, "mIconStates") + shelfClampedAmount = requireField(iconState, "clampedAppearAmount") + shelfAppearAmount = requireField(iconState, "iconAppearAmount") + shelfAlpha = requireField(iconState, "mAlpha") + shelfHidden = requireField(iconState, "hidden") + shelfVisibleState = requireField(iconState, "visibleState") + + framework.hook(calculateShelfIcons).intercept { chain -> + val container = chain.thisObject as? ViewGroup + ?: return@intercept chain.proceed() + if (container.parent?.javaClass?.name != SHELF) return@intercept chain.proceed() + synchronized(shelves) { shelves += container } + if (!isCollapsedLockscreen(container)) return@intercept chain.proceed() + val children = List(container.childCount, container::getChildAt) + val states = shelfIconStates.get(container) as? Map<*, *> + ?: return@intercept chain.proceed() + val blocked = children.filter(::isBlockedShelfIcon) + blocked.forEach { states[it]?.let(::hideShelfIconState) } + children.filterNot(blocked::contains).forEach(container::bringChildToFront) + val result = try { + chain.proceed() + } finally { + children.forEach(container::bringChildToFront) + } + blocked.forEach { states[it]?.let(::hideShelfIconState) } + result + } + } + + private fun track(stack: ViewGroup) { + synchronized(stacks) { stacks += stack } + } + + private fun applyStack(stack: ViewGroup) { + val active = isCollapsedLockscreen(stack) + repeat(stack.childCount) { applyRow(stack, stack.getChildAt(it), active) } + stack.requestLayout() + } + + private fun applyRow(stack: ViewGroup, row: View, active: Boolean? = null) { + if (row.javaClass.name != ROW) return + val filter = active ?: isCollapsedLockscreen(stack) + if (!filter) restoreGroupOrder(row) + val children = attachedChildrenForRow(row) + val parentBlocked = filter && isBlocked(row) + var blockedChildren = children.map { it to (parentBlocked && isBlocked(it)) } + if (parentBlocked && children.isNotEmpty()) { + groupOrders.putIfAbsent(row, children) + blockedChildren = blockedChildren.partition { !it.second }.let { it.first + it.second } + reorderChildren(row, blockedChildren.map { it.first }) + setGroupCount(row, blockedChildren.count { !it.second }) + } + blockedChildren.forEach { (child, blocked) -> + if (blocked) hideRow(child) else restoreRow(child) + } + if (parentBlocked && blockedChildren.all { it.second }) hideRow(row) else restoreRow(row) + } + + private fun isBlocked(row: View): Boolean { + val sbn = runCatching { entryForRow(row)?.let(sbnForEntry) }.getOrNull() ?: return false + return isBlocked(sbn) + } + + private fun isBlockedShelfIcon(icon: View): Boolean { + if (icon.javaClass.name != STATUS_BAR_ICON_VIEW) return false + val sbn = runCatching { shelfNotification.get(icon) as? StatusBarNotification }.getOrNull() + ?: return false + val row = rowForKey(icon, sbn.key) + return row?.let { isBlocked(it) && attachedChildrenForRow(it).none { child -> !isBlocked(child) } } + ?: isBlocked(sbn) + } + + private fun rowForKey(view: View, key: String): View? { + val root = view.rootView + return synchronized(stacks) { stacks.toList() } + .asSequence() + .filter { it.rootView === root } + .flatMap { stack -> (0 until stack.childCount).asSequence().map(stack::getChildAt) } + .firstOrNull { row -> + row.javaClass.name == ROW && + runCatching { entryForRow(row)?.let(sbnForEntry)?.key == key }.getOrDefault(false) + } + } + + private fun isBlocked(sbn: StatusBarNotification): Boolean = + ProcessVisibilityPolicyCache.isBlocked( + VisibilityNotificationExtractor.from(sbn), + NotificationSurface.LOCKSCREEN_COLLAPSED, + ).also { blocked -> + if (blocked && !blockedObserved) { + blockedObserved = true + Log.i(TAG, "Filtered an AOSP collapsed-lockscreen notification") + } + } + + private fun isCollapsedLockscreen(view: View): Boolean { + val root = view.rootView + val stack = synchronized(stacks) { stacks.firstOrNull { it.rootView === root } } ?: return false + return runCatching { onKeyguard.invoke(stack) as Boolean }.getOrDefault(false) + } + + private fun hideShelfIconState(state: Any) { + shelfClampedAmount.setFloat(state, 0f) + shelfAppearAmount.setFloat(state, 0f) + shelfAlpha.setFloat(state, 0f) + shelfHidden.setBoolean(state, true) + shelfVisibleState.setInt(state, ICON_HIDDEN) + } + + private fun hideRow(row: View) { + if (row.visibility == View.GONE && row.alpha == 0f) return + hiddenRows.putIfAbsent(row, RowState(row.visibility, row.alpha)) + row.visibility = View.GONE + row.alpha = 0f + } + + private fun restoreRow(row: View) { + val state = hiddenRows.remove(row) ?: return + row.visibility = state.visibility + row.alpha = state.alpha + row.requestLayout() + (row.parent as? View)?.requestLayout() + } + + private fun restoreTree(row: View) { + restoreGroupOrder(row) + if (row.javaClass.name == ROW) attachedChildrenForRow(row).forEach(::restoreRow) + restoreRow(row) + } + + private fun restoreGroupOrder(row: View) { + val saved = groupOrders.remove(row) ?: return + val current = attachedChildrenForRow(row) + reorderChildren(row, saved.filter(current::contains) + current.filterNot(saved::contains)) + setGroupCount(row, current.size) + } + + private fun accessor(method: Method?, field: Field?): (Any) -> Any? { + method?.isAccessible = true + field?.isAccessible = true + return if (method != null) ({ target -> method.invoke(target) }) else ({ target -> field!!.get(target) }) + } + + private fun findField(type: Class<*>, vararg names: String): Field? { + var current: Class<*>? = type + while (current != null) { + names.forEach { name -> + runCatching { current.getDeclaredField(name) }.getOrNull()?.let { + it.isAccessible = true + return it + } + } + current = current.superclass + } + return null + } + + private fun requireField(type: Class<*>, name: String) = + findField(type, name) ?: error("AOSP $name field is unavailable") + + private data class RowState(val visibility: Int, val alpha: Float) + + private companion object { + const val TAG = "NotificationsMaster" + const val STACK = "com.android.systemui.statusbar.notification.stack.NotificationStackScrollLayout" + const val ROW = "com.android.systemui.statusbar.notification.row.ExpandableNotificationRow" + const val SHELF = "com.android.systemui.statusbar.NotificationShelf" + const val ICON_CONTAINER = "com.android.systemui.statusbar.phone.NotificationIconContainer" + const val ICON_STATE = "$ICON_CONTAINER\$IconState" + const val STATUS_BAR_ICON_VIEW = "com.android.systemui.statusbar.StatusBarIconView" + const val ICON_HIDDEN = 2 + } +} diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/module/NotificationsMasterModule.kt b/app/src/main/java/se/ajpanton/notificationsmaster/module/NotificationsMasterModule.kt index c3e8e96..cc3c511 100644 --- a/app/src/main/java/se/ajpanton/notificationsmaster/module/NotificationsMasterModule.kt +++ b/app/src/main/java/se/ajpanton/notificationsmaster/module/NotificationsMasterModule.kt @@ -24,6 +24,7 @@ class NotificationsMasterModule : XposedModule() { AospModernIconBackend(this), ).firstOrNull { it.install(param.classLoader) } systemUiBackends = listOfNotNull(unlocked) + listOf( + AospLockscreenBackend(this), OneUiLockscreenBackend(this), OneUiLockscreenCardsBackend(this), SamsungAodPluginBackend(this), diff --git a/test-helper/src/main/java/se/ajpanton/notificationsmaster/helper/MainActivity.kt b/test-helper/src/main/java/se/ajpanton/notificationsmaster/helper/MainActivity.kt index 8900255..252e1a1 100644 --- a/test-helper/src/main/java/se/ajpanton/notificationsmaster/helper/MainActivity.kt +++ b/test-helper/src/main/java/se/ajpanton/notificationsmaster/helper/MainActivity.kt @@ -74,6 +74,7 @@ class MainActivity : AppCompatActivity() { "chronometer" -> postChronometer() "timeout" -> postTimeout() "group" -> postGroup() + "group_exception" -> postGroup(exceptionText = true) "cancel_text" -> manager.cancel(TEXT_ID) "cancel_all" -> manager.cancelAll() } @@ -120,8 +121,9 @@ class MainActivity : AppCompatActivity() { private fun postTimeout() = manager.notify(TIMEOUT_ID, base().setContentTitle("Timeout test").setContentText("This should expire automatically.").setTimeoutAfter(2_000).build()) - private fun postGroup() { - manager.notify(GROUP_CHILD_ONE, base().setContentText("Grouped child one").setGroup(GROUP_KEY).build()) + private fun postGroup(exceptionText: Boolean = false) { + val first = if (exceptionText) "Edited message" else "Grouped child one" + manager.notify(GROUP_CHILD_ONE, base().setContentText(first).setGroup(GROUP_KEY).build()) manager.notify(GROUP_CHILD_TWO, base().setContentText("Grouped child two").setGroup(GROUP_KEY).build()) manager.notify(GROUP_SUMMARY, base().setContentTitle("Grouped summary").setGroup(GROUP_KEY).setGroupSummary(true).build()) } @@ -150,6 +152,7 @@ class MainActivity : AppCompatActivity() { "Post very long text" to "very_long_text", "Post inbox" to "inbox", "Post progress" to "progress", "Update progress" to "progress_update", "Post chronometer" to "chronometer", "Post timeout" to "timeout", "Post group" to "group", + "Post group with matching child" to "group_exception", "Cancel text" to "cancel_text", "Cancel all" to "cancel_all", ) }