diff --git a/app/src/main/java/se/ajpanton/statusbartweak/runtime/layout/LockscreenCardsNotificationRowFilter.java b/app/src/main/java/se/ajpanton/statusbartweak/runtime/layout/LockscreenCardsNotificationRowFilter.java new file mode 100644 index 0000000..779f602 --- /dev/null +++ b/app/src/main/java/se/ajpanton/statusbartweak/runtime/layout/LockscreenCardsNotificationRowFilter.java @@ -0,0 +1,416 @@ +package se.ajpanton.statusbartweak.runtime.layout; + +import android.app.KeyguardManager; +import android.app.Notification; +import android.content.Context; +import android.os.Bundle; +import android.os.Parcelable; +import android.service.notification.StatusBarNotification; +import android.view.View; +import android.view.ViewGroup; + +import java.util.ArrayList; +import java.util.Map; +import java.util.WeakHashMap; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; + +import se.ajpanton.statusbartweak.runtime.hooks.ReflectionSupport; +import se.ajpanton.statusbartweak.runtime.mode.NotificationDisplayStyle; +import se.ajpanton.statusbartweak.runtime.mode.SystemNotificationDisplayStyleDetector; +import se.ajpanton.statusbartweak.runtime.settings.RuntimeSettingsCache; +import se.ajpanton.statusbartweak.shell.settings.AppIconRules; +import se.ajpanton.statusbartweak.shell.settings.SbtSettings; + +final class LockscreenCardsNotificationRowFilter { + private static final String MODE_LOCK = "LOCK"; + + private final Map hiddenRows = new WeakHashMap<>(); + + boolean apply(ViewGroup stack) { + return apply(stack, false); + } + + boolean apply(ViewGroup stack, boolean allowAfterKeyguardUnlock) { + if (stack == null) { + return false; + } + Context context = stack.getContext(); + boolean active = isActive(context, allowAfterKeyguardUnlock); + boolean changed = false; + int childCount = stack.getChildCount(); + for (int i = 0; i < childCount; i++) { + View child = stack.getChildAt(i); + if (!isNotificationRow(child)) { + continue; + } + if (active && shouldHideRow(context, child, allowAfterKeyguardUnlock)) { + changed |= hideRow(child); + } else { + changed |= restoreRow(child); + } + } + if (changed) { + requestLayout(stack); + } + return changed; + } + + void restoreAll() { + if (hiddenRows.isEmpty()) { + return; + } + ArrayList rows = new ArrayList<>(hiddenRows.keySet()); + for (View row : rows) { + restoreRow(row); + } + } + + void suppressHiddenRows() { + if (hiddenRows.isEmpty()) { + return; + } + ArrayList rows = new ArrayList<>(hiddenRows.keySet()); + for (View row : rows) { + if (row == null || !row.isAttachedToWindow() || !isNotificationRow(row)) { + hiddenRows.remove(row); + continue; + } + hideRow(row); + } + } + + boolean isActive(Context context) { + return isActive(context, false); + } + + private boolean isActive(Context context, boolean allowAfterKeyguardUnlock) { + if (context == null) { + return false; + } + SbtSettings settings = RuntimeSettingsCache.get(context); + if (settings == null || !settings.clockEnabled) { + return false; + } + if (settings.appIconBlockedModes.isEmpty() && settings.appIconExceptionRules.isEmpty()) { + return false; + } + if (SystemNotificationDisplayStyleDetector.read(context) != NotificationDisplayStyle.CARDS) { + return false; + } + KeyguardManager keyguard = context.getSystemService(KeyguardManager.class); + if (!allowAfterKeyguardUnlock && (keyguard == null || !keyguard.isKeyguardLocked())) { + return false; + } + return true; + } + + boolean shouldHideEntry(Context context, Object entry) { + return shouldHideEntry(context, entry, false); + } + + private boolean shouldHideEntry(Context context, Object entry, boolean allowAfterKeyguardUnlock) { + if (!isActive(context, allowAfterKeyguardUnlock)) { + return false; + } + SbtSettings settings = RuntimeSettingsCache.get(context); + String packageName = packageFor(entry); + if (!AppIconRules.isValidPackageName(packageName)) { + return false; + } + String text = matchText(null, entry); + AppIconRules.ExceptionRule exceptionRule = + matchingExceptionRule(text, settings.appIconExceptionRules.get(packageName)); + return exceptionRule != null + ? exceptionRule.blocksMode(MODE_LOCK) + : AppIconRules.isBlocked(settings.appIconBlockedModes, packageName, MODE_LOCK); + } + + private boolean shouldHideRow(Context context, View row, boolean allowAfterKeyguardUnlock) { + Object entry = entryForRow(row); + if (shouldHideEntry(context, entry, allowAfterKeyguardUnlock)) { + return true; + } + SbtSettings settings = RuntimeSettingsCache.get(context); + String packageName = packageFor(row); + if (!AppIconRules.isValidPackageName(packageName)) { + return false; + } + String text = matchText(row, entry); + AppIconRules.ExceptionRule exceptionRule = + matchingExceptionRule(text, settings.appIconExceptionRules.get(packageName)); + if (exceptionRule != null) { + return exceptionRule.blocksMode(MODE_LOCK); + } + return AppIconRules.isBlocked(settings.appIconBlockedModes, packageName, MODE_LOCK); + } + + private boolean hideRow(View row) { + if (row == null) { + return false; + } + if (row.getVisibility() == View.GONE && row.getAlpha() == 0f) { + return false; + } + hiddenRows.computeIfAbsent(row, ignored -> new HiddenRowState(View.VISIBLE, 1f)); + boolean changed = false; + if (row.getVisibility() != View.GONE) { + row.setVisibility(View.GONE); + changed = true; + } + if (row.getAlpha() != 0f) { + row.setAlpha(0f); + changed = true; + } + return changed; + } + + private boolean restoreRow(View row) { + if (row == null) { + return false; + } + HiddenRowState state = hiddenRows.remove(row); + if (state == null) { + return false; + } + boolean changed = false; + if (row.getVisibility() != state.visibility) { + row.setVisibility(state.visibility); + changed = true; + } + if (row.getAlpha() != state.alpha) { + row.setAlpha(state.alpha); + changed = true; + } + changed |= normalizeRowState(row); + requestLayout(row); + return changed; + } + + private boolean normalizeRowState(View row) { + boolean changed = false; + changed |= setBooleanField(row, "mWillBeGone", false); + changed |= setBooleanField(row, "mInShelf", false); + changed |= setBooleanField(row, "mTransformingInShelf", false); + Object viewState = ReflectionSupport.getFieldValue(row, "mViewState"); + if (viewState != null) { + changed |= setBooleanField(viewState, "hidden", false); + changed |= setBooleanField(viewState, "gone", false); + } + return changed; + } + + private boolean setBooleanField(Object target, String fieldName, boolean value) { + Object current = ReflectionSupport.getFieldValue(target, fieldName); + if (!(current instanceof Boolean) || (Boolean) current == value) { + return false; + } + ReflectionSupport.setFieldValue(target, fieldName, value); + return true; + } + + private void requestLayout(View view) { + if (view == null) { + return; + } + view.requestLayout(); + view.invalidate(); + Object parent = view.getParent(); + if (parent instanceof View parentView) { + parentView.requestLayout(); + parentView.invalidate(); + } + } + + private static boolean isNotificationRow(View view) { + return view != null && view.getClass().getName().contains("ExpandableNotificationRow"); + } + + private Object entryForRow(View row) { + Object value = ReflectionSupport.invokeMethod(row, "getEntry", new Class[0]); + if (value != null) { + return value; + } + value = ReflectionSupport.getFieldValue(row, "mEntry"); + return value != null ? value : ReflectionSupport.getFieldValue(row, "entry"); + } + + private String packageFor(Object target) { + if (target == null) { + return null; + } + StatusBarNotification sbn = statusBarNotification(target); + if (sbn != null && AppIconRules.isValidPackageName(sbn.getPackageName())) { + return sbn.getPackageName(); + } + String key = notificationKey(target); + String fromKey = packageFromNotificationKey(key); + if (AppIconRules.isValidPackageName(fromKey)) { + return fromKey; + } + Object packageName = ReflectionSupport.invokeMethod(target, "getPackageName", new Class[0]); + if (packageName instanceof String string && AppIconRules.isValidPackageName(string)) { + return string; + } + packageName = ReflectionSupport.getFieldValue(target, "packageName"); + if (packageName instanceof String string && AppIconRules.isValidPackageName(string)) { + return string; + } + packageName = ReflectionSupport.getFieldValue(target, "mPackageName"); + if (packageName instanceof String string && AppIconRules.isValidPackageName(string)) { + return string; + } + if (target instanceof View view) { + Object entry = entryForRow(view); + String fromEntry = packageFor(entry); + if (AppIconRules.isValidPackageName(fromEntry)) { + return fromEntry; + } + CharSequence description = view.getContentDescription(); + if (description != null) { + String fromDescription = packageFromNotificationKey(description.toString()); + if (AppIconRules.isValidPackageName(fromDescription)) { + return fromDescription; + } + } + } + return null; + } + + private String notificationKey(Object target) { + Object key = ReflectionSupport.invokeMethod(target, "getKey", new Class[0]); + if (key instanceof String string && !string.isEmpty()) { + return string; + } + key = ReflectionSupport.invokeMethod(target, "getNotificationKey", new Class[0]); + if (key instanceof String string && !string.isEmpty()) { + return string; + } + key = ReflectionSupport.getFieldValue(target, "mKey"); + if (key instanceof String string && !string.isEmpty()) { + return string; + } + key = ReflectionSupport.getFieldValue(target, "key"); + return key instanceof String string && !string.isEmpty() ? string : null; + } + + private StatusBarNotification statusBarNotification(Object target) { + if (target instanceof StatusBarNotification sbn) { + return sbn; + } + Object value = ReflectionSupport.invokeMethod(target, "getSbn", new Class[0]); + if (value instanceof StatusBarNotification sbn) { + return sbn; + } + for (String field : new String[]{"mSbn", "sbn", "mStatusBarNotification", "statusBarNotification"}) { + value = ReflectionSupport.getFieldValue(target, field); + if (value instanceof StatusBarNotification sbn) { + return sbn; + } + } + return null; + } + + private String packageFromNotificationKey(String key) { + if (key == null) { + return null; + } + String normalized = key.trim(); + int suffix = normalized.indexOf('@'); + if (suffix >= 0) { + normalized = normalized.substring(0, suffix); + } + int slash = normalized.indexOf('/'); + if (slash >= 0) { + normalized = normalized.substring(0, slash); + } + return AppIconRules.isValidPackageName(normalized) ? normalized : null; + } + + private String matchText(View row, Object entry) { + StringBuilder out = new StringBuilder(); + append(out, notificationKey(entry)); + append(out, notificationKey(row)); + appendNotification(out, statusBarNotification(entry)); + appendNotification(out, statusBarNotification(row)); + if (row != null) { + append(out, row.getContentDescription()); + } + return out.toString(); + } + + private void appendNotification(StringBuilder out, StatusBarNotification sbn) { + if (sbn == null) { + return; + } + append(out, sbn.getKey()); + append(out, sbn.getPackageName()); + Notification notification = sbn.getNotification(); + Bundle extras = notification != null ? notification.extras : null; + if (extras == null) { + return; + } + append(out, extras.getCharSequence(Notification.EXTRA_TITLE)); + append(out, extras.getCharSequence(Notification.EXTRA_TITLE_BIG)); + append(out, extras.getCharSequence(Notification.EXTRA_TEXT)); + append(out, extras.getCharSequence(Notification.EXTRA_BIG_TEXT)); + append(out, extras.getCharSequence(Notification.EXTRA_SUB_TEXT)); + append(out, extras.getCharSequence(Notification.EXTRA_SUMMARY_TEXT)); + append(out, extras.getCharSequence(Notification.EXTRA_INFO_TEXT)); + CharSequence[] lines = extras.getCharSequenceArray(Notification.EXTRA_TEXT_LINES); + if (lines != null) { + for (CharSequence line : lines) { + append(out, line); + } + } + Parcelable[] messages = extras.getParcelableArray(Notification.EXTRA_MESSAGES); + if (messages != null) { + for (Parcelable message : messages) { + append(out, message != null ? message.toString() : null); + } + } + } + + private void append(StringBuilder out, CharSequence text) { + if (out == null || text == null || text.length() == 0) { + return; + } + if (out.length() > 0) { + out.append('\n'); + } + out.append(text); + } + + private AppIconRules.ExceptionRule matchingExceptionRule( + String text, + ArrayList rules + ) { + if (rules == null || rules.isEmpty()) { + return null; + } + String candidateText = text != null ? text : ""; + for (AppIconRules.ExceptionRule rule : rules) { + if (rule == null || rule.regex == null || rule.regex.trim().isEmpty()) { + continue; + } + try { + if (Pattern.compile(rule.regex).matcher(candidateText).find()) { + return rule; + } + } catch (PatternSyntaxException ignored) { + // Invalid user regexes are ignored instead of blocking every card. + } + } + return null; + } + + private static final class HiddenRowState { + final int visibility; + final float alpha; + + HiddenRowState(int visibility, float alpha) { + this.visibility = visibility; + this.alpha = alpha; + } + } +} diff --git a/app/src/main/java/se/ajpanton/statusbartweak/runtime/layout/StockLayoutCanvasController.java b/app/src/main/java/se/ajpanton/statusbartweak/runtime/layout/StockLayoutCanvasController.java index 5df174b..f7d89ff 100644 --- a/app/src/main/java/se/ajpanton/statusbartweak/runtime/layout/StockLayoutCanvasController.java +++ b/app/src/main/java/se/ajpanton/statusbartweak/runtime/layout/StockLayoutCanvasController.java @@ -3,6 +3,7 @@ package se.ajpanton.statusbartweak.runtime.layout; import android.app.KeyguardManager; import android.content.Context; import android.content.res.Resources; +import android.os.Looper; import android.os.PowerManager; import android.view.View; import android.view.ViewGroup; @@ -69,6 +70,7 @@ final class StockLayoutCanvasController { private final Map rootGeometries = new WeakHashMap<>(); private final Map debugOverlaySignatures = new WeakHashMap<>(); private final Map settingsIdentityByRoot = new WeakHashMap<>(); + private final Map lockscreenCardsRowFilterSignatures = new WeakHashMap<>(); private final Map clockTimeBucketByRoot = new WeakHashMap<>(); private final Set confirmedAodPluginViews = Collections.newSetFromMap(new WeakHashMap<>()); @@ -92,6 +94,8 @@ final class StockLayoutCanvasController { new DebugBoundsOverlayController(); private final LockscreenCardsNotificationStripRenderer lockscreenCardsNotificationStripRenderer = new LockscreenCardsNotificationStripRenderer(); + private final LockscreenCardsNotificationRowFilter lockscreenCardsNotificationRowFilter = + new LockscreenCardsNotificationRowFilter(); private final UnlockedIconSnapshotRenderController unlockedIconRenderController; // The keyguard carrier text lives outside the statusbar root on this device. // Keep a weak source reference so the lockscreen statusbar can render it when @@ -111,11 +115,68 @@ final class StockLayoutCanvasController { } installPluginManagerHooks(classLoader); installCardsNotificationIconSourceHook(classLoader); + installLockscreenCardsNotificationRowFilterHook(classLoader); installUnlockedAppearanceInvalidationHooks(classLoader); + installShadeHeaderAnimationGuardHook(classLoader); installViewRootHook(classLoader); hooksInstalled = true; } + private void installShadeHeaderAnimationGuardHook(ClassLoader classLoader) { + Class viewClass = ReflectionSupport.requireClass("android.view.View", classLoader); + XposedHookSupport.hookAllMethodsIfExists(runtimeContext.getFramework(), viewClass, "setAlpha", chain -> { + Object target = chain.getThisObject(); + Object alphaArg = firstArg(chain.getArgs()); + if (target instanceof View view && alphaArg instanceof Float alpha) { + if (shouldSuppressConflictingShadeHeaderAlpha(view, alpha)) { + return null; + } + } + return chain.proceed(); + }); + XposedHookSupport.hookAllMethodsIfExists(runtimeContext.getFramework(), viewClass, "setTranslationY", chain -> { + Object target = chain.getThisObject(); + Object translationArg = firstArg(chain.getArgs()); + if (target instanceof View view && translationArg instanceof Float translationY) { + if (shouldSuppressConflictingShadeHeaderTranslationY(view, translationY)) { + return null; + } + } + return chain.proceed(); + }); + } + + private void installLockscreenCardsNotificationRowFilterHook(ClassLoader classLoader) { + Class stackClass = ReflectionSupport.findClassIfExists( + "com.android.systemui.statusbar.notification.stack.NotificationStackScrollLayout", + classLoader); + if (stackClass != null) { + XposedHookSupport.hookAllMethodsIfExists( + runtimeContext.getFramework(), + stackClass, + "onViewAdded", + chain -> { + Object result = chain.proceed(); + if (chain.getThisObject() instanceof ViewGroup stack) { + applyLockscreenCardsNotificationRowFilter(stack); + } + return result; + }); + XposedHookSupport.hookAllMethodsIfExists( + runtimeContext.getFramework(), + stackClass, + "onViewRemoved", + chain -> { + Object result = chain.proceed(); + if (chain.getThisObject() instanceof ViewGroup stack) { + applyLockscreenCardsNotificationRowFilter(stack); + } + return result; + }); + } + + } + private void installUnlockedAppearanceInvalidationHooks(ClassLoader classLoader) { Class darkIconDispatcherClass = ReflectionSupport.findClassIfExists( "com.android.systemui.statusbar.phone.DarkIconDispatcherImpl", @@ -296,6 +357,12 @@ final class StockLayoutCanvasController { new ArrayList<>(); Function wrapped = entryObj -> { String key = LockscreenCardsNotificationIconSourceStore.keyForEntry(entryObj); + if (!runtimeContext.getModeStateRepository().isSystemUiShadeLocked() + && lockscreenCardsNotificationRowFilter.shouldHideEntry( + container.getContext(), + entryObj)) { + return null; + } Object result = original.apply(entryObj); if (result == null) { result = fallbackStatusBarIcon(entryObj); @@ -384,6 +451,10 @@ final class StockLayoutCanvasController { return chain.proceed(); }); XposedHookSupport.hookAllMethods(runtimeContext.getFramework(), viewRootClass, "performTraversals", chain -> { + Object rootObjBefore = ReflectionSupport.requireFieldValue(chain.getThisObject(), "mView"); + if (rootObjBefore instanceof View root) { + applyBeforeTraversal(root); + } Object result = chain.proceed(); Object rootObj = ReflectionSupport.requireFieldValue(chain.getThisObject(), "mView"); if (rootObj instanceof View root) { @@ -394,9 +465,44 @@ final class StockLayoutCanvasController { } private void applyBeforeDraw(View root) { + if (!isViewThread(root)) { + return; + } applyBeforeDrawInternal(root); } + private void applyBeforeTraversal(View root) { + if (!isViewThread(root)) { + return; + } + if (root == null) { + return; + } + SbtSettings settings = RuntimeSettingsCache.get(root.getContext()); + if (!settings.clockEnabled) { + return; + } + SceneKey activeScene = runtimeContext.getModeStateRepository().getActiveScene(); + if (activeScene == null) { + return; + } + if (activeScene.isLockscreen() + && activeScene.notificationDisplayStyle() == NotificationDisplayStyle.CARDS + && isNotificationShadeWindowRoot(root)) { + applyLockscreenCardsNotificationRowFilterInTreeIfNeeded( + root, + settings, + activeScene, + true); + lockscreenCardsNotificationRowFilter.suppressHiddenRows(); + } else if (activeScene.isUnlocked() && isNotificationShadeWindowRoot(root)) { + lockscreenCardsNotificationRowFilter.restoreAll(); + } else if (activeScene.isAod() + && activeScene.notificationDisplayStyle() == NotificationDisplayStyle.CARDS) { + lockscreenCardsNotificationRowFilter.suppressHiddenRows(); + } + } + private void applyBeforeDrawInternal(View root) { if (root == null) { return; @@ -416,7 +522,22 @@ final class StockLayoutCanvasController { && activeScene.isLockscreen() && activeScene.notificationDisplayStyle() == NotificationDisplayStyle.CARDS && canContainLockedStatusBarIconSurfaces(root)) { + applyLockscreenCardsNotificationRowFilterInTreeIfNeeded( + root, + settings, + activeScene, + isNotificationShadeWindowRoot(root)); renderLockscreenCardsNotificationStripIfNeeded(root, settings, activeScene); + } else if (activeScene != null + && activeScene.isAod() + && activeScene.notificationDisplayStyle() == NotificationDisplayStyle.CARDS) { + lockscreenCardsRowFilterSignatures.remove(root); + lockscreenCardsNotificationRowFilter.suppressHiddenRows(); + } else { + lockscreenCardsRowFilterSignatures.remove(root); + if (activeScene != null && activeScene.isUnlocked()) { + lockscreenCardsNotificationRowFilter.restoreAll(); + } } if (!isUnlockedStatusBarRoot(root)) { return; @@ -460,6 +581,7 @@ final class StockLayoutCanvasController { Integer previous = settingsIdentityByRoot.put(root, identity); if (previous != null && previous != identity) { dirtyUnlockedLayoutRoots.add(root); + lockscreenCardsRowFilterSignatures.remove(root); } } @@ -560,6 +682,9 @@ final class StockLayoutCanvasController { } private void applyToRoot(View root) { + if (!isViewThread(root)) { + return; + } applyToRootInternal(root); } @@ -889,6 +1014,53 @@ final class StockLayoutCanvasController { return foundInRoot; } + private boolean shouldSuppressConflictingShadeHeaderAlpha(View view, float alpha) { + return view != null + && alpha != view.getAlpha() + && isConflictingShadeHeaderAnimatorWrite(view); + } + + private boolean shouldSuppressConflictingShadeHeaderTranslationY(View view, float translationY) { + return view != null + && translationY != view.getTranslationY() + && isConflictingShadeHeaderAnimatorWrite(view); + } + + private boolean isConflictingShadeHeaderAnimatorWrite(View view) { + if (view == null + || !"split_shade_status_bar".equals(resolveIdName(view)) + || !isLayoutEnabled(view.getContext())) { + return false; + } + SceneKey activeScene = runtimeContext.getModeStateRepository().getActiveScene(); + // Samsung runs two QS header animators during lockscreen cards shade pulls. + // LegacyQsOpenAnimator provides the visible fade. LegacyQsExpandAnimator + // writes conflicting values both while tracking expansion and when it clears + // its state after collapse. + return activeScene != null + && activeScene.isLockscreen() + && activeScene.notificationDisplayStyle() == NotificationDisplayStyle.CARDS + && !runtimeContext.getModeStateRepository().isSystemUiShadeLocked() + && (stackContains("LegacyQsExpandAnimator", "setQsExpansionPosition") + || stackContains("LegacyQsExpandAnimator", "clearAnimationState")); + } + + private boolean stackContains(String classNamePart, String methodName) { + StackTraceElement[] stack = Thread.currentThread().getStackTrace(); + for (StackTraceElement element : stack) { + if (element == null) { + continue; + } + String className = element.getClassName(); + if (className != null + && className.contains(classNamePart) + && methodName.equals(element.getMethodName())) { + return true; + } + } + return false; + } + private void restoreTrackedLockedStatusBarIconSurfaces(View root) { if (trackedLockedStatusBarIconSurfaces.isEmpty()) { return; @@ -967,6 +1139,9 @@ final class StockLayoutCanvasController { Object current = view; while (current instanceof View currentView) { String className = currentView.getClass().getName(); + if ("split_shade_status_bar".equals(resolveIdName(currentView))) { + return false; + } if (className.contains("KeyguardStatusBarView") || className.contains("PhoneStatusBarView") || className.contains("StatusBarWindowView")) { @@ -1054,6 +1229,10 @@ final class StockLayoutCanvasController { || className.contains("StatusBarWindowView"); } + private boolean isNotificationShadeWindowRoot(View root) { + return root != null && root.getClass().getName().contains("NotificationShadeWindowView"); + } + private void rememberLockedCarrierTextSource(View root) { if (lockedCarrierTextSource() != null || root == null || !isCarrierView(root)) { return; @@ -1167,6 +1346,7 @@ final class StockLayoutCanvasController { pendingUnlockedAppearanceRefreshRoots.remove(root); dirtyUnlockedLayoutRoots.remove(root); settingsIdentityByRoot.remove(root); + lockscreenCardsRowFilterSignatures.remove(root); clockTimeBucketByRoot.remove(root); removeDebugOverlay(root); root.requestLayout(); @@ -1242,7 +1422,7 @@ final class StockLayoutCanvasController { } private boolean hideView(View view, boolean keepLaidOut) { - if (view == null) { + if (view == null || !isViewThread(view)) { return false; } int targetVisibility = keepLaidOut ? View.VISIBLE : View.GONE; @@ -1260,7 +1440,7 @@ final class StockLayoutCanvasController { } private boolean restoreView(View view) { - if (view == null) { + if (view == null || !isViewThread(view)) { return false; } HiddenViewState state = hiddenViewStates.remove(view); @@ -1337,6 +1517,7 @@ final class StockLayoutCanvasController { pendingUnlockedAppearanceRefreshRoots.remove(root); dirtyUnlockedLayoutRoots.remove(root); settingsIdentityByRoot.remove(root); + lockscreenCardsRowFilterSignatures.remove(root); clockTimeBucketByRoot.remove(root); if (!isUnlockedStatusBarRoot(root) || !layoutOffCleanedRoots.add(root)) { removeLockscreenCardsNotificationHost(root); @@ -1348,7 +1529,7 @@ final class StockLayoutCanvasController { } private void forceViewVisible(View view) { - if (view == null) { + if (view == null || !isViewThread(view)) { return; } view.setAlpha(1f); @@ -1357,7 +1538,7 @@ final class StockLayoutCanvasController { } private void requestViewAndParentLayout(View view) { - if (view == null) { + if (view == null || !isViewThread(view)) { return; } view.requestLayout(); @@ -1440,6 +1621,72 @@ final class StockLayoutCanvasController { } } + private void applyLockscreenCardsNotificationRowFilter(ViewGroup stack) { + applyLockscreenCardsNotificationRowFilter(stack, false); + } + + private void applyLockscreenCardsNotificationRowFilter(ViewGroup stack, boolean allowAfterKeyguardUnlock) { + if (stack == null) { + return; + } + if (runtimeContext.getModeStateRepository().isSystemUiShadeLocked()) { + lockscreenCardsNotificationRowFilter.restoreAll(); + return; + } + lockscreenCardsNotificationRowFilter.apply(stack, allowAfterKeyguardUnlock); + } + + private void applyLockscreenCardsNotificationRowFilterInTreeIfNeeded( + View root, + SbtSettings settings, + SceneKey activeScene, + boolean allowAfterKeyguardUnlock) { + int signature = lockscreenCardsRowFilterSignature(root, settings, activeScene); + Integer previous = lockscreenCardsRowFilterSignatures.put(root, signature); + if (previous != null && previous == signature) { + return; + } + applyLockscreenCardsNotificationRowFilterInTree(root, allowAfterKeyguardUnlock); + } + + private int lockscreenCardsRowFilterSignature( + View root, + SbtSettings settings, + SceneKey activeScene) { + int result = 17; + result = 31 * result + System.identityHashCode(settings); + result = 31 * result + (activeScene != null ? activeScene.hashCode() : 0); + result = 31 * result + + (runtimeContext.getModeStateRepository().isSystemUiShadeLocked() ? 1 : 0); + result = 31 * result + root.getWidth(); + result = 31 * result + root.getHeight(); + result = 31 * result + (root instanceof ViewGroup group ? group.getChildCount() : 0); + return result; + } + + private void applyLockscreenCardsNotificationRowFilterInTree(View root, boolean allowAfterKeyguardUnlock) { + if (!(root instanceof ViewGroup group)) { + return; + } + ArrayDeque queue = new ArrayDeque<>(); + queue.add(group); + while (!queue.isEmpty()) { + View view = queue.removeFirst(); + if (view instanceof ViewGroup viewGroup) { + if (viewGroup.getClass().getName().contains("NotificationStackScrollLayout")) { + applyLockscreenCardsNotificationRowFilter(viewGroup, allowAfterKeyguardUnlock); + return; + } + for (int i = 0; i < viewGroup.getChildCount(); i++) { + View child = viewGroup.getChildAt(i); + if (child != null) { + queue.addLast(child); + } + } + } + } + } + private View findVisibleBouncerSurface(View root) { if (root == null) { return null; @@ -1542,6 +1789,7 @@ final class StockLayoutCanvasController { pendingUnlockedAppearanceRefreshRoots.remove(root); dirtyUnlockedLayoutRoots.remove(root); settingsIdentityByRoot.remove(root); + lockscreenCardsRowFilterSignatures.remove(root); clockTimeBucketByRoot.remove(root); return null; } @@ -1628,6 +1876,18 @@ final class StockLayoutCanvasController { return powerManager == null || powerManager.isInteractive(); } + private boolean isViewThread(View view) { + Looper current = Looper.myLooper(); + if (current == null || current != Looper.getMainLooper()) { + return false; + } + if (view == null || view.getHandler() == null) { + return true; + } + Looper looper = view.getHandler().getLooper(); + return looper == null || looper == current; + } + private boolean isLayoutEnabled(Context context) { SbtSettings settings = RuntimeSettingsCache.get(context); return settings.clockEnabled; diff --git a/app/src/main/java/se/ajpanton/statusbartweak/runtime/render/LockscreenCardsNotificationIconSourceStore.java b/app/src/main/java/se/ajpanton/statusbartweak/runtime/render/LockscreenCardsNotificationIconSourceStore.java index e1588c4..c020051 100644 --- a/app/src/main/java/se/ajpanton/statusbartweak/runtime/render/LockscreenCardsNotificationIconSourceStore.java +++ b/app/src/main/java/se/ajpanton/statusbartweak/runtime/render/LockscreenCardsNotificationIconSourceStore.java @@ -5,6 +5,7 @@ import android.view.ViewGroup; import java.util.ArrayList; import java.util.Collections; +import java.util.HashSet; import java.util.IdentityHashMap; import java.util.Set; import java.util.WeakHashMap; @@ -47,11 +48,15 @@ public final class LockscreenCardsNotificationIconSourceStore { return result; } int skipCards = visibleShelfStartIndex(container, views); + HashSet visibleCardKeys = visibleCardKeys(root, container); for (int i = 0; i < views.size(); i++) { if (i < skipCards) { continue; } SourceEntry entry = views.get(i); + if (entry.key != null && visibleCardKeys.contains(entry.key)) { + continue; + } View view = entry.view; if (view == null || !view.isAttachedToWindow()) { continue; @@ -61,7 +66,46 @@ public final class LockscreenCardsNotificationIconSourceStore { if (width <= 0 || height <= 0) { continue; } - result.add(new SnapshotSource(view, SnapshotMode.NOTIFICATION_DRAWABLE)); + result.add(new SnapshotSource(view, SnapshotMode.NOTIFICATION_DRAWABLE, entry.key)); + } + return result; + } + + private static HashSet visibleCardKeys(View root, View shelf) { + HashSet result = new HashSet<>(); + if (!(root instanceof ViewGroup group)) { + return result; + } + int shelfTop = Integer.MAX_VALUE; + if (shelf != null) { + se.ajpanton.statusbartweak.runtime.layoutsolver.StatusBarLayoutSolver.Bounds shelfBounds = + ViewGeometry.boundsRelativeTo(root, shelf); + if (shelfBounds != null) { + shelfTop = shelfBounds.top; + } + } + ArrayList stack = new ArrayList<>(); + stack.add(group); + for (int i = 0; i < stack.size(); i++) { + View view = stack.get(i); + if (isNotificationRow(view) && isVisibleCardRow(view)) { + se.ajpanton.statusbartweak.runtime.layoutsolver.StatusBarLayoutSolver.Bounds rowBounds = + ViewGeometry.boundsRelativeTo(root, view); + if (rowBounds != null && rowBounds.top < shelfTop) { + String key = keyForRow(view); + if (key != null && !key.isEmpty()) { + result.add(key); + } + } + } + if (view instanceof ViewGroup viewGroup) { + for (int child = 0; child < viewGroup.getChildCount(); child++) { + View childView = viewGroup.getChildAt(child); + if (childView != null) { + stack.add(childView); + } + } + } } return result; } @@ -176,40 +220,18 @@ public final class LockscreenCardsNotificationIconSourceStore { return null; } - private static int visibleCardCountBeforeShelf(View root, View shelf) { - if (!(root instanceof ViewGroup group)) { - return 0; + private static String keyForRow(View view) { + Object entry = ReflectionSupport.invokeMethod(view, "getEntry", new Class[0]); + String key = keyForEntry(entry); + if (key != null && !key.isEmpty()) { + return key; } - int shelfTop = Integer.MAX_VALUE; - if (shelf != null) { - se.ajpanton.statusbartweak.runtime.layoutsolver.StatusBarLayoutSolver.Bounds shelfBounds = - ViewGeometry.boundsRelativeTo(root, shelf); - if (shelfBounds != null) { - shelfTop = shelfBounds.top; - } + entry = ReflectionSupport.getFieldValue(view, "mEntry"); + key = keyForEntry(entry); + if (key != null && !key.isEmpty()) { + return key; } - int count = 0; - ArrayList stack = new ArrayList<>(); - stack.add(group); - for (int i = 0; i < stack.size(); i++) { - View view = stack.get(i); - if (isNotificationRow(view) && isVisibleCardRow(view)) { - se.ajpanton.statusbartweak.runtime.layoutsolver.StatusBarLayoutSolver.Bounds rowBounds = - ViewGeometry.boundsRelativeTo(root, view); - if (rowBounds != null && rowBounds.top < shelfTop) { - count++; - } - } - if (view instanceof ViewGroup viewGroup) { - for (int child = 0; child < viewGroup.getChildCount(); child++) { - View childView = viewGroup.getChildAt(child); - if (childView != null) { - stack.add(childView); - } - } - } - } - return count; + return keyForIconView(view); } private static boolean isNotificationRow(View view) { diff --git a/app/src/main/java/se/ajpanton/statusbartweak/runtime/render/LockscreenCardsNotificationStripRenderer.java b/app/src/main/java/se/ajpanton/statusbartweak/runtime/render/LockscreenCardsNotificationStripRenderer.java index 9464e90..fcd5631 100644 --- a/app/src/main/java/se/ajpanton/statusbartweak/runtime/render/LockscreenCardsNotificationStripRenderer.java +++ b/app/src/main/java/se/ajpanton/statusbartweak/runtime/render/LockscreenCardsNotificationStripRenderer.java @@ -30,6 +30,7 @@ public final class LockscreenCardsNotificationStripRenderer { private final SnapshotRenderer renderer = new SnapshotRenderer(false, false, false); private final WeakHashMap> pillViewsByHost = new WeakHashMap<>(); + private final NotificationSeenAppReporter seenAppReporter = new NotificationSeenAppReporter(); public RenderResult render( View root, @@ -65,8 +66,10 @@ public final class LockscreenCardsNotificationStripRenderer { clear(host); return RenderResult.empty(); } + ArrayList unfilteredPlacements = renderer.rawPlacements(root, sources); + seenAppReporter.report(root.getContext(), unfilteredPlacements); ArrayList placements = SnapshotIconFilter.notificationPlacements( - renderer.rawPlacements(root, sources), + unfilteredPlacements, settings, scene); StripLayout layout = layoutPlacements(root, sourceRoot, placements, settings); diff --git a/app/src/main/java/se/ajpanton/statusbartweak/runtime/render/NotificationSeenAppReporter.java b/app/src/main/java/se/ajpanton/statusbartweak/runtime/render/NotificationSeenAppReporter.java new file mode 100644 index 0000000..460513f --- /dev/null +++ b/app/src/main/java/se/ajpanton/statusbartweak/runtime/render/NotificationSeenAppReporter.java @@ -0,0 +1,37 @@ +package se.ajpanton.statusbartweak.runtime.render; + +import android.content.Context; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; + +import se.ajpanton.statusbartweak.shell.settings.SbtSettings; + +final class NotificationSeenAppReporter { + private static final long MIN_REPORT_INTERVAL_MS = 500L; + + private final Map lastReportedMs = new HashMap<>(); + + void report(Context context, ArrayList placements) { + if (context == null || placements == null || placements.isEmpty()) { + return; + } + long now = System.currentTimeMillis(); + HashSet reportedThisPass = new HashSet<>(); + for (SnapshotPlacement placement : placements) { + String packageName = SnapshotIconFilter.notificationPackage( + placement != null ? placement.key : null); + if (packageName == null || !reportedThisPass.add(packageName)) { + continue; + } + Long lastReported = lastReportedMs.get(packageName); + if (lastReported != null && now - lastReported < MIN_REPORT_INTERVAL_MS) { + continue; + } + lastReportedMs.put(packageName, now); + SbtSettings.noteSeenIconApp(context, packageName, now); + } + } +} diff --git a/app/src/main/java/se/ajpanton/statusbartweak/runtime/render/OwnedIconHostManager.java b/app/src/main/java/se/ajpanton/statusbartweak/runtime/render/OwnedIconHostManager.java index aa9a789..bd5352c 100644 --- a/app/src/main/java/se/ajpanton/statusbartweak/runtime/render/OwnedIconHostManager.java +++ b/app/src/main/java/se/ajpanton/statusbartweak/runtime/render/OwnedIconHostManager.java @@ -4,6 +4,8 @@ import android.view.View; import android.view.ViewGroup; import android.widget.FrameLayout; +import java.util.ArrayList; +import java.util.List; import java.util.Objects; /** @@ -26,6 +28,13 @@ public final class OwnedIconHostManager { "statusbartweak.unlocked.chip.host"; public static final String TAG_LOCKSCREEN_CARDS_NOTIFICATION_HOST = "statusbartweak.lockscreen.cards.notification.host"; + private static final String[] UNLOCKED_HOST_ORDER = { + TAG_UNLOCKED_STATUS_HOST, + TAG_UNLOCKED_NOTIFICATION_HOST, + TAG_UNLOCKED_CLOCK_HOST, + TAG_UNLOCKED_CARRIER_HOST, + TAG_UNLOCKED_CHIP_HOST + }; public FrameLayout ensureUnlockedStatusHost(View root) { return ensureHost(root, TAG_UNLOCKED_STATUS_HOST); @@ -58,7 +67,6 @@ public final class OwnedIconHostManager { } FrameLayout existing = findDirectHost(parent, tag); if (existing != null) { - existing.bringToFront(); return existing; } FrameLayout host = new FrameLayout(parent.getContext()); @@ -94,19 +102,22 @@ public final class OwnedIconHostManager { } public void setUnlockedHostsVisible(View root, boolean visible) { - setHostVisible(root, TAG_UNLOCKED_STATUS_HOST, visible); - setHostVisible(root, TAG_UNLOCKED_NOTIFICATION_HOST, visible); - setHostVisible(root, TAG_UNLOCKED_CLOCK_HOST, visible); - setHostVisible(root, TAG_UNLOCKED_CARRIER_HOST, visible); - setHostVisible(root, TAG_UNLOCKED_CHIP_HOST, visible); + for (String tag : UNLOCKED_HOST_ORDER) { + setHostVisible(root, tag, visible); + } } public void bringUnlockedHostsToFront(View root) { - bringHostToFront(root, TAG_UNLOCKED_STATUS_HOST); - bringHostToFront(root, TAG_UNLOCKED_NOTIFICATION_HOST); - bringHostToFront(root, TAG_UNLOCKED_CLOCK_HOST); - bringHostToFront(root, TAG_UNLOCKED_CARRIER_HOST); - bringHostToFront(root, TAG_UNLOCKED_CHIP_HOST); + if (!(root instanceof ViewGroup parent)) { + return; + } + ArrayList hosts = unlockedHosts(parent); + if (hosts.isEmpty() || unlockedHostsAreAlreadyAtFront(parent, hosts)) { + return; + } + for (View host : hosts) { + host.bringToFront(); + } } public boolean isOwnedHost(View view) { @@ -140,17 +151,35 @@ public final class OwnedIconHostManager { if (host == null) { return; } - host.setVisibility(visible ? View.VISIBLE : View.INVISIBLE); + int visibility = visible ? View.VISIBLE : View.INVISIBLE; + if (host.getVisibility() != visibility) { + host.setVisibility(visibility); + } } - private void bringHostToFront(View root, String tag) { - if (!(root instanceof ViewGroup parent) || tag == null) { - return; + private ArrayList unlockedHosts(ViewGroup parent) { + ArrayList hosts = new ArrayList<>(); + for (String tag : UNLOCKED_HOST_ORDER) { + View host = findDirectHost(parent, tag); + if (host != null) { + hosts.add(host); + } } - FrameLayout host = findDirectHost(parent, tag); - if (host != null) { - host.bringToFront(); + return hosts; + } + + private boolean unlockedHostsAreAlreadyAtFront(ViewGroup parent, List hosts) { + if (parent == null || hosts == null || hosts.isEmpty() + || parent.getChildCount() < hosts.size()) { + return false; } + int start = parent.getChildCount() - hosts.size(); + for (int i = 0; i < hosts.size(); i++) { + if (parent.getChildAt(start + i) != hosts.get(i)) { + return false; + } + } + return true; } } diff --git a/app/src/main/java/se/ajpanton/statusbartweak/runtime/render/SnapshotModels.java b/app/src/main/java/se/ajpanton/statusbartweak/runtime/render/SnapshotModels.java index 8006fbb..dae5335 100644 --- a/app/src/main/java/se/ajpanton/statusbartweak/runtime/render/SnapshotModels.java +++ b/app/src/main/java/se/ajpanton/statusbartweak/runtime/render/SnapshotModels.java @@ -11,8 +11,12 @@ import se.ajpanton.statusbartweak.runtime.layoutsolver.StatusBarLayoutSolver.Bou record SnapshotSource( View view, - SnapshotMode mode + SnapshotMode mode, + String keyHint ) { + SnapshotSource(View view, SnapshotMode mode) { + this(view, mode, null); + } } enum SnapshotMode { diff --git a/app/src/main/java/se/ajpanton/statusbartweak/runtime/render/SnapshotRenderer.java b/app/src/main/java/se/ajpanton/statusbartweak/runtime/render/SnapshotRenderer.java index f3c7573..d53f49e 100644 --- a/app/src/main/java/se/ajpanton/statusbartweak/runtime/render/SnapshotRenderer.java +++ b/app/src/main/java/se/ajpanton/statusbartweak/runtime/render/SnapshotRenderer.java @@ -347,6 +347,10 @@ final class SnapshotRenderer { private String keyFor(SnapshotSource source, int order) { View view = source.view(); + String keyHint = source.keyHint(); + if (keyHint != null && !keyHint.isEmpty()) { + return keyHint; + } String slot = reflectString(view, "getSlot", "mSlot", "slot"); if (!slot.isEmpty()) { return slot; diff --git a/app/src/main/java/se/ajpanton/statusbartweak/runtime/render/UnlockedLayoutPlanner.java b/app/src/main/java/se/ajpanton/statusbartweak/runtime/render/UnlockedLayoutPlanner.java index 05dac57..3653d8e 100644 --- a/app/src/main/java/se/ajpanton/statusbartweak/runtime/render/UnlockedLayoutPlanner.java +++ b/app/src/main/java/se/ajpanton/statusbartweak/runtime/render/UnlockedLayoutPlanner.java @@ -27,6 +27,8 @@ final class UnlockedLayoutPlanner { private final SnapshotRenderer chipRenderer; private final UnlockedChipPlacementController chipPlacementController; private final UnlockedVerticalOffsetScaler offsetScaler; + private final NotificationSeenAppReporter notificationSeenAppReporter = + new NotificationSeenAppReporter(); UnlockedLayoutPlanner( SnapshotRenderer statusRenderer, @@ -130,11 +132,13 @@ final class UnlockedLayoutPlanner { } int notificationCount = notificationCountForScene(settings, scene); if (notificationCount > 0) { + ArrayList unfilteredPlacements = + notificationRenderer.rawPlacements(root, collectedIcons.notificationIcons); + notificationSeenAppReporter.report( + root != null ? root.getContext() : null, + unfilteredPlacements); ArrayList rawPlacements = - SnapshotIconFilter.notificationPlacements( - notificationRenderer.rawPlacements(root, collectedIcons.notificationIcons), - settings, - scene); + SnapshotIconFilter.notificationPlacements(unfilteredPlacements, settings, scene); if (scene != null && scene.isLockscreen() && scene.notificationDisplayStyle() == NotificationDisplayStyle.DOT) { diff --git a/app/src/main/java/se/ajpanton/statusbartweak/shell/settings/SbtSettings.java b/app/src/main/java/se/ajpanton/statusbartweak/shell/settings/SbtSettings.java index a5d7db4..10ab8c0 100644 --- a/app/src/main/java/se/ajpanton/statusbartweak/shell/settings/SbtSettings.java +++ b/app/src/main/java/se/ajpanton/statusbartweak/shell/settings/SbtSettings.java @@ -868,6 +868,29 @@ public final class SbtSettings { } } + public static void noteSeenIconApp(Context context, String packageName, long firstSeenMs) { + if (context == null || !AppIconRules.isValidPackageName(packageName)) { + return; + } + try { + if (!MODULE_PACKAGE.equals(context.getPackageName()) && hasSettingsProvider(context)) { + Uri uri = Uri.parse("content://" + SbtSettingsProvider.AUTHORITY); + Bundle extras = new Bundle(); + extras.putString(AppIconRules.EXTRA_PACKAGE, packageName); + extras.putLong(AppIconRules.EXTRA_FIRST_SEEN_MS, firstSeenMs); + context.getContentResolver() + .call(uri, SbtSettingsProvider.METHOD_NOTE_SEEN_ICON_APP, null, extras); + return; + } + SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + Map seen = AppIconRules.loadSeenSession(prefs); + AppIconRules.noteSeen(seen, packageName, firstSeenMs); + AppIconRules.saveSeenSession(prefs, seen); + } catch (Throwable ignored) { + // This list is advisory UI state; rendering should never fail because it cannot be updated. + } + } + private static boolean hasSettingsProvider(Context context) { if (context == null) { return false; diff --git a/app/src/main/java/se/ajpanton/statusbartweak/shell/ui/LayoutSectionCopySupport.java b/app/src/main/java/se/ajpanton/statusbartweak/shell/ui/LayoutSectionCopySupport.java index 90e43a8..242820e 100644 --- a/app/src/main/java/se/ajpanton/statusbartweak/shell/ui/LayoutSectionCopySupport.java +++ b/app/src/main/java/se/ajpanton/statusbartweak/shell/ui/LayoutSectionCopySupport.java @@ -429,13 +429,9 @@ final class LayoutSectionCopySupport { row.setTag(COPY_ROW_TAG); row.setGravity(android.view.Gravity.CENTER_VERTICAL); row.setOrientation(LinearLayout.HORIZONTAL); - TextView label = new TextView(context); - label.setText(R.string.layout_copy_from); - row.addView(label); LinearLayout.LayoutParams spinnerParams = new LinearLayout.LayoutParams( ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); - spinnerParams.leftMargin = dp(context, 8); row.addView(spinner, spinnerParams); LinearLayout.LayoutParams rowParams = new LinearLayout.LayoutParams( ViewGroup.LayoutParams.WRAP_CONTENT, @@ -460,9 +456,6 @@ final class LayoutSectionCopySupport { row.setTag(COPY_ROW_TAG); row.setOrientation(LinearLayout.HORIZONTAL); row.setGravity(android.view.Gravity.CENTER_VERTICAL); - TextView label = new TextView(context); - label.setText(R.string.layout_copy_from); - row.addView(label); Spinner spinner = new Spinner(context); spinner.setAdapter(new CopyAdapter(context, options)); spinner.setSelection(0, false); @@ -491,7 +484,6 @@ final class LayoutSectionCopySupport { LinearLayout.LayoutParams spinnerParams = new LinearLayout.LayoutParams( ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); - spinnerParams.leftMargin = dp(context, 8); row.addView(spinner, spinnerParams); return row; }